From 5a0515ab1370173156c2bf75d2473a709bdedd5c Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Thu, 24 Sep 2026 15:47:58 +0200 Subject: [PATCH] Test that the camera delivers frames and follows its settings to black and white --- config/config.example.ini | 5 + docs/deployment.md | 8 +- docs/known-issues.md | 4 +- docs/tower-installation.md | 3 +- include/fgc/HardwareChecks.h | 38 ++++++ include/fgc/ICameraSource.h | 31 +++++ include/fgc/TestRunner.h | 13 ++- include/fgc/VimbaCameraSource.h | 5 + include/fgc/mock/MockCameraSource.h | 55 ++++++++- scripts/install-tower | 25 ++-- src/camera/VimbaCameraSource.cpp | 90 ++++++++++++++ src/core/Application.cpp | 5 +- src/core/HardwareChecks.cpp | 83 +++++++++++++ src/core/HelpText.cpp | 7 ++ src/core/TestRunner.cpp | 174 ++++++++++++++++++++++++++++ tests/test_hardwarechecks.cpp | 63 ++++++++++ tests/test_testrunner.cpp | 86 ++++++++++++++ 17 files changed, 679 insertions(+), 16 deletions(-) diff --git a/config/config.example.ini b/config/config.example.ini index 8693f0a..4ebe8b7 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -284,6 +284,11 @@ imu_drift_window_ms = 60000 ; long static window for the yaw-drift fit imu_yaw_noise_max_deg = 0.5 imu_yaw_drift_max_deg_min = 1.0 imu_expected_hz = 100 +; test camera: frames to grab, frames to let pass after forcing a setting before +; judging one, and the share of pixels that must clip at each forced extreme. +camera_frames = 5 +camera_settle_frames = 3 +camera_clip_min_pct = 99 ; imu_expected_profile = VRU_general ; assert the active XKF profile, if set backlash_step_counts = 200,500,1000 ; small steps probing reversal dead-band balance_steps = 10 ; pitch up/down current-profile resolution diff --git a/docs/deployment.md b/docs/deployment.md index febfdd0..d6f29a9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -67,9 +67,11 @@ After an update, check it: ssh ggs@ 'cd ~/projects/fwt_2a/software && scripts/install-tower --verify' ``` -It changes nothing; it runs `test hardware` inside the running program (pausing -and then resuming capture) and checks the files, the firmware's pins and the -boot service. +It changes nothing; it runs `test hardware` and `test camera` inside the running +program (pausing and then resuming capture) and checks the files, the firmware's +pins and the boot service. `test camera` takes about 15 s: it grabs frames, then +drives the camera's own settings to force every pixel black and then white - +which holds whatever the camera is pointed at - and puts the profile back. `--push-config` also overwrites the tower's `config.ini` with the dev box's, backing the tower's copy up first. It exists for the development tower; a diff --git a/docs/known-issues.md b/docs/known-issues.md index 6615439..e9dc482 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -29,8 +29,8 @@ doctest unit-test suite (`ctest`). |---|-------|--------| | 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Home, then run `gimbal heading setup` (yaw, needs no IMU) and `gimbal pitch setup` (pitch, needs the IMU) on the rig. | | 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). | -| 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. | -| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. | +| 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. **2026-09-24:** tower 121 has still never imaged a scene - a frame grabbed directly and the program's own saved images (2026-09-21) are a smooth grey gradient with no edges, i.e. the lens is covered or grossly out of focus. Tower 111's images from the same afternoon show nothing sharp anywhere either (soft blobs, no edges) - defocus, a cover or a fogged window; unexplained. `test camera` (below) proves the camera and its data path work in both cases; it cannot, by design, judge the picture. | +| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. **Seen in real data 2026-09-24:** of eight consecutive images tower 111 kept at 8 deg/s, one was 98.6 % clipped white, one 57 % black and two about half clipped - frames kept before the continuous auto-exposure had caught up with the new view. | | 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. **Solved on tower 121 by the setup kit** (`hardware/lattepanda-iota/preconditions/`): a systemd-tmpfiles drop-in for usbfs and two udev rules, all installed by `install.sh --preconditions`. Verified 2026-09-23 — `usbfs_memory_mb` reads 1000 after **6 days of uptime**, so it survived the boot, and both rules are in `/etc/udev/rules.d/`; and again across a deliberate reboot the same day, when `install-tower --verify` read `usbfs_memory_mb` 1000 and the camera port's `usb3_lpm_permit` 0 (a check `test hardware camera` now makes too, once `[Test] usb3_lpm_path` is set - the installer sets it). Note it is **tmpfiles, not the kernel cmdline** the original entry proposed. **On the Delta fleet**: tower 111 read 16 and `u1_u2` on 2026-09-23, and the Delta kit (`hardware/lattepanda-3-delta/`, the same two files) was applied the same day - 1000 and `0` afterwards, confirmed by `test hardware camera`. Confirmed across a reboot on 2026-09-24 (`install-tower --verify`). **Solved on both boards**; open only for Delta towers the kit has not reached. | ## Camera acquisition (USB3) — root cause + approach diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 5c17300..a0db08c 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -1049,7 +1049,7 @@ scripts/install-tower --verify It checks the USB preconditions, that `config.ini` and `hardware.ini` load together, that `hardware.ini` has no gaps, that the firmware reports the pins it -declares, and the boot service - and runs `test hardware`: inside the running +declares, and the boot service - and runs `test hardware` and `test camera`: inside the running program when it is up (pausing capture, then resuming it), or as a one-shot run when it is not. By hand, the one-shot form is: @@ -1071,6 +1071,7 @@ freshly flashed board is not failed for values the host had not sent yet. | IMU / ambient sensor | `test hardware imu` / `env` | none declared | ✅ MTi streaming, SHT41 CRC-valid on a real I2C bus | | Camera enumerates | `ListCameras_VmbC` reports the `DEV_` id in `hardware.ini` | ✅ `DEV_1AB22C0B7D4E` | ✅ `DEV_1AB22C049CD1` | | Camera profile applies | startup logs `profile 'standard' applied ... (N settings)` | ✅ 17206 settings | ✅ 3743 settings | +| Camera works, whatever it sees | `test camera`: whole frames, then every pixel forced to black (minimum exposure and gain) and to white (black level at maximum), then the profile restored | ✅ 2026-09-24, with its lens covered: 100.00 % black, 100.00 % white | not run yet (needs the current host) | | Firmware identity | `DUMP BEGIN build=` is the revision flashed | ✅ | ✅ `a0b1e4a` | | Driver answers over SPI | `IOIN` reads version `0x30` for every axis | ✅ | ✅ both axes | | Firmware holds `hardware.ini` | `test hardware motor`: values, current, pins | ✅ | ✅ every leaf, 2026-09-24 | diff --git a/include/fgc/HardwareChecks.h b/include/fgc/HardwareChecks.h index 963b374..7228c73 100644 --- a/include/fgc/HardwareChecks.h +++ b/include/fgc/HardwareChecks.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include namespace fgc { @@ -134,4 +136,40 @@ CheckResult checkPinsBuilt(const std::string& label, const DeclaredValue* values // axis has not homed, and nothing is checked. CheckResult checkTravelFitsPositive(const std::string& axis, long center, long half_travel); +// ---- `test camera` ------------------------------------------------------------ + +// What fraction of a frame's bytes sit at each end of the range. Every channel +// byte counts, so an RGB8 frame's 12 M bytes are 12 M samples. +struct PixelStats { + size_t samples = 0; + double mean = 0.0; + double pct_low = 0.0; // bytes <= low + double pct_high = 0.0; // bytes >= high +}; +PixelStats pixelStats(const std::vector& data, uint8_t low = 5, uint8_t high = 250); + +// A run of frames asked for through the program's own camera path. +struct FrameRun { + int wanted = 0; + int got = 0; // arrived complete within the timeout + int timeout_ms = 0; // per frame + bool consistent = true; // every frame the size and depth of the first + uint32_t width = 0, height = 0; + int channels = 0; + long long dropped = 0; // incomplete deliveries during the run +}; + +// Did the camera deliver whole, well-formed frames? Says nothing about what is +// in them - that is checkClipped()'s job, done at forced settings so the scene +// cannot decide the verdict. +CheckResult checkFramesDelivered(const std::string& camera, const FrameRun& run); + +// Did a frame taken at forced settings clip to the end they force? `white` +// false = black. The camera is driven there by its own settings (minimum +// exposure and gain for black; black level at maximum for white), so this holds +// in fog, at night and under a lens cap alike - and proves the settings reach the +// sensor, the sensor's output reaches the host, and the full range survives. +CheckResult checkClipped(const std::string& camera, bool white, const PixelStats& stats, + double min_pct); + } // namespace fgc diff --git a/include/fgc/ICameraSource.h b/include/fgc/ICameraSource.h index c5622f8..a544a43 100644 --- a/include/fgc/ICameraSource.h +++ b/include/fgc/ICameraSource.h @@ -83,6 +83,37 @@ public: // Live identity/telemetry/stats per camera, for the expanded camera view. // Default: none. Implementations may refresh a throttled cache, so non-const. virtual std::vector deviceInfo() { return {}; } + + // ---- For `test camera` only ------------------------------------------------ + // The camera's configuration otherwise changes through setProfile() and + // nothing else; these let the self-test drive a camera to known extremes and + // then put it back. Defaults: unsupported, which the test reports as "not + // checked" rather than as a fault. + + // The next complete frame from one camera that arrives after this call, into + // `out`. False if none arrives within `timeout_ms`. The stream must be + // running (start()); this does not start it. + virtual bool grabFrame(int /*camera_index*/, Frame& /*out*/, int /*timeout_ms*/) { + return false; + } + + // Set one camera feature by its GenICam name, the value given as text ("Off", + // "66.4", "true"). False if the camera has no such feature or refuses it. + virtual bool setFeature(int /*camera_index*/, const std::string& /*name*/, + const std::string& /*value*/) { + return false; + } + + // A numeric feature's range. False if absent or not numeric. + virtual bool featureRange(int /*camera_index*/, const std::string& /*name*/, + double& /*min*/, double& /*max*/) { + return false; + } + + // Put a camera back on the profile it was configured with, undoing every + // setFeature() since. False if that failed - which leaves the camera on + // settings nobody chose, so callers must say so loudly. + virtual bool restoreProfile(int /*camera_index*/) { return false; } }; } // namespace fgc diff --git a/include/fgc/TestRunner.h b/include/fgc/TestRunner.h index c8eb15b..a82493a 100644 --- a/include/fgc/TestRunner.h +++ b/include/fgc/TestRunner.h @@ -42,13 +42,20 @@ enum TestLeaf : uint32_t { T_HW_IMU = 1u << 14, T_HW_ENV = 1u << 15, T_HW_CAMERA = 1u << 16, + // `test camera`: does the camera deliver whole frames, and can its own + // settings drive every pixel to black and to white? Forced settings, so the + // verdict does not depend on the scene, the weather or the time of day. + T_CAM_FRAMES = 1u << 17, + T_CAM_DARK = 1u << 18, + T_CAM_WHITE = 1u << 19, }; constexpr uint32_t T_GIMBAL_ALL = T_GIMBAL_HOMING | T_GIMBAL_ENCODER | T_GIMBAL_FRICTION | T_GIMBAL_BACKLASH | T_GIMBAL_BALANCE; constexpr uint32_t T_IMU_ALL = T_IMU_CONFIG | T_IMU_HEALTH | T_IMU_DRIFT; constexpr uint32_t T_HOST_ALL = T_HOST_THERMAL | T_HOST_DISK | T_HOST_MEMORY | T_HOST_LOAD; constexpr uint32_t T_HW_ALL = T_HW_AXES | T_HW_MOTOR | T_HW_IMU | T_HW_ENV | T_HW_CAMERA; -constexpr uint32_t T_ALL = T_GIMBAL_ALL | T_IMU_ALL | T_HOST_ALL | T_HW_ALL; +constexpr uint32_t T_CAM_ALL = T_CAM_FRAMES | T_CAM_DARK | T_CAM_WHITE; +constexpr uint32_t T_ALL = T_GIMBAL_ALL | T_IMU_ALL | T_HOST_ALL | T_HW_ALL | T_CAM_ALL; // Which leaf masks need real motor / IMU hardware (used by the caller to guard). // Needs a HOMED gimbal, not merely a motor link: the caller refuses the whole @@ -75,6 +82,9 @@ struct HardwareTestContext { ICameraSource* camera = nullptr; std::vector configured_cameras; // [Camera.] blocks in open order + // Per camera, same order: how long a frame may take under its active profile + // ([Camera..] frame_timeout_ms). Missing => 2000. + std::vector frame_timeout_ms; std::string i2c_device; // [Env] i2c_device, for the adapter-class check // The degree maps, so a declared deg/s can be turned back into the VMAX the // firmware should be holding. @@ -136,6 +146,7 @@ private: void testHwImu(TestReport& r); void testHwEnv(TestReport& r); void testHwCamera(TestReport& r); + void testCamera(TestReport& r); // all three `test camera` leaves, one stream session IMotorController& motor_; IImuSource* imu_; diff --git a/include/fgc/VimbaCameraSource.h b/include/fgc/VimbaCameraSource.h index ffddf04..672d75d 100644 --- a/include/fgc/VimbaCameraSource.h +++ b/include/fgc/VimbaCameraSource.h @@ -5,6 +5,7 @@ #include "fgc/ICameraSource.h" #include +#include #include namespace fgc { @@ -35,6 +36,10 @@ public: void setFrameCallback(FrameCallback cb) override; int cameraCount() const override; std::vector deviceInfo() override; + bool grabFrame(int camera_index, Frame& out, int timeout_ms) override; + bool setFeature(int camera_index, const std::string& name, const std::string& value) override; + bool featureRange(int camera_index, const std::string& name, double& min, double& max) override; + bool restoreProfile(int camera_index) override; private: struct Impl; diff --git a/include/fgc/mock/MockCameraSource.h b/include/fgc/mock/MockCameraSource.h index 6c3eb6a..849044c 100644 --- a/include/fgc/mock/MockCameraSource.h +++ b/include/fgc/mock/MockCameraSource.h @@ -3,8 +3,11 @@ #include "fgc/ICameraSource.h" #include "fgc/Logger.h" +#include #include +#include #include +#include namespace fgc { @@ -43,6 +46,43 @@ public: return true; } + // `test camera`. The mock behaves like the real one: its own settings drive + // it to black (minimum exposure and gain) and to white (black level at its + // maximum), so the self-test passes against it - and a fault can be switched + // on to prove the test fails when it should. + enum class Fault { None, NoFrames, IgnoresSettings }; + void setFault(Fault f) { fault_ = f; } + + bool grabFrame(int camera_index, Frame& out, int timeout_ms) override { + if (!started_ || camera_index < 0 || camera_index >= count_) return false; + if (fault_ == Fault::NoFrames) { + std::this_thread::sleep_for(std::chrono::milliseconds(std::min(timeout_ms, 20))); + return false; + } + out = synth(); + out.cam_id = camera_index; + ++frames_; + return true; + } + bool setFeature(int camera_index, const std::string& name, const std::string& value) override { + if (camera_index < 0 || camera_index >= count_) return false; + features_[name] = value; + return true; + } + bool featureRange(int camera_index, const std::string& name, double& min, + double& max) override { + if (camera_index < 0 || camera_index >= count_) return false; + if (name == "ExposureTime") { min = 66.357; max = 9999971.0; return true; } + if (name == "Gain") { min = 0.0; max = 48.0; return true; } + if (name == "BlackLevel") { min = 0.0; max = 4095.0; return true; } + return false; + } + bool restoreProfile(int camera_index) override { + if (camera_index < 0 || camera_index >= count_) return false; + features_.clear(); + return true; + } + // Synthetic device info so the expanded camera view renders (and is testable) // without hardware. Non-hardware fields (temperature, exposure...) stay at 0. std::vector deviceInfo() override { @@ -77,7 +117,18 @@ private: .count(); f.data.resize(static_cast(width_) * height_ * 3); - const double level = 110.0; + // The scene's mid grey, unless the self-test has forced an extreme. + double level = 110.0; + auto num = [&](const char* k, double dflt) { + auto it = features_.find(k); + return it == features_.end() ? dflt : std::stod(it->second); + }; + if (fault_ != Fault::IgnoresSettings) { + if (num("BlackLevel", 0.0) >= 4095.0) level = 400.0; // clips white + else if (features_.count("ExposureTime") && num("ExposureTime", 1e9) < 100.0 && + num("Gain", 99.0) <= 0.0) + level = -100.0; // clips black + } const uint8_t phase = static_cast(f.timestamp_ms); for (uint32_t y = 0; y < height_; ++y) { @@ -105,6 +156,8 @@ private: long long frames_ = 0; std::string profile_; FrameCallback callback_; + Fault fault_ = Fault::None; + std::map features_; // what setFeature() has set }; } // namespace fgc diff --git a/scripts/install-tower b/scripts/install-tower index 40ba0ae..1791686 100755 --- a/scripts/install-tower +++ b/scripts/install-tower @@ -315,7 +315,9 @@ EOF # ----------------------------------------------------------------- verify --- TEST_RC="" -run_hardware_test() { +# run_test SELECTION - `test hardware`, `test camera`, ... Sets TEST_RC. +run_test() { + local sel="$1" # The app holds the serial port, so a test runs INSIDE it when it is up, and # as a one-shot run of the binary when it is not. if app_running; then @@ -325,7 +327,7 @@ run_hardware_test() { if tmux capture-pane -p -t "$pane" | grep -q '╭'; then tmux send-keys -t "$pane" ':'; sleep 0.5 fi - tmux send-keys -t "$pane" 'test hardware' Enter + tmux send-keys -t "$pane" "test $sel" Enter for _ in $(seq 1 60); do after="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" [[ -n $after && $after != "$before" ]] && break @@ -346,8 +348,8 @@ run_hardware_test() { local out="$WORK/test.out" TEST_RC=0 ( cd "$REPO" && GENICAM_GENTL64_PATH="${GENICAM_GENTL64_PATH:-/opt/VimbaX/cti}" \ - "$BIN" --no-mqtt --test hardware ) > "$out" 2>&1 < /dev/null || TEST_RC=$? - grep -E '\-\-test|test hw_|test overall' "$out" | sed 's/^/ /' + "$BIN" --no-mqtt --test "$sel" ) > "$out" 2>&1 < /dev/null || TEST_RC=$? + grep -E '\-\-test|test [a-z]+_[a-z]+:|test overall' "$out" | sed 's/^/ /' local rep; rep="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" [[ -n $rep ]] && grep -E ' ; ' "$rep" | sed 's/^/ /' fi @@ -378,8 +380,12 @@ verify() { local want_pins; want_pins="$(declared_pins)" check "firmware pins" "$(pins_match "$now" "$want_pins" && echo 0 || echo 1)" \ "running ${now:-unknown}; declared $want_pins" - run_hardware_test + run_test hardware check "test hardware" "$TEST_RC" "$([[ $TEST_RC == 0 ]] && echo PASS || echo "exit $TEST_RC")" + # Frames, and every pixel following the camera's own settings to black and to + # white - which holds whatever the camera is pointed at. + run_test camera + check "test camera" "$TEST_RC" "$([[ $TEST_RC == 0 ]] && echo PASS || echo "exit $TEST_RC")" # Only once there IS a service: a tower run by hand is a choice, not a fault. if [[ -r $UNIT ]]; then check "boot service" "$([[ $S_SVC_ENABLED == enabled ]] && echo 0 || echo 1)" "$S_SVC_ENABLED" @@ -666,7 +672,7 @@ else plan firmware change "build $S_FW_REV and flash it (running: ${S_FW_NOW:-unknown}; declared $want_pins)" fi -plan test run "test hardware (moves nothing)" +plan test run "test hardware and test camera (moves nothing)" plan homing ask "asked at the end: home the gimbal and take a short capture" # The unit is scripts/fire-gimbal-control.service with its @...@ fields filled. @@ -858,9 +864,14 @@ elif [[ ${DO[firmware]} == blocked ]]; then warn "firmware not flashed: $FW_BLOCK" fi +hr "test camera" +run_test camera +[[ $TEST_RC == 0 ]] && say "test camera: PASS" || warn "test camera: exit $TEST_RC - read the report above" + hr "test hardware" S_SVC_ENABLED="$(systemctl is-enabled fire-gimbal-control 2>/dev/null || true)" -run_hardware_test +# Last, so TEST_RC is this one's: it is what the homing gate below asks about. +run_test hardware [[ $TEST_RC == 0 ]] && say "test hardware: PASS" || warn "test hardware: exit $TEST_RC - read the report above" # Before the gate, so that homing - if a person says yes - is done by the diff --git a/src/camera/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index 90196b5..8e611b6 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -436,4 +436,94 @@ std::vector VimbaCameraSource::deviceInfo() { return out; } +// ---- `test camera` ------------------------------------------------------------- + +bool VimbaCameraSource::grabFrame(int camera_index, Frame& out, int timeout_ms) { + const size_t idx = static_cast(camera_index); + if (camera_index < 0 || idx >= impl_->observers.size()) return false; // not streaming + auto* obs = SP_DYN_CAST(impl_->observers[idx]).get(); + // Strictly newer than now: a frame that was already waiting was exposed + // before the caller asked, possibly under settings it has since changed. + const long long since = nowMs(); + const long long deadline = since + timeout_ms; + while (nowMs() < deadline) { + if (obs->latestTimestamp() > since) return obs->takeLatest(out); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return false; +} + +bool VimbaCameraSource::setFeature(int camera_index, const std::string& name, + const std::string& value) { + const size_t idx = static_cast(camera_index); + 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) + return false; + auto write = [&]() -> VmbErrorType { + try { + switch (type) { + case VmbFeatureDataFloat: return f->SetValue(std::stod(value)); + case VmbFeatureDataInt: return f->SetValue(static_cast(std::stoll(value))); + case VmbFeatureDataBool: return f->SetValue(value == "true" || value == "1"); + case VmbFeatureDataEnum: + case VmbFeatureDataString: return f->SetValue(value.c_str()); + default: return VmbErrorWrongType; + } + } catch (const std::exception&) { + return VmbErrorBadParameter; // text that is not a number for a numeric feature + } + }; + VmbErrorType rc = write(); + // Some features cannot change while the camera streams - BlackLevel on the + // Alvium 1800 answers InvalidAccess (measured on tower 121) where ExposureTime + // and Gain are fine. Stop, write, and start again, as setProfile() does. + if (rc == VmbErrorInvalidAccess && impl_->started) { + try { + impl_->stopAcquisition(); + rc = write(); + impl_->startAcquisition(); + } catch (const std::exception& e) { + LOG_WARN << "camera: restarting the stream around " << name << " failed: " << e.what(); + rc = VmbErrorInternalFault; + } + } + if (rc != VmbErrorSuccess) { + LOG_WARN << "camera: setting " << name << " = " << value << " failed (error " << rc << ')'; + return false; + } + LOG_DEBUG << "camera cam" << camera_index << ": " << name << " = " << value; + return true; +} + +bool VimbaCameraSource::featureRange(int camera_index, const std::string& name, double& min, + double& max) { + const size_t idx = static_cast(camera_index); + 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) + return false; + if (type == VmbFeatureDataFloat) return f->GetRange(min, max) == VmbErrorSuccess; + if (type == VmbFeatureDataInt) { + VmbInt64_t a = 0, b = 0; + if (f->GetRange(a, b) != VmbErrorSuccess) return false; + min = static_cast(a); + max = static_cast(b); + return true; + } + return false; +} + +bool VimbaCameraSource::restoreProfile(int camera_index) { + const size_t idx = static_cast(camera_index); + if (camera_index < 0 || idx >= impl_->profiles.size()) return false; + // setProfile() rather than applyProfile(): it cycles the stream when one is + // running, since a profile may carry features that are not writable live. + return setProfile(camera_index, impl_->profiles[idx]); +} + } // namespace fgc diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 6b2bc71..56cbd0c 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -2200,7 +2200,10 @@ struct Application::Impl { hw.drive = &cfg.drivetrain; hw.env = env.get(); hw.camera = camera.get(); - for (const auto* c : cfg.camera.active()) hw.configured_cameras.push_back(c->name); + for (const auto* c : cfg.camera.active()) { + hw.configured_cameras.push_back(c->name); + hw.frame_timeout_ms.push_back(c->timingFor(c->profile).frame_timeout_ms); + } hw.i2c_device = cfg.env.i2c_device; hw.geo_yaw = cfg.geometry.yaw; hw.geo_pitch = cfg.geometry.pitch; diff --git a/src/core/HardwareChecks.cpp b/src/core/HardwareChecks.cpp index a71f1ba..f31aca0 100644 --- a/src/core/HardwareChecks.cpp +++ b/src/core/HardwareChecks.cpp @@ -2,6 +2,7 @@ #include #include +#include #include namespace fgc { @@ -267,4 +268,86 @@ CheckResult checkTravelFitsPositive(const std::string& axis, long center, long h return r; } +PixelStats pixelStats(const std::vector& data, uint8_t low, uint8_t high) { + PixelStats st; + st.samples = data.size(); + if (data.empty()) return st; + // A histogram, not per-byte arithmetic: a frame is 12 M bytes, and this runs + // on the tower in the middle of a test. + size_t hist[256] = {}; + for (uint8_t b : data) ++hist[b]; + double sum = 0.0; + size_t lo = 0, hi = 0; + for (int v = 0; v < 256; ++v) { + sum += static_cast(v) * hist[v]; + if (v <= low) lo += hist[v]; + if (v >= high) hi += hist[v]; + } + const double n = static_cast(data.size()); + st.mean = sum / n; + st.pct_low = 100.0 * lo / n; + st.pct_high = 100.0 * hi / n; + return st; +} + +CheckResult checkFramesDelivered(const std::string& camera, const FrameRun& run) { + CheckResult r; + if (run.wanted <= 0) { + r.ran = false; + r.detail = camera + ": no frames asked for"; + return r; + } + char geom[64]; + std::snprintf(geom, sizeof(geom), "%ux%u x%d", run.width, run.height, run.channels); + if (run.got < run.wanted) { + r.pass = false; + r.detail = camera + ": " + std::to_string(run.got) + " of " + std::to_string(run.wanted) + + " frames arrived within " + std::to_string(run.timeout_ms) + + " ms each -- the stream is stalling or not running"; + return r; + } + if (!run.consistent) { + r.pass = false; + r.detail = camera + ": frames changed size or depth mid-run (first " + geom + ")"; + return r; + } + if (run.width == 0 || run.height == 0 || (run.channels != 1 && run.channels != 3)) { + r.pass = false; + r.detail = camera + ": frames arrived malformed (" + geom + + "); the program handles 1 or 3 channels"; + return r; + } + if (run.dropped > 0) { + r.pass = false; + r.detail = camera + ": " + std::to_string(run.dropped) + + " incomplete frame(s) during the run -- usually USB bandwidth (known-issues #19)"; + return r; + } + r.detail = camera + ": " + std::to_string(run.got) + " complete frames, " + geom; + return r; +} + +CheckResult checkClipped(const std::string& camera, bool white, const PixelStats& st, + double min_pct) { + CheckResult r; + if (st.samples == 0) { + r.pass = false; + r.detail = camera + ": no frame to judge"; + return r; + } + const double got = white ? st.pct_high : st.pct_low; + char buf[160]; + std::snprintf(buf, sizeof(buf), "%.2f%% of pixels %s (want >= %.1f%%), mean %.1f", got, + white ? "clipped white" : "clipped black", min_pct, st.mean); + r.pass = got >= min_pct; + r.detail = camera + ": " + buf; + if (!r.pass) + r.detail += white ? " -- the forced settings did not reach white: settings not applied, " + "or the sensor output is not reaching the host intact" + : " -- the forced settings did not reach black: settings not applied, " + "a stuck or noisy sensor, or light direct enough to beat the " + "minimum exposure"; + return r; +} + } // namespace fgc diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index da21f59..d22820a 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -127,6 +127,13 @@ const std::vector& helpCatalog() { "a declared IMU streams; a declared ambient sensor sits on a REAL I2C bus (not", "SMBus-only) and its CRC checks out; the cameras opened and the USB", "preconditions hold. Nothing declared reports 'not checked', never PASS."}}, + {"test camera [frames|dark|white]", + "Check the camera works, whatever it is pointed at.", { + "frames: whole frames arrive through the program's own path, within the", + "profile's frame_timeout_ms. dark / white: the camera's own settings force", + "every pixel to black (minimum exposure and gain) and to white (black level", + "at its maximum) - so fog, night or a lens cap cannot decide the verdict.", + "The camera is put back on its profile afterwards; capture pauses meanwhile."}}, {"gimbal pitch setup", "Calibrate PITCH steps<->degrees against gravity, using the IMU.", { "Auto-homes if needed, sweeps pitch dwelling at each step, fits, then", diff --git a/src/core/TestRunner.cpp b/src/core/TestRunner.cpp index bbd5303..7b6b2cd 100644 --- a/src/core/TestRunner.cpp +++ b/src/core/TestRunner.cpp @@ -127,6 +127,13 @@ bool resolveTestSelection(const std::string& subsystem, const std::string& test, else if (test == "env") m = T_HW_ENV; else if (test == "camera") m = T_HW_CAMERA; else { err = "unknown hardware test: " + test; return false; } + } else if (subsystem == "camera") { + sub_ok = true; + if (test.empty()) m = T_CAM_ALL; + else if (test == "frames") m = T_CAM_FRAMES; + else if (test == "dark") m = T_CAM_DARK; + else if (test == "white") m = T_CAM_WHITE; + else { err = "unknown camera test: " + test; return false; } } else if (subsystem == "host") { sub_ok = true; if (test.empty()) m = T_HOST_ALL; @@ -223,6 +230,7 @@ void TestRunner::run() { if (!cancelled() && (selection_ & T_HW_IMU)) testHwImu(r); if (!cancelled() && (selection_ & T_HW_ENV)) testHwEnv(r); if (!cancelled() && (selection_ & T_HW_CAMERA)) testHwCamera(r); + if (!cancelled() && (selection_ & T_CAM_ALL)) testCamera(r); r.all_pass = true; for (const auto& s : r.sections) @@ -1145,4 +1153,170 @@ void TestRunner::testHwCamera(TestReport& r) { r.sections.push_back(std::move(s)); } +// `test camera` - frames, and the two forced extremes. +// +// The image test is deliberately not "is the picture good": fog, night and a +// lens cap all make a bad picture out of a healthy camera. Instead the camera is +// driven by its OWN settings to each end of its range - minimum exposure and gain +// for black, black level at maximum for white - and every pixel must follow. That +// holds whatever it is pointed at, and still proves the settings reach the +// sensor, its output reaches the host whole, and the full range survives. +// Measured on tower 121 with its lens covered: 100.00 % black, 100.00 % white. +void TestRunner::testCamera(TestReport& r) { + ICameraSource* cam = hw_.camera; + const int n = cam ? cam->cameraCount() : 0; + + auto notRun = [&](const char* id, const char* title, const std::string& why) { + TestSection s; s.id = id; s.title = title; + s.notes.push_back(why); + s.ran = false; + r.sections.push_back(std::move(s)); + }; + const bool want_frames = selection_ & T_CAM_FRAMES; + const bool want_dark = selection_ & T_CAM_DARK; + const bool want_white = selection_ & T_CAM_WHITE; + const char* kFramesTitle = "the camera delivers whole frames"; + const char* kDarkTitle = "forced dark: every pixel follows to black"; + const char* kWhiteTitle = "forced bright: every pixel follows to white"; + + double lo = 0, hi = 0; + if (n == 0 || !cam->featureRange(0, "ExposureTime", lo, hi)) { + const std::string why = n == 0 ? "no camera in this run" + : "this camera source cannot be driven by the self-test"; + if (want_frames) notRun("cam_frames", kFramesTitle, why); + if (want_dark) notRun("cam_dark", kDarkTitle, why); + if (want_white) notRun("cam_white", kWhiteTitle, why); + return; + } + + const int frames = static_cast(profile_.get("camera_frames", 5)); + const int discard = static_cast(profile_.get("camera_settle_frames", 3)); + const double min_pct = profile_.get("camera_clip_min_pct", 99.0); + + auto name = [&](int i) { + return i < static_cast(hw_.configured_cameras.size()) ? hw_.configured_cameras[i] + : "cam" + std::to_string(i); + }; + auto timeout = [&](int i) { + return i < static_cast(hw_.frame_timeout_ms.size()) && hw_.frame_timeout_ms[i] > 0 + ? hw_.frame_timeout_ms[i] : 2000; + }; + auto dropped = [&](int i) -> long long { + const auto info = cam->deviceInfo(); + return i < static_cast(info.size()) ? info[i].frames_dropped : 0; + }; + + // The capture scheduler stopped the stream for the test; it needs to run. + cam->start(); + + TestSection sf, sd, sw; + sf.id = "cam_frames"; sf.title = kFramesTitle; + sd.id = "cam_dark"; sd.title = kDarkTitle; + sw.id = "cam_white"; sw.title = kWhiteTitle; + + for (int i = 0; i < n && !cancelled(); ++i) { + const std::string cn = name(i); + + if (want_frames) { + setProgress(sf.id, i + 1, n, "grabbing frames"); + FrameRun run; + run.wanted = frames; + run.timeout_ms = timeout(i); + const long long dropped0 = dropped(i); + const auto t0 = std::chrono::steady_clock::now(); + for (int k = 0; k < frames && !cancelled(); ++k) { + Frame f; + if (!cam->grabFrame(i, f, run.timeout_ms)) break; + if (run.got == 0) { + run.width = f.width; run.height = f.height; run.channels = f.channels; + } else if (f.width != run.width || f.height != run.height || + f.channels != run.channels) { + run.consistent = false; + } + if (f.data.size() != static_cast(f.width) * f.height * f.channels) + run.consistent = false; + ++run.got; + } + const double secs = std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + run.dropped = dropped(i) - dropped0; + verdict(sf, cn + "_frames", checkFramesDelivered(cn, run)); + if (run.got > 1 && secs > 0) + sf.notes.push_back(cn + ": " + std::to_string(run.got) + " frames in " + + std::to_string(static_cast(secs * 1000)) + " ms"); + } + + if (!want_dark && !want_white) continue; + + // From here the camera is off its profile. It is put back below on every + // path out of these two phases, and a failure to put it back is a failure + // of the test. + auto force = [&](bool white, TestSection& s) { + double emin = 0, emax = 0, gmin = 0, gmax = 0, bmin = 0, bmax = 0; + CheckResult c; + const bool ranges = cam->featureRange(i, "ExposureTime", emin, emax) && + cam->featureRange(i, "Gain", gmin, gmax) && + cam->featureRange(i, "BlackLevel", bmin, bmax); + auto num = [](double v) { + char b[32]; std::snprintf(b, sizeof(b), "%.6g", v); return std::string(b); + }; + const bool set = ranges && + cam->setFeature(i, "ExposureAuto", "Off") && + cam->setFeature(i, "GainAuto", "Off") && + cam->setFeature(i, "ExposureTime", num(emin)) && + cam->setFeature(i, "Gain", num(gmin)) && + cam->setFeature(i, "BlackLevel", num(white ? bmax : bmin)); + if (!set) { + c.pass = false; + c.detail = cn + ": the camera refused the forced settings (ExposureAuto, " + "GainAuto, ExposureTime, Gain, BlackLevel)"; + verdict(s, cn + (white ? "_white" : "_dark"), c); + return; + } + // Frames already exposed under the old settings are still arriving; + // judge one taken well after the change. + Frame f; + bool got = false; + for (int k = 0; k <= discard && !cancelled(); ++k) + got = cam->grabFrame(i, f, timeout(i)) || got; + if (!got) { + c.pass = false; + c.detail = cn + ": no frame arrived at the forced settings"; + verdict(s, cn + (white ? "_white" : "_dark"), c); + return; + } + verdict(s, cn + (white ? "_white" : "_dark"), + checkClipped(cn, white, pixelStats(f.data), min_pct)); + }; + + if (want_dark && !cancelled()) { + setProgress(sd.id, i + 1, n, "forcing black"); + force(false, sd); + } + if (want_white && !cancelled()) { + setProgress(sw.id, i + 1, n, "forcing white"); + force(true, sw); + } + + CheckResult back; + if (cam->restoreProfile(i)) { + back.detail = cn + ": back on its profile"; + } else { + back.pass = false; + back.detail = cn + ": could NOT be put back on its profile - it is on the test's " + "forced settings; restart the program"; + LOG_ERROR << "test camera: " << back.detail; + } + verdict(want_white ? sw : sd, cn + "_restored", back); + } + + cam->stop(); + for (TestSection* s : {&sf, &sd, &sw}) { + if ((s == &sf && !want_frames) || (s == &sd && !want_dark) || (s == &sw && !want_white)) + continue; + rollup(*s); + r.sections.push_back(std::move(*s)); + } +} + } // namespace fgc diff --git a/tests/test_hardwarechecks.cpp b/tests/test_hardwarechecks.cpp index 3fbd19b..647cd68 100644 --- a/tests/test_hardwarechecks.cpp +++ b/tests/test_hardwarechecks.cpp @@ -284,3 +284,66 @@ TEST_CASE("no [Pins] declared is not checked") { auto r = checkPinsBuilt("yaw", v, 1); CHECK_FALSE(r.ran); } + +// --- test camera ------------------------------------------------------------ + +TEST_CASE("pixelStats counts each end of the range over every byte") { + std::vector d(1000, 128); + for (int i = 0; i < 100; ++i) d[i] = 0; + for (int i = 100; i < 150; ++i) d[i] = 5; // still "low": the bound is inclusive + for (int i = 150; i < 200; ++i) d[i] = 250; // "high", inclusive + for (int i = 200; i < 210; ++i) d[i] = 255; + auto st = pixelStats(d); + CHECK(st.samples == 1000); + CHECK(st.pct_low == doctest::Approx(15.0)); + CHECK(st.pct_high == doctest::Approx(6.0)); + CHECK(pixelStats({}).samples == 0); +} + +TEST_CASE("frames: all arriving whole and consistent passes, and says so") { + FrameRun run; + run.wanted = 5; run.got = 5; run.timeout_ms = 2000; + run.width = 2664; run.height = 1520; run.channels = 3; + auto r = checkFramesDelivered("rgb", run); + CHECK(r.pass); + CHECK(r.detail.find("5 complete frames, 2664x1520 x3") != std::string::npos); +} + +TEST_CASE("frames: a stall, a size change, bad depth and dropped frames each fail") { + FrameRun ok; + ok.wanted = 5; ok.got = 5; ok.timeout_ms = 2000; + ok.width = 640; ok.height = 480; ok.channels = 3; + + FrameRun stall = ok; stall.got = 2; + CHECK_FALSE(checkFramesDelivered("rgb", stall).pass); + CHECK(checkFramesDelivered("rgb", stall).detail.find("2 of 5") != std::string::npos); + + FrameRun changed = ok; changed.consistent = false; + CHECK_FALSE(checkFramesDelivered("rgb", changed).pass); + + FrameRun depth = ok; depth.channels = 2; + CHECK_FALSE(checkFramesDelivered("rgb", depth).pass); + + FrameRun drops = ok; drops.dropped = 1; + CHECK_FALSE(checkFramesDelivered("rgb", drops).pass); + CHECK(checkFramesDelivered("rgb", drops).detail.find("#19") != std::string::npos); + + FrameRun none; + CHECK_FALSE(checkFramesDelivered("rgb", none).ran); +} + +TEST_CASE("clipping: the forced end must be reached by the threshold share of pixels") { + PixelStats black; black.samples = 100; black.pct_low = 100.0; black.pct_high = 0.0; + PixelStats white; white.samples = 100; white.pct_low = 0.0; white.pct_high = 99.5; + PixelStats grey; grey.samples = 100; grey.pct_low = 1.0; grey.pct_high = 1.0; grey.mean = 128; + CHECK(checkClipped("rgb", false, black, 99.0).pass); + CHECK(checkClipped("rgb", true, white, 99.0).pass); + // A small bright patch at the dark end - the sun in frame - still passes. + PixelStats sun = black; sun.pct_low = 99.2; + CHECK(checkClipped("rgb", false, sun, 99.0).pass); + // A camera that did not follow the settings fails, with the reason. + auto r = checkClipped("rgb", true, grey, 99.0); + CHECK_FALSE(r.pass); + CHECK(r.detail.find("did not reach white") != std::string::npos); + CHECK_FALSE(checkClipped("rgb", false, PixelStats{}, 99.0).pass); +} diff --git a/tests/test_testrunner.cpp b/tests/test_testrunner.cpp index be9f6ba..16bc908 100644 --- a/tests/test_testrunner.cpp +++ b/tests/test_testrunner.cpp @@ -1,8 +1,10 @@ #include #include "fgc/TestRunner.h" +#include "fgc/HardwareChecks.h" #include +#include "fgc/mock/MockCameraSource.h" #include "fgc/mock/MockImuSource.h" #include "fgc/mock/MockMotorController.h" @@ -149,3 +151,87 @@ TEST_CASE("TestRunner is cancellable") { std::this_thread::sleep_for(std::chrono::milliseconds(20)); CHECK_FALSE(tr.running()); } + +// --- test camera ---------------------------------------------------------------- + +namespace { +const TestSection* section(const TestReport& r, const char* id) { + auto it = std::find_if(r.sections.begin(), r.sections.end(), + [&](const TestSection& s) { return s.id == id; }); + return it == r.sections.end() ? nullptr : &*it; +} +std::optional runCamera(MockCameraSource& cam, uint32_t mask = T_CAM_ALL) { + MockMotorController motor; + motor.start(); + HardwareTestContext hw; + hw.camera = &cam; + hw.configured_cameras = {"rgb"}; + hw.frame_timeout_ms = {200}; + TestRunner tr(motor, nullptr, unitGeometry(), mask, fastProfile(), ".", hw); + return runToCompletion(tr); +} +} // namespace + +TEST_CASE("the camera subsystem resolves, and is part of a bare 'test'") { + uint32_t m = 0; std::string err; + REQUIRE(resolveTestSelection("camera", "", m, err)); + CHECK(m == T_CAM_ALL); + REQUIRE(resolveTestSelection("camera", "white", m, err)); + CHECK(m == T_CAM_WHITE); + CHECK_FALSE(resolveTestSelection("camera", "focus", m, err)); + CHECK((T_ALL & T_CAM_ALL) == T_CAM_ALL); +} + +TEST_CASE("a healthy camera passes frames, forced black and forced white, and is put back") { + MockCameraSource cam; + auto rep = runCamera(cam); + REQUIRE(rep); + for (const char* id : {"cam_frames", "cam_dark", "cam_white"}) { + const TestSection* s = section(*rep, id); + REQUIRE(s); + CHECK(s->ran); + CHECK(s->pass); + } + CHECK(rep->all_pass); + // Put back: the mock's forced settings are gone, so it is at mid grey again. + cam.start(); + Frame f; + REQUIRE(cam.grabFrame(0, f, 100)); + const auto st = pixelStats(f.data); + CHECK(st.pct_low < 1.0); + CHECK(st.pct_high < 1.0); +} + +TEST_CASE("a camera that delivers nothing fails the frames leaf") { + MockCameraSource cam; + cam.setFault(MockCameraSource::Fault::NoFrames); + auto rep = runCamera(cam, T_CAM_FRAMES); + REQUIRE(rep); + const TestSection* s = section(*rep, "cam_frames"); + REQUIRE(s); + CHECK(s->ran); + CHECK_FALSE(s->pass); +} + +TEST_CASE("a camera that ignores its settings fails both extremes - the scene does not rescue it") { + MockCameraSource cam; + cam.setFault(MockCameraSource::Fault::IgnoresSettings); + auto rep = runCamera(cam, T_CAM_DARK | T_CAM_WHITE); + REQUIRE(rep); + CHECK_FALSE(section(*rep, "cam_dark")->pass); + CHECK_FALSE(section(*rep, "cam_white")->pass); + CHECK_FALSE(rep->all_pass); +} + +TEST_CASE("with no camera, the camera leaves are not checked rather than passed") { + MockMotorController motor; + motor.start(); + TestRunner tr(motor, nullptr, unitGeometry(), T_CAM_ALL, fastProfile(), "."); + auto rep = runToCompletion(tr); + REQUIRE(rep); + for (const char* id : {"cam_frames", "cam_dark", "cam_white"}) { + const TestSection* s = section(*rep, id); + REQUIRE(s); + CHECK_FALSE(s->ran); + } +}