Test that the camera delivers frames and follows its settings to black and white

This commit is contained in:
pgdalmeida 2026-09-24 15:47:58 +02:00
parent e8293f736d
commit 5a0515ab13
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
17 changed files with 679 additions and 16 deletions

View File

@ -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_noise_max_deg = 0.5
imu_yaw_drift_max_deg_min = 1.0 imu_yaw_drift_max_deg_min = 1.0
imu_expected_hz = 100 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 ; imu_expected_profile = VRU_general ; assert the active XKF profile, if set
backlash_step_counts = 200,500,1000 ; small steps probing reversal dead-band backlash_step_counts = 200,500,1000 ; small steps probing reversal dead-band
balance_steps = 10 ; pitch up/down current-profile resolution balance_steps = 10 ; pitch up/down current-profile resolution

View File

@ -67,9 +67,11 @@ After an update, check it:
ssh ggs@<tower> 'cd ~/projects/fwt_2a/software && scripts/install-tower --verify' ssh ggs@<tower> 'cd ~/projects/fwt_2a/software && scripts/install-tower --verify'
``` ```
It changes nothing; it runs `test hardware` inside the running program (pausing It changes nothing; it runs `test hardware` and `test camera` inside the running
and then resuming capture) and checks the files, the firmware's pins and the program (pausing and then resuming capture) and checks the files, the firmware's
boot service. 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, `--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 backing the tower's copy up first. It exists for the development tower; a

View File

@ -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. | | 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). | | 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. | | 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. | | 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. | | 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 ## Camera acquisition (USB3) — root cause + approach

View File

@ -1049,7 +1049,7 @@ scripts/install-tower --verify
It checks the USB preconditions, that `config.ini` and `hardware.ini` load 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 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 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: 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 | | 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 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 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` | | Firmware identity | `DUMP BEGIN build=` is the revision flashed | ✅ | ✅ `a0b1e4a` |
| Driver answers over SPI | `IOIN` reads version `0x30` for every axis | ✅ | ✅ both axes | | 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 | | Firmware holds `hardware.ini` | `test hardware motor`: values, current, pins | ✅ | ✅ every leaf, 2026-09-24 |

View File

@ -1,6 +1,8 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <vector>
namespace fgc { namespace fgc {
@ -134,4 +136,40 @@ CheckResult checkPinsBuilt(const std::string& label, const DeclaredValue* values
// axis has not homed, and nothing is checked. // axis has not homed, and nothing is checked.
CheckResult checkTravelFitsPositive(const std::string& axis, long center, long half_travel); 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<uint8_t>& 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 } // namespace fgc

View File

@ -83,6 +83,37 @@ public:
// Live identity/telemetry/stats per camera, for the expanded camera view. // Live identity/telemetry/stats per camera, for the expanded camera view.
// Default: none. Implementations may refresh a throttled cache, so non-const. // Default: none. Implementations may refresh a throttled cache, so non-const.
virtual std::vector<CameraDeviceInfo> deviceInfo() { return {}; } virtual std::vector<CameraDeviceInfo> 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 } // namespace fgc

View File

@ -42,13 +42,20 @@ enum TestLeaf : uint32_t {
T_HW_IMU = 1u << 14, T_HW_IMU = 1u << 14,
T_HW_ENV = 1u << 15, T_HW_ENV = 1u << 15,
T_HW_CAMERA = 1u << 16, 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 | constexpr uint32_t T_GIMBAL_ALL = T_GIMBAL_HOMING | T_GIMBAL_ENCODER | T_GIMBAL_FRICTION |
T_GIMBAL_BACKLASH | T_GIMBAL_BALANCE; 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_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_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_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). // 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 // Needs a HOMED gimbal, not merely a motor link: the caller refuses the whole
@ -75,6 +82,9 @@ struct HardwareTestContext {
ICameraSource* camera = nullptr; ICameraSource* camera = nullptr;
std::vector<std::string> configured_cameras; // [Camera.<name>] blocks in open order std::vector<std::string> configured_cameras; // [Camera.<name>] blocks in open order
// Per camera, same order: how long a frame may take under its active profile
// ([Camera.<name>.<profile>] frame_timeout_ms). Missing => 2000.
std::vector<int> frame_timeout_ms;
std::string i2c_device; // [Env] i2c_device, for the adapter-class check 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 // The degree maps, so a declared deg/s can be turned back into the VMAX the
// firmware should be holding. // firmware should be holding.
@ -136,6 +146,7 @@ private:
void testHwImu(TestReport& r); void testHwImu(TestReport& r);
void testHwEnv(TestReport& r); void testHwEnv(TestReport& r);
void testHwCamera(TestReport& r); void testHwCamera(TestReport& r);
void testCamera(TestReport& r); // all three `test camera` leaves, one stream session
IMotorController& motor_; IMotorController& motor_;
IImuSource* imu_; IImuSource* imu_;

View File

@ -5,6 +5,7 @@
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include <memory> #include <memory>
#include <string>
#include <vector> #include <vector>
namespace fgc { namespace fgc {
@ -35,6 +36,10 @@ public:
void setFrameCallback(FrameCallback cb) override; void setFrameCallback(FrameCallback cb) override;
int cameraCount() const override; int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override; std::vector<CameraDeviceInfo> 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: private:
struct Impl; struct Impl;

View File

@ -3,8 +3,11 @@
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <algorithm>
#include <chrono> #include <chrono>
#include <map>
#include <string> #include <string>
#include <thread>
namespace fgc { namespace fgc {
@ -43,6 +46,43 @@ public:
return true; 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) // Synthetic device info so the expanded camera view renders (and is testable)
// without hardware. Non-hardware fields (temperature, exposure...) stay at 0. // without hardware. Non-hardware fields (temperature, exposure...) stay at 0.
std::vector<CameraDeviceInfo> deviceInfo() override { std::vector<CameraDeviceInfo> deviceInfo() override {
@ -77,7 +117,18 @@ private:
.count(); .count();
f.data.resize(static_cast<size_t>(width_) * height_ * 3); f.data.resize(static_cast<size_t>(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<uint8_t>(f.timestamp_ms); const uint8_t phase = static_cast<uint8_t>(f.timestamp_ms);
for (uint32_t y = 0; y < height_; ++y) { for (uint32_t y = 0; y < height_; ++y) {
@ -105,6 +156,8 @@ private:
long long frames_ = 0; long long frames_ = 0;
std::string profile_; std::string profile_;
FrameCallback callback_; FrameCallback callback_;
Fault fault_ = Fault::None;
std::map<std::string, std::string> features_; // what setFeature() has set
}; };
} // namespace fgc } // namespace fgc

View File

@ -315,7 +315,9 @@ EOF
# ----------------------------------------------------------------- verify --- # ----------------------------------------------------------------- verify ---
TEST_RC="" 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 # 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. # as a one-shot run of the binary when it is not.
if app_running; then if app_running; then
@ -325,7 +327,7 @@ run_hardware_test() {
if tmux capture-pane -p -t "$pane" | grep -q '╭'; then if tmux capture-pane -p -t "$pane" | grep -q '╭'; then
tmux send-keys -t "$pane" ':'; sleep 0.5 tmux send-keys -t "$pane" ':'; sleep 0.5
fi fi
tmux send-keys -t "$pane" 'test hardware' Enter tmux send-keys -t "$pane" "test $sel" Enter
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
after="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" after="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)"
[[ -n $after && $after != "$before" ]] && break [[ -n $after && $after != "$before" ]] && break
@ -346,8 +348,8 @@ run_hardware_test() {
local out="$WORK/test.out" local out="$WORK/test.out"
TEST_RC=0 TEST_RC=0
( cd "$REPO" && GENICAM_GENTL64_PATH="${GENICAM_GENTL64_PATH:-/opt/VimbaX/cti}" \ ( cd "$REPO" && GENICAM_GENTL64_PATH="${GENICAM_GENTL64_PATH:-/opt/VimbaX/cti}" \
"$BIN" --no-mqtt --test hardware ) > "$out" 2>&1 < /dev/null || TEST_RC=$? "$BIN" --no-mqtt --test "$sel" ) > "$out" 2>&1 < /dev/null || TEST_RC=$?
grep -E '\-\-test|test hw_|test overall' "$out" | sed 's/^/ /' 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)" local rep; rep="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)"
[[ -n $rep ]] && grep -E ' ; ' "$rep" | sed 's/^/ /' [[ -n $rep ]] && grep -E ' ; ' "$rep" | sed 's/^/ /'
fi fi
@ -378,8 +380,12 @@ verify() {
local want_pins; want_pins="$(declared_pins)" local want_pins; want_pins="$(declared_pins)"
check "firmware pins" "$(pins_match "$now" "$want_pins" && echo 0 || echo 1)" \ check "firmware pins" "$(pins_match "$now" "$want_pins" && echo 0 || echo 1)" \
"running ${now:-unknown}; declared $want_pins" "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")" 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. # Only once there IS a service: a tower run by hand is a choice, not a fault.
if [[ -r $UNIT ]]; then if [[ -r $UNIT ]]; then
check "boot service" "$([[ $S_SVC_ENABLED == enabled ]] && echo 0 || echo 1)" "$S_SVC_ENABLED" 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)" plan firmware change "build $S_FW_REV and flash it (running: ${S_FW_NOW:-unknown}; declared $want_pins)"
fi 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" 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. # 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" warn "firmware not flashed: $FW_BLOCK"
fi 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" hr "test hardware"
S_SVC_ENABLED="$(systemctl is-enabled fire-gimbal-control 2>/dev/null || true)" 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" [[ $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 # Before the gate, so that homing - if a person says yes - is done by the

View File

@ -436,4 +436,94 @@ std::vector<CameraDeviceInfo> VimbaCameraSource::deviceInfo() {
return out; return out;
} }
// ---- `test camera` -------------------------------------------------------------
bool VimbaCameraSource::grabFrame(int camera_index, Frame& out, int timeout_ms) {
const size_t idx = static_cast<size_t>(camera_index);
if (camera_index < 0 || idx >= impl_->observers.size()) return false; // not streaming
auto* obs = SP_DYN_CAST<FrameObserver>(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<size_t>(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<VmbInt64_t>(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<size_t>(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<double>(a);
max = static_cast<double>(b);
return true;
}
return false;
}
bool VimbaCameraSource::restoreProfile(int camera_index) {
const size_t idx = static_cast<size_t>(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 } // namespace fgc

View File

@ -2200,7 +2200,10 @@ struct Application::Impl {
hw.drive = &cfg.drivetrain; hw.drive = &cfg.drivetrain;
hw.env = env.get(); hw.env = env.get();
hw.camera = camera.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.i2c_device = cfg.env.i2c_device;
hw.geo_yaw = cfg.geometry.yaw; hw.geo_yaw = cfg.geometry.yaw;
hw.geo_pitch = cfg.geometry.pitch; hw.geo_pitch = cfg.geometry.pitch;

View File

@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdio>
#include <string> #include <string>
namespace fgc { namespace fgc {
@ -267,4 +268,86 @@ CheckResult checkTravelFitsPositive(const std::string& axis, long center, long h
return r; return r;
} }
PixelStats pixelStats(const std::vector<uint8_t>& 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<double>(v) * hist[v];
if (v <= low) lo += hist[v];
if (v >= high) hi += hist[v];
}
const double n = static_cast<double>(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 } // namespace fgc

View File

@ -127,6 +127,13 @@ const std::vector<HelpSection>& helpCatalog() {
"a declared IMU streams; a declared ambient sensor sits on a REAL I2C bus (not", "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", "SMBus-only) and its CRC checks out; the cameras opened and the USB",
"preconditions hold. Nothing declared reports 'not checked', never PASS."}}, "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", {"gimbal pitch setup",
"Calibrate PITCH steps<->degrees against gravity, using the IMU.", { "Calibrate PITCH steps<->degrees against gravity, using the IMU.", {
"Auto-homes if needed, sweeps pitch dwelling at each step, fits, then", "Auto-homes if needed, sweeps pitch dwelling at each step, fits, then",

View File

@ -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 == "env") m = T_HW_ENV;
else if (test == "camera") m = T_HW_CAMERA; else if (test == "camera") m = T_HW_CAMERA;
else { err = "unknown hardware test: " + test; return false; } 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") { } else if (subsystem == "host") {
sub_ok = true; sub_ok = true;
if (test.empty()) m = T_HOST_ALL; 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_IMU)) testHwImu(r);
if (!cancelled() && (selection_ & T_HW_ENV)) testHwEnv(r); if (!cancelled() && (selection_ & T_HW_ENV)) testHwEnv(r);
if (!cancelled() && (selection_ & T_HW_CAMERA)) testHwCamera(r); if (!cancelled() && (selection_ & T_HW_CAMERA)) testHwCamera(r);
if (!cancelled() && (selection_ & T_CAM_ALL)) testCamera(r);
r.all_pass = true; r.all_pass = true;
for (const auto& s : r.sections) for (const auto& s : r.sections)
@ -1145,4 +1153,170 @@ void TestRunner::testHwCamera(TestReport& r) {
r.sections.push_back(std::move(s)); 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<int>(profile_.get("camera_frames", 5));
const int discard = static_cast<int>(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<int>(hw_.configured_cameras.size()) ? hw_.configured_cameras[i]
: "cam" + std::to_string(i);
};
auto timeout = [&](int i) {
return i < static_cast<int>(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<int>(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<size_t>(f.width) * f.height * f.channels)
run.consistent = false;
++run.got;
}
const double secs = std::chrono::duration<double>(
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<int>(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 } // namespace fgc

View File

@ -284,3 +284,66 @@ TEST_CASE("no [Pins] declared is not checked") {
auto r = checkPinsBuilt("yaw", v, 1); auto r = checkPinsBuilt("yaw", v, 1);
CHECK_FALSE(r.ran); CHECK_FALSE(r.ran);
} }
// --- test camera ------------------------------------------------------------
TEST_CASE("pixelStats counts each end of the range over every byte") {
std::vector<uint8_t> 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);
}

View File

@ -1,8 +1,10 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include "fgc/TestRunner.h" #include "fgc/TestRunner.h"
#include "fgc/HardwareChecks.h"
#include <algorithm> #include <algorithm>
#include "fgc/mock/MockCameraSource.h"
#include "fgc/mock/MockImuSource.h" #include "fgc/mock/MockImuSource.h"
#include "fgc/mock/MockMotorController.h" #include "fgc/mock/MockMotorController.h"
@ -149,3 +151,87 @@ TEST_CASE("TestRunner is cancellable") {
std::this_thread::sleep_for(std::chrono::milliseconds(20)); std::this_thread::sleep_for(std::chrono::milliseconds(20));
CHECK_FALSE(tr.running()); 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<TestReport> 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);
}
}