From c41cb6fc6a11f3b7a8cd4bcef52b7a6d125276c6 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Mon, 13 Jul 2026 19:15:38 +0200 Subject: [PATCH] Added camera control --- config/config.example.ini | 27 +++ docs/configuration.md | 13 + docs/known-issues.md | 34 ++- include/fgc/Config.h | 22 ++ include/fgc/ICameraSource.h | 33 +++ include/fgc/ImagePipeline.h | 4 + include/fgc/VimbaCameraSource.h | 15 +- include/fgc/mock/MockCameraSource.h | 28 ++- include/fgc/ui/UiSnapshot.h | 29 +++ src/camera/ImagePipeline.cpp | 1 + src/camera/VimbaCameraSource.cpp | 357 ++++++++++++++++++++++++---- src/core/Application.cpp | 59 ++++- src/core/Config.cpp | 27 +++ src/ui/TuiUi.cpp | 117 +++++++-- tests/test_config.cpp | 52 ++++ tests/test_uisnapshot.cpp | 22 ++ 16 files changed, 757 insertions(+), 83 deletions(-) diff --git a/config/config.example.ini b/config/config.example.ini index ff92877..b38dad8 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -29,6 +29,33 @@ id_Cam1 = DEV_0000000000 id_Cam2 = id_Cam3 = id_Cam4 = +; --- On-camera imaging (Alvium; applied in-session at startup) --- +; The LattePanda USB3 host cannot reliably transfer large single frames (~60 MB RGB8 +; full-res stalls); keep frames small and PACE capture. See docs/known-issues.md. +; Digital binning: 1 = full res; 2 = 2x2 (~5 MP, ~15 MB) - the reliable default. +binning = 2 +; ROI (0 = sensor maximum / full frame). Set width/height to crop. +offset_x = 0 +offset_y = 0 +width = 0 +height = 0 +; Pixel format: RGB8 keeps de-Bayering AND white balance on-camera (colour out, no host debayer). +pixel_format = RGB8 +; DeviceLinkThroughputLimit in MByte/s. Do NOT max it (450 caused incomplete frames); ~200-300. +throughput_mbytes = 250 +; Paced acquisition rate (fps). Keep LOW: ~2 fps of 15 MB frames stalls this USB3 host; 1 fps +; sustains. Low rate still keeps the on-camera auto exposure/gain/white-balance converged. +stream_fps = 1.0 +; On-camera auto adjustments to track dramatically changing outdoor light. *_max caps bound +; motion blur / frame time (exposure) and noise (gain); 0 = leave the camera default. +exposure_auto = true +exposure_max_us = 0 +gain_auto = true +gain_max_db = 0 +white_balance_auto = true +; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9. +jxl_distance = 0.8 +jxl_effort = 4 [Serial] ; Motor-controller serial device and baud rate. diff --git a/docs/configuration.md b/docs/configuration.md index f4436a7..08311d3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,19 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device | | `Serial` | `baud` | int | `115200` | Serial baud rate | | `Camera` | `id_Cam1`..`id_Cam4` | string | — | Camera IDs (GigE IP or USB `DEV_...`); non-empty ones used in order | +| `Camera` | `binning` | int ≥ 1 | `2` | Digital binning; `1` = full res, `2` = 2×2 (~5 MP, ~15 MB — the reliable default) | +| `Camera` | `offset_x`/`offset_y` | int | `0` | ROI origin (0 = full frame) | +| `Camera` | `width`/`height` | int | `0` | ROI size in pixels; `0` = sensor maximum | +| `Camera` | `pixel_format` | string | `RGB8` | On-camera format (RGB8 keeps de-Bayer + white balance on-camera) | +| `Camera` | `throughput_mbytes` | int > 0 | `250` | `DeviceLinkThroughputLimit` (MByte/s); do **not** max it (≥450 drops frames) | +| `Camera` | `stream_fps` | double > 0 | `1.0` | Paced acquisition rate; keeps on-camera auto converged. Keep low (~2 fps of 15 MB frames stalls this USB3 host; 1 fps sustains) | +| `Camera` | `exposure_auto` | bool | `true` | `ExposureAuto=Continuous` (adapt to changing light) | +| `Camera` | `exposure_max_us` | double | `0` | `ExposureAutoMax` cap (µs); `0` = camera default | +| `Camera` | `gain_auto` | bool | `true` | `GainAuto=Continuous` | +| `Camera` | `gain_max_db` | double | `0` | `GainAutoMax` cap (dB); `0` = camera default | +| `Camera` | `white_balance_auto` | bool | `true` | `BalanceWhiteAuto=Continuous` | +| `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) | +| `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) | | `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` | | `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) | | `Features` | `enable_camera` | bool | `true` | (reserved) | diff --git a/docs/known-issues.md b/docs/known-issues.md index df38fd6..8b56bb5 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -30,6 +30,37 @@ doctest unit-test suite (`ctest`). | 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. | | 14 | Capture sweep untested on hardware | Homing was verified live (see below), but the `MOVE → settle → trigger` sweep was **not** run with `--start`. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) still need tuning against observed `ST` behaviour, alongside #13. | +## Camera acquisition (USB3) — root cause + approach + +Real-camera capture (Alvium 1800 U-2040c, Sony IMX541, 20.4 MP) was brought up on the LattePanda this +session. Two problems were root-caused (both **host/USB3-side**, reproduced in Allied Vision's own +`vmbpy` — the camera keeps acquiring while the host stops receiving; **no kernel errors**): + +1. **Camera shipped in hardware-trigger mode** (`TriggerSource=Line0`) → produced no frames. Fixed by + configuring **software trigger** explicitly in-session in [VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) + (relying on the camera's persisted user set alone did not work). +2. **Acquisition stall** — frame delivery stops after a few frames: + - **USB3 hardware LPM (U1/U2) was enabled** on the camera's link (the camera logs + `Enable of device-initiated U2 failed`) → silent bulk-transfer stalls under load. Disable per-port: + write `0` to `/sys/devices/.../usb2/2-0:1.0/usb2-port/usb3_lpm_permit`, then re-enumerate + (the per-device `power/usb3_hardware_lpm_u*` files are read-only). **Make persistent** via a udev + rule matched on idVendor `1ab2`. + - **The host xHCI cannot reliably move large single frames.** Measured (LPM off, paced ~1 fps): RGB8 + full-res **61 MB stalls in 3–5 frames**; **≤ ~20 MB frames sustain when *paced*** (BayerRG8 full-res + 20 MB → 21 frames/20 s; 3.8 MB → 41/20 s). Freerun at max rate stalls even for medium frames. + - Also raise `usbcore.usbfs_memory_mb` to 1000 (persist via kernel cmdline; resets to 16 on reboot). + +**Approach (config-driven, see `[Camera]` in [configuration.md](configuration.md)):** RGB8 + **2×2 binning** +(~5 MP, ~15 MB) keeps de-Bayering **and** white balance on-camera (the on-camera pipeline applies white +balance before de-Bayering); a **paced low-rate stream** keeps on-camera auto-exposure/gain/white-balance +converged to the changing outdoor light, and the scheduler saves one frame per waypoint. `DeviceLinkThroughputLimit` +is kept conservative (~250 MB/s; ≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 +(20 MB) + a host de-Bayer step if reduced resolution proves insufficient. + +**Status:** the `[Camera]` config schema, near-lossless JPEG XL, and unit tests are in place. Still to do +on the rig: wire the in-session imaging config + paced acquisition in `VimbaCameraSource`, add the udev/boot +persistence for LPM + usbfs, and verify a full sweep captures white-balanced frames with no stall. + ## Verification caveats - **Full build verified on the LattePanda**: a `WITH_VIMBA=ON WITH_MQTT=ON` build compiles and links on the @@ -47,7 +78,8 @@ doctest unit-test suite (`ctest`). ## Possible follow-ups (not done) - Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown). -- Make the camera index→label map and JPEG XL defaults fully config-driven. +- Make the camera index→label map fully config-driven. (JPEG XL distance/effort and the on-camera imaging + settings are now config-driven under `[Camera]` — see [configuration.md](configuration.md).) - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was removed). - **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 51ff99e..bbd63f5 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -32,6 +32,28 @@ struct SerialConfig { struct CameraConfig { std::vector ids; // GigE IP or USB DEV_ id, in order std::vector labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder + + // On-camera imaging (Alvium), applied in-session by VimbaCameraSource at start(). + // Defaults keep frames small enough to transfer reliably on the LattePanda USB3 host + // (large ~60 MB frames stall; see docs/known-issues.md "Camera acquisition"). + int binning = 2; // 1 = full res; 2 = 2x2 (~5 MP, ~15 MB) default + int offset_x = 0; // ROI origin; 0/0 + 0 size => sensor maximum (full frame) + int offset_y = 0; + int width = 0; // ROI width in pixels; 0 => sensor maximum + int height = 0; // ROI height in pixels; 0 => sensor maximum + std::string pixel_format = "RGB8"; // on-camera de-Bayer + white balance -> colour out + int throughput_mbytes = 250; // DeviceLinkThroughputLimit (MByte/s); do NOT max it + double stream_fps = 1.0; // paced acquisition rate; keeps auto converged. Keep low: + // ~2 fps of 15 MB frames stalls this USB3 host; 1 fps sustains + bool exposure_auto = true; // ExposureAuto = Continuous (adapt to changing light) + double exposure_max_us = 0.0; // ExposureAutoMax cap (us); 0 => leave camera default + bool gain_auto = true; // GainAuto = Continuous + double gain_max_db = 0.0; // GainAutoMax cap (dB); 0 => leave camera default + bool white_balance_auto = true; // BalanceWhiteAuto = Continuous + + // JPEG XL encoding (consumed by ImagePipeline). Near-lossless default. + double jxl_distance = 0.8; // 0 = lossless; higher = lossier + int jxl_effort = 4; // libjxl effort 1..9 }; struct PathsConfig { diff --git a/include/fgc/ICameraSource.h b/include/fgc/ICameraSource.h index eaaf733..3a3abf8 100644 --- a/include/fgc/ICameraSource.h +++ b/include/fgc/ICameraSource.h @@ -2,6 +2,7 @@ #include #include +#include #include namespace fgc { @@ -16,6 +17,34 @@ struct Frame { int cam_id = 0; // index into the configured camera list }; +// Live per-camera identity, sensor telemetry, and acquisition stats, for the +// expanded camera view. Populated by deviceInfo(); fields the source can't read +// are left at their defaults. +struct CameraDeviceInfo { + std::string id; + std::string model; + std::string serial; + bool streaming = false; + + // Current frame geometry as delivered. + uint32_t width = 0; + uint32_t height = 0; + long long payload_bytes = 0; + + // Live sensor telemetry (read from the camera, throttled). + double exposure_us = 0.0; + double gain_db = 0.0; + double wb_red = 0.0; + double wb_blue = 0.0; + double temperature_c = 0.0; + double actual_fps = 0.0; + + // Cumulative acquisition stats since the source was created. + long long frames_delivered = 0; + long long frames_dropped = 0; // incomplete/invalid deliveries + long long restarts = 0; // stream restarts (restart-on-stall) +}; + // Abstraction over the camera array. Implemented by VimbaCameraSource (Allied // Vision Vimba X) and MockCameraSource (synthetic frames, no hardware). // @@ -42,6 +71,10 @@ public: // Number of cameras this source manages. virtual int cameraCount() const = 0; + + // 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 {}; } }; } // namespace fgc diff --git a/include/fgc/ImagePipeline.h b/include/fgc/ImagePipeline.h index 4ed9396..4634a0b 100644 --- a/include/fgc/ImagePipeline.h +++ b/include/fgc/ImagePipeline.h @@ -58,6 +58,9 @@ public: // UI's camera panel. Thread-safe; nullopt until the first capture is saved. std::optional lastEvent() const; + // Number of images successfully written this session (for the camera view). + long long imagesSaved() const { return saved_.load(); } + private: void run(); void process(const Frame& frame); @@ -75,6 +78,7 @@ private: mutable std::mutex last_event_mutex_; std::optional last_event_; + std::atomic saved_{0}; // images successfully written this session }; } // namespace fgc diff --git a/include/fgc/VimbaCameraSource.h b/include/fgc/VimbaCameraSource.h index 6827924..1f897d0 100644 --- a/include/fgc/VimbaCameraSource.h +++ b/include/fgc/VimbaCameraSource.h @@ -1,20 +1,20 @@ #pragma once +#include "fgc/Config.h" #include "fgc/ICameraSource.h" #include -#include -#include namespace fgc { -// Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens -// cameras by ID, runs continuous acquisition with a settle-then-keep frame -// observer, and delivers completed frames to the callback. The encode/save -// step lives in ImagePipeline, so this class only produces Frames. +// Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the +// cameras in CameraConfig, applies the on-camera imaging settings in-session, runs a +// PACED FREE-RUN stream (fixed low frame rate, no software trigger - large frames +// streamed fast stall this USB3 host), and keeps the latest complete frame; trigger() +// delivers it to the callback once per waypoint. Encode/save lives in ImagePipeline. class VimbaCameraSource : public ICameraSource { public: - explicit VimbaCameraSource(std::vector camera_ids); + explicit VimbaCameraSource(CameraConfig config); ~VimbaCameraSource() override; void open() override; @@ -25,6 +25,7 @@ public: bool setFrameRate(double fps) override; void setFrameCallback(FrameCallback cb) override; int cameraCount() const override; + std::vector deviceInfo() override; private: struct Impl; diff --git a/include/fgc/mock/MockCameraSource.h b/include/fgc/mock/MockCameraSource.h index 1bf5ce0..c884b33 100644 --- a/include/fgc/mock/MockCameraSource.h +++ b/include/fgc/mock/MockCameraSource.h @@ -4,6 +4,7 @@ #include "fgc/Logger.h" #include +#include namespace fgc { @@ -17,8 +18,8 @@ public: void open() override { LOG_INFO << "[mock] camera opened (" << count_ << ")"; } void close() override {} - void start() override { LOG_INFO << "[mock] camera acquisition started"; } - void stop() override {} + void start() override { started_ = true; LOG_INFO << "[mock] camera acquisition started"; } + void stop() override { started_ = false; } bool trigger() override { if (!callback_) return false; @@ -43,6 +44,7 @@ public: } LOG_TRACE_CAT(LogCat::Camera) << "RX frame cam0 " << width_ << 'x' << height_ << ' ' << f.data.size() << "B (mock)"; + ++frames_; callback_(f); return true; } @@ -50,10 +52,32 @@ public: void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); } int cameraCount() const override { return count_; } + // 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 { + std::vector out; + for (int i = 0; i < count_; ++i) { + CameraDeviceInfo d; + d.id = "MOCK" + std::to_string(i); + d.model = "MockCamera"; + d.serial = "SIM-0000"; + d.streaming = started_; + d.width = width_; + d.height = height_; + d.payload_bytes = static_cast(width_) * height_ * 3; + d.actual_fps = 1.0; + d.frames_delivered = (i == 0) ? frames_ : 0; // only cam0 produces frames + out.push_back(std::move(d)); + } + return out; + } + private: int count_; uint32_t width_; uint32_t height_; + bool started_ = false; + long long frames_ = 0; FrameCallback callback_; }; diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index ef2f3ff..941a5ed 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -90,12 +90,41 @@ struct ScanWaypointView { bool current = false; // the scheduler's live cursor position }; +// Static imaging + encoding config (from CameraConfig), for the expanded view. +struct CameraConfigView { + bool mock = false; + std::vector ids; + std::string pixel_format; + int binning = 1; + int offset_x = 0, offset_y = 0, width = 0, height = 0; // 0 = full/max + int throughput_mbytes = 0; + double stream_fps = 0.0; + bool exposure_auto = true; double exposure_max_us = 0.0; + bool gain_auto = true; double gain_max_db = 0.0; + bool white_balance_auto = true; + double jxl_distance = 0.0; int jxl_effort = 0; + std::string output_dir; +}; + +// Live per-camera identity/telemetry/stats (from ICameraSource::deviceInfo()). +struct CameraDeviceView { + std::string id, model, serial; + bool streaming = false; + unsigned width = 0, height = 0; long long payload_bytes = 0; + double exposure_us = 0, gain_db = 0, wb_red = 0, wb_blue = 0, temperature_c = 0, actual_fps = 0; + long long frames_delivered = 0, frames_dropped = 0, restarts = 0; +}; + struct CaptureView { bool present = false; bool active = false; double image_rate = 0.0; // img/s int camera_count = 0; std::vector labels; + // Full camera-system detail for the expanded ('c') view. + CameraConfigView config; + std::vector devices; + long long images_saved = 0; // Defined scan grid (ControlCode 0 auto-sweep waypoints) with the live cursor // flagged, surfaced in the expanded camera view. std::vector scan_grid; diff --git a/src/camera/ImagePipeline.cpp b/src/camera/ImagePipeline.cpp index 1af028b..8f3481b 100644 --- a/src/camera/ImagePipeline.cpp +++ b/src/camera/ImagePipeline.cpp @@ -110,6 +110,7 @@ void ImagePipeline::process(const Frame& frame) { last_event_ = ev; } + saved_.fetch_add(1); LOG_DEBUG << "Saved " << file.string() << " (" << img.cols << "x" << img.rows << ")"; } diff --git a/src/camera/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index 2d68692..65053f2 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -2,8 +2,11 @@ #include "fgc/Logger.h" +#include #include #include +#include +#include #include #include @@ -21,24 +24,46 @@ long long nowMs() { .count(); } -// Frame observer: dumps the first few frames after each trigger (sensor -// settling), then delivers completed frames to the source callback. +// Cumulative per-camera acquisition stats. Lives in Impl (NOT the observer) so it +// survives the restart-on-stall, which recreates the observer each session. +struct CamStats { + std::atomic delivered{0}; + std::atomic dropped{0}; + std::atomic restarts{0}; + std::atomic last_w{0}; + std::atomic last_h{0}; + std::atomic last_bytes{0}; +}; + +// Frame observer for a paced free-running stream: keeps the most recent COMPLETE +// frame. trigger() (once per scan waypoint) reads it out; the intermediate frames +// just give the on-camera auto-exposure/gain/white-balance time to converge to the +// current light. This avoids the software-trigger path, which stalls on this host. class FrameObserver : public IFrameObserver { public: - FrameObserver(CameraPtr camera, int cam_id, ICameraSource::FrameCallback cb) - : IFrameObserver(camera), cam_id_(cam_id), cb_(std::move(cb)) {} + FrameObserver(CameraPtr camera, int cam_id, CamStats* stats) + : IFrameObserver(camera), cam_id_(cam_id), stats_(stats) {} - void resetSettle() { settle_.store(3); } + // Copy out the latest complete frame; false if none has arrived yet. + bool takeLatest(Frame& out) { + std::lock_guard lock(mutex_); + if (!has_) return false; + out = latest_; + return true; + } + + // Timestamp of the latest complete frame (0 if none) - lets the source detect a + // stalled stream (timestamp stops advancing) without copying the whole frame. + long long latestTimestamp() { + std::lock_guard lock(mutex_); + return has_ ? latest_.timestamp_ms : 0; + } void FrameReceived(const FramePtr pFrame) override { - if (settle_.fetch_sub(1) > 0) { // still settling -> discard - m_pCamera->QueueFrame(pFrame); - return; - } VmbFrameStatusType status; - if (pFrame->GetReceiveStatus(status) == VmbErrorSuccess && - status == VmbFrameStatusComplete) { - VmbUint32_t w = 0, h = 0, sz = 0; + VmbErrorType rc = pFrame->GetReceiveStatus(status); + if (rc == VmbErrorSuccess && status == VmbFrameStatusComplete) { + VmbUint32_t w = 0, h = 0, sz = 0; const VmbUchar_t* buf = nullptr; if (pFrame->GetWidth(w) == VmbErrorSuccess && pFrame->GetHeight(h) == VmbErrorSuccess && @@ -51,36 +76,192 @@ public: f.cam_id = cam_id_; f.timestamp_ms = nowMs(); f.data.assign(buf, buf + sz); + { + std::lock_guard lock(mutex_); + latest_ = std::move(f); + has_ = true; + } + if (stats_) { + stats_->delivered.fetch_add(1); + stats_->last_w.store(w); + stats_->last_h.store(h); + stats_->last_bytes.store(sz); + } LOG_TRACE_CAT(LogCat::Camera) << "RX frame cam" << cam_id_ << ' ' << w << 'x' << h << ' ' << sz << 'B'; - if (cb_) cb_(f); } + } else { + // Incomplete/invalid delivery - usually USB bandwidth starvation on large + // frames. Surface it instead of silently dropping. + if (stats_) stats_->dropped.fetch_add(1); + LOG_WARN << "camera cam" << cam_id_ << " dropped frame (status=" + << (rc == VmbErrorSuccess ? static_cast(status) : -1) << ')'; } m_pCamera->QueueFrame(pFrame); } private: - int cam_id_; - ICameraSource::FrameCallback cb_; - std::atomic settle_{3}; + int cam_id_; + CamStats* stats_; + std::mutex mutex_; + Frame latest_; + bool has_ = false; }; +// Feature setters: log (but tolerate) failures - cameras vary in which features they +// expose, so a missing/locked feature is a warning, not fatal. +void setEnum(const CameraPtr& cam, const char* name, const char* value) { + FeaturePtr f; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && + f->SetValue(value) == VmbErrorSuccess) + return; + LOG_WARN << "camera: could not set " << name << '=' << value; +} +void setInt(const CameraPtr& cam, const char* name, VmbInt64_t value) { + FeaturePtr f; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && + f->SetValue(value) == VmbErrorSuccess) + return; + LOG_WARN << "camera: could not set " << name << '=' << value; +} +void setFloat(const CameraPtr& cam, const char* name, double value) { + FeaturePtr f; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && + f->SetValue(value) == VmbErrorSuccess) + return; + LOG_WARN << "camera: could not set " << name << '=' << value; +} +void setBool(const CameraPtr& cam, const char* name, bool value) { + FeaturePtr f; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && + f->SetValue(value) == VmbErrorSuccess) + return; + LOG_WARN << "camera: could not set " << name << '=' << (value ? "true" : "false"); +} +VmbInt64_t featureValue(const CameraPtr& cam, const char* name, VmbInt64_t fallback) { + FeaturePtr f; + VmbInt64_t v = fallback; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess) + return v; + return fallback; +} +// Read a float/string feature, returning `fallback`/"" if absent (tolerant, for telemetry). +double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) { + FeaturePtr f; + double v = fallback; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess) + return v; + return fallback; +} +std::string getString(const CameraPtr& cam, const char* name) { + FeaturePtr f; + std::string v; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess) + return v; + return {}; +} +// White-balance ratio for a colour channel ("Red"/"Blue"): select then read. +double wbRatio(const CameraPtr& cam, const char* channel) { + FeaturePtr sel; + if (SP_ACCESS(cam)->GetFeatureByName("BalanceRatioSelector", sel) == VmbErrorSuccess) + sel->SetValue(channel); + return getFloat(cam, "BalanceRatio", 0.0); +} + +// Apply the config-driven imaging settings, in-session. Order per the Alvium user +// guide: ROI/binning -> throughput -> exposure. We drive a PACED FREE-RUN stream +// (TriggerMode=Off + a fixed AcquisitionFrameRate), NOT software trigger: large +// frames streamed fast stall this USB3 host, but a low paced rate sustains and keeps +// the on-camera auto adjustments converged. (TriggerMode for FrameStart is read-only +// while AcquisitionFrameRateEnable is true, so trigger must be turned off first.) +void configureCamera(const CameraPtr& cam, const CameraConfig& c) { + setInt(cam, "BinningHorizontal", c.binning); + setInt(cam, "BinningVertical", c.binning); + setInt(cam, "OffsetX", 0); + setInt(cam, "OffsetY", 0); + setInt(cam, "Width", c.width > 0 ? c.width : featureValue(cam, "WidthMax", 0)); + setInt(cam, "Height", c.height > 0 ? c.height : featureValue(cam, "HeightMax", 0)); + if (c.offset_x > 0) setInt(cam, "OffsetX", c.offset_x); + if (c.offset_y > 0) setInt(cam, "OffsetY", c.offset_y); + + setEnum(cam, "PixelFormat", c.pixel_format.c_str()); + + setEnum(cam, "DeviceLinkThroughputLimitMode", "On"); + setInt(cam, "DeviceLinkThroughputLimit", static_cast(c.throughput_mbytes) * 1000000); + + setEnum(cam, "TriggerSelector", "FrameStart"); + // AcquisitionFrameRateEnable=On makes TriggerMode (FrameStart) read-only. If the camera was + // left with the rate enabled (or in software-trigger mode from a previous run), TriggerMode=Off + // would be rejected and we'd get NO free-run frames. So drop the rate control first to unlock + // TriggerMode, force free-run, then re-enable the paced rate. + setBool(cam, "AcquisitionFrameRateEnable", false); + setEnum(cam, "TriggerMode", "Off"); // free-run + setEnum(cam, "AcquisitionMode", "Continuous"); + setBool(cam, "AcquisitionFrameRateEnable", true); + setFloat(cam, "AcquisitionFrameRate", c.stream_fps); + + setEnum(cam, "ExposureAuto", c.exposure_auto ? "Continuous" : "Off"); + if (c.exposure_max_us > 0.0) setFloat(cam, "ExposureAutoMax", c.exposure_max_us); + setEnum(cam, "GainAuto", c.gain_auto ? "Continuous" : "Off"); + if (c.gain_max_db > 0.0) setFloat(cam, "GainAutoMax", c.gain_max_db); + setEnum(cam, "BalanceWhiteAuto", c.white_balance_auto ? "Continuous" : "Off"); + + LOG_INFO << "camera configured: binning=" << c.binning << " fmt=" << c.pixel_format + << " " << c.stream_fps << "fps throughput=" << c.throughput_mbytes << "MB/s" + << " autoExp=" << c.exposure_auto << " autoWB=" << c.white_balance_auto; +} + } // namespace struct VimbaCameraSource::Impl { - explicit Impl(std::vector ids) - : camera_ids(std::move(ids)), sys(VmbSystem::GetInstance()) {} + explicit Impl(CameraConfig cfg) : config(std::move(cfg)), sys(VmbSystem::GetInstance()) {} - std::vector camera_ids; - VmbSystem& sys; - std::vector cameras; - std::vector observers; - ICameraSource::FrameCallback callback; - bool started = false; + struct Identity { std::string id, model, serial; }; + struct Telemetry { + double exposure_us = 0, gain_db = 0, wb_red = 0, wb_blue = 0, temperature_c = 0, actual_fps = 0; + }; + + CameraConfig config; + VmbSystem& sys; + std::vector cameras; + std::vector observers; + std::vector> stats; // one per camera; survives restart + std::vector identity; // read once at open() + std::vector telemetry; // live values, cached ~1 Hz + long long telemetry_ms = 0; // last telemetry refresh + ICameraSource::FrameCallback callback; + bool started = false; + long long last_captured_ts = 0; // freshness watermark + long long last_no_frame_warn_ms = 0; + + // Begin/end a free-run acquisition session (fresh observers each time). Split out so + // trigger() can restart the stream when it stalls. Stats persist across restarts. + void startAcquisition() { + observers.clear(); + int cam_id = 0; + for (auto& cam : cameras) { + IFrameObserverPtr observer(new FrameObserver(cam, cam_id, stats[cam_id].get())); + if (cam->StartContinuousImageAcquisition(5, observer) != VmbErrorSuccess) + throw std::runtime_error("Could not start acquisition"); + observers.push_back(observer); + ++cam_id; + } + } + void stopAcquisition() { + for (auto& cam : cameras) cam->StopContinuousImageAcquisition(); + observers.clear(); + } + // Newest frame timestamp across all cameras (0 if none delivered yet). + long long newestTs() { + long long t = 0; + for (auto& obs : observers) + t = std::max(t, SP_DYN_CAST(obs)->latestTimestamp()); + return t; + } }; -VimbaCameraSource::VimbaCameraSource(std::vector camera_ids) - : impl_(std::make_unique(std::move(camera_ids))) {} +VimbaCameraSource::VimbaCameraSource(CameraConfig config) + : impl_(std::make_unique(std::move(config))) {} VimbaCameraSource::~VimbaCameraSource() { try { @@ -94,14 +275,19 @@ VimbaCameraSource::~VimbaCameraSource() { void VimbaCameraSource::open() { if (impl_->sys.Startup() != VmbErrorSuccess) throw std::runtime_error("Could not start Vimba X API"); - for (const auto& id : impl_->camera_ids) { + for (const auto& id : impl_->config.ids) { CameraPtr cam; if (impl_->sys.GetCameraByID(id.c_str(), cam) != VmbErrorSuccess) throw std::runtime_error("Camera not found: " + id); if (cam->Open(VmbAccessModeFull) != VmbErrorSuccess) throw std::runtime_error("Could not open camera: " + id); impl_->cameras.push_back(cam); - LOG_INFO << "Opened camera " << id; + // Per-camera stats (persist across restart) + identity (read once here). + impl_->stats.push_back(std::make_unique()); + impl_->telemetry.emplace_back(); + impl_->identity.push_back({id, getString(cam, "DeviceModelName"), + getString(cam, "DeviceSerialNumber")}); + LOG_INFO << "Opened camera " << id << " (" << impl_->identity.back().model << ')'; } } @@ -112,38 +298,56 @@ void VimbaCameraSource::close() { } void VimbaCameraSource::start() { - int cam_id = 0; - for (auto& cam : impl_->cameras) { - IFrameObserverPtr observer(new FrameObserver(cam, cam_id, impl_->callback)); - if (cam->StartContinuousImageAcquisition(5, observer) != VmbErrorSuccess) - throw std::runtime_error("Could not start acquisition"); - impl_->observers.push_back(observer); - ++cam_id; - } + for (auto& cam : impl_->cameras) configureCamera(cam, impl_->config); + impl_->startAcquisition(); impl_->started = true; } void VimbaCameraSource::stop() { - for (auto& cam : impl_->cameras) cam->StopContinuousImageAcquisition(); - impl_->observers.clear(); + impl_->stopAcquisition(); impl_->started = false; } bool VimbaCameraSource::trigger() { - if (impl_->cameras.empty()) return false; - LOG_TRACE_CAT(LogCat::Camera) << "TX trigger cam0"; - for (auto& obs : impl_->observers) - SP_DYN_CAST(obs)->resetSettle(); - - VmbErrorType result = VmbErrorSuccess; - for (int i = 0; i < 4; ++i) { // 3 settle frames + 1 real - FeaturePtr feature; - if (SP_ACCESS(impl_->cameras[0])->GetFeatureByName("TriggerSoftware", feature) == - VmbErrorSuccess) - result = feature->RunCommand(); - std::this_thread::sleep_for(std::chrono::milliseconds(400)); + // Paced free-run: deliver the freshest complete frame per waypoint. This host's USB3 + // stream reliably delivers the first frames of a session but stalls after a burst; if + // no frame newer than the last capture is available, the stream has stalled - restart + // acquisition to unstick it and wait briefly for a fresh frame. Without this, keep-latest + // would hand the same stale frame to every remaining waypoint (all overwriting one file). + if (impl_->newestTs() <= impl_->last_captured_ts) { + LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition"; + for (auto& s : impl_->stats) s->restarts.fetch_add(1); + try { + impl_->stopAcquisition(); + impl_->startAcquisition(); + } catch (const std::exception& e) { + LOG_WARN << "camera: acquisition restart failed: " << e.what(); + } + const long long deadline = nowMs() + 3000; + while (impl_->newestTs() <= impl_->last_captured_ts && nowMs() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); } - return result == VmbErrorSuccess; + + bool any = false; + for (auto& obs : impl_->observers) { + Frame f; + if (SP_DYN_CAST(obs)->takeLatest(f)) { + LOG_TRACE_CAT(LogCat::Camera) << "capture cam" << f.cam_id << " ts=" << f.timestamp_ms; + impl_->last_captured_ts = std::max(impl_->last_captured_ts, f.timestamp_ms); + if (impl_->callback) impl_->callback(f); + any = true; + } + } + if (!any) { + // The scheduler retries trigger() every tick until a frame is ready, so a brief + // warm-up (or a fully stalled stream) would otherwise flood the log. Rate-limit. + long long now = nowMs(); + if (now - impl_->last_no_frame_warn_ms > 5000) { + LOG_WARN << "camera: no frame available to capture yet (stream warming up or stalled)"; + impl_->last_no_frame_warn_ms = now; + } + } + return any; } bool VimbaCameraSource::setFrameRate(double fps) { @@ -153,7 +357,10 @@ bool VimbaCameraSource::setFrameRate(double fps) { if (SP_ACCESS(cam)->GetFeatureByName("AcquisitionFrameRate", feature) == VmbErrorSuccess) result = feature->SetValue(fps); } - if (result == VmbErrorSuccess) LOG_INFO << "camera fps set to " << fps; + if (result == VmbErrorSuccess) { + impl_->config.stream_fps = fps; + LOG_INFO << "camera fps set to " << fps; + } return result == VmbErrorSuccess; } @@ -161,4 +368,50 @@ void VimbaCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = s int VimbaCameraSource::cameraCount() const { return static_cast(impl_->cameras.size()); } +std::vector VimbaCameraSource::deviceInfo() { + // Refresh the live sensor telemetry at most ~1 Hz - deviceInfo() is called every UI + // tick (~10 Hz) but runs on the control thread (serialized with trigger()), and reading + // camera features is not free. Identity + stats come from cached/atomic state. + const long long now = nowMs(); + if (now - impl_->telemetry_ms > 1000) { + impl_->telemetry_ms = now; + for (size_t i = 0; i < impl_->cameras.size(); ++i) { + auto& cam = impl_->cameras[i]; + auto& t = impl_->telemetry[i]; + t.exposure_us = getFloat(cam, "ExposureTime"); + t.gain_db = getFloat(cam, "Gain"); + t.temperature_c = getFloat(cam, "DeviceTemperature"); + t.actual_fps = getFloat(cam, "AcquisitionFrameRate"); + t.wb_red = wbRatio(cam, "Red"); + t.wb_blue = wbRatio(cam, "Blue"); + } + } + + std::vector out; + out.reserve(impl_->cameras.size()); + for (size_t i = 0; i < impl_->cameras.size(); ++i) { + const CamStats& s = *impl_->stats[i]; + const Impl::Telemetry& t = impl_->telemetry[i]; + CameraDeviceInfo d; + d.id = impl_->identity[i].id; + d.model = impl_->identity[i].model; + d.serial = impl_->identity[i].serial; + d.streaming = impl_->started && !impl_->observers.empty(); + d.width = s.last_w.load(); + d.height = s.last_h.load(); + d.payload_bytes = s.last_bytes.load(); + d.exposure_us = t.exposure_us; + d.gain_db = t.gain_db; + d.wb_red = t.wb_red; + d.wb_blue = t.wb_blue; + d.temperature_c = t.temperature_c; + d.actual_fps = t.actual_fps; + d.frames_delivered = s.delivered.load(); + d.frames_dropped = s.dropped.load(); + d.restarts = s.restarts.load(); + out.push_back(std::move(d)); + } + return out; +} + } // namespace fgc diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 7c332d8..e591757 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,13 @@ namespace fgc { namespace { +// Set by SIGINT/SIGTERM so the control loop exits cleanly and tears the camera down +// (StopContinuousImageAcquisition + Close). Without this, a Ctrl-C / kill / systemd +// stop skips teardown and leaves the camera acquiring - which wedges it so the NEXT +// run gets no frames. Only an atomic flag is set here (async-signal-safe). +std::atomic g_shutdown_requested{false}; +void handleShutdownSignal(int) { g_shutdown_requested.store(true); } + long long nowEpochMs() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) @@ -266,7 +274,7 @@ struct Application::Impl { return std::make_unique(count); } #if FGC_WITH_VIMBA - return std::make_unique(cfg.camera.ids); + return std::make_unique(cfg.camera); #else return std::make_unique(1); #endif @@ -399,6 +407,44 @@ struct Application::Impl { s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0; s.capture.camera_count = camera ? camera->cameraCount() : 0; s.capture.labels = cfg.camera.labels; + // Static imaging + encoding config, for the expanded camera view. + { + auto& cc = s.capture.config; + cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera); + cc.ids = cfg.camera.ids; + cc.pixel_format = cfg.camera.pixel_format; + cc.binning = cfg.camera.binning; + cc.offset_x = cfg.camera.offset_x; + cc.offset_y = cfg.camera.offset_y; + cc.width = cfg.camera.width; + cc.height = cfg.camera.height; + cc.throughput_mbytes = cfg.camera.throughput_mbytes; + cc.stream_fps = cfg.camera.stream_fps; + cc.exposure_auto = cfg.camera.exposure_auto; + cc.exposure_max_us = cfg.camera.exposure_max_us; + cc.gain_auto = cfg.camera.gain_auto; + cc.gain_max_db = cfg.camera.gain_max_db; + cc.white_balance_auto = cfg.camera.white_balance_auto; + cc.jxl_distance = cfg.camera.jxl_distance; + cc.jxl_effort = cfg.camera.jxl_effort; + cc.output_dir = cfg.paths.output_dir; + } + // Live per-camera identity/telemetry/stats. + s.capture.devices.clear(); + if (camera) { + for (const auto& d : camera->deviceInfo()) { + CameraDeviceView v; + v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming; + v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes; + v.exposure_us = d.exposure_us; v.gain_db = d.gain_db; + v.wb_red = d.wb_red; v.wb_blue = d.wb_blue; + v.temperature_c = d.temperature_c; v.actual_fps = d.actual_fps; + v.frames_delivered = d.frames_delivered; v.frames_dropped = d.frames_dropped; + v.restarts = d.restarts; + s.capture.devices.push_back(std::move(v)); + } + } + s.capture.images_saved = pipeline ? pipeline->imagesSaved() : 0; // Defined scan grid + live cursor (read on the control thread, same as the // scheduler that mutates it, so no locking is needed). s.capture.scan_from_file = !cfg.scan.grid_file.empty(); @@ -1309,6 +1355,8 @@ struct Application::Impl { pp.labels = cfg.camera.labels; pp.tower = cfg.general.tower_name; pp.demo = opts.demo; + pp.jxl_distance = cfg.camera.jxl_distance; + pp.jxl_effort = cfg.camera.jxl_effort; // Orientation supplier: convert the per-axis encoder counts to degrees. pipeline = std::make_unique( *channel, @@ -1367,8 +1415,13 @@ struct Application::Impl { } if (opts.start) startCapture(); - LOG_INFO << "Entering control loop (type 'exit' to quit)"; - while (running) { + // Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the + // camera is always stopped and closed (an unclean exit wedges it for next time). + std::signal(SIGINT, handleShutdownSignal); + std::signal(SIGTERM, handleShutdownSignal); + + LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)"; + while (running && !g_shutdown_requested.load()) { drainCommands(); pollBackgroundResults(); // apply calibration result / emit DIAG summary+log scheduler->tick(); diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 460ce5e..d4c4d4d 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -144,6 +144,33 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { if (!id.empty()) cfg.camera.ids.push_back(id); } + // On-camera imaging + encoding. See CameraConfig for why the defaults are conservative. + cfg.camera.binning = getInt(kv, "Camera.binning", cfg.camera.binning); + cfg.camera.offset_x = getInt(kv, "Camera.offset_x", cfg.camera.offset_x); + cfg.camera.offset_y = getInt(kv, "Camera.offset_y", cfg.camera.offset_y); + cfg.camera.width = getInt(kv, "Camera.width", cfg.camera.width); + cfg.camera.height = getInt(kv, "Camera.height", cfg.camera.height); + cfg.camera.pixel_format = get(kv, "Camera.pixel_format", cfg.camera.pixel_format); + cfg.camera.throughput_mbytes = getInt(kv, "Camera.throughput_mbytes", cfg.camera.throughput_mbytes); + cfg.camera.stream_fps = getDouble(kv, "Camera.stream_fps", cfg.camera.stream_fps); + cfg.camera.exposure_auto = getBool(kv, "Camera.exposure_auto", cfg.camera.exposure_auto); + cfg.camera.exposure_max_us = getDouble(kv, "Camera.exposure_max_us", cfg.camera.exposure_max_us); + cfg.camera.gain_auto = getBool(kv, "Camera.gain_auto", cfg.camera.gain_auto); + cfg.camera.gain_max_db = getDouble(kv, "Camera.gain_max_db", cfg.camera.gain_max_db); + cfg.camera.white_balance_auto = getBool(kv, "Camera.white_balance_auto", cfg.camera.white_balance_auto); + cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance); + cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort); + if (cfg.camera.binning < 1) + throw std::runtime_error("Camera.binning must be >= 1"); + if (cfg.camera.throughput_mbytes <= 0) + throw std::runtime_error("Camera.throughput_mbytes must be > 0"); + if (cfg.camera.stream_fps <= 0.0) + throw std::runtime_error("Camera.stream_fps must be > 0"); + if (cfg.camera.jxl_distance < 0.0) + throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)"); + if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9) + throw std::runtime_error("Camera.jxl_effort must be in 1..9"); + std::string out = get(kv, "Paths.output_dir"); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index cb898ca..cfe74f7 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -134,20 +134,97 @@ Element cameraPanel(const CaptureView& c) { return panel("CAMERA", Color::Blue, vbox(std::move(rows))); } -// Expanded camera view ('c'): the defined auto-sweep scan grid with the live -// cursor highlighted. Long grids wrap into side-by-side columns so the whole -// list stays visible. +// Expanded camera view ('c'): the full camera system — identity, live sensor +// telemetry, imaging config, acquisition health, encoding/output — with the +// auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows. Element cameraDetailPanel(const CaptureView& c) { + auto num = [](double v, int dec) { + char b[32]; + std::snprintf(b, sizeof(b), "%.*f", dec, v); + return std::string(b); + }; + auto row = [](const std::string& k, Element v) { + return hbox({text(k) | dim | size(WIDTH, EQUAL, 14), std::move(v)}); + }; + + std::vector rows; + + // --- Status --- Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold) : (text(" idle ") | dim); - std::vector rows = { - hbox({text("capture ") | dim, active, filler(), - text(std::to_string(c.image_rate) + " img/s") | dim}), - hbox({text("source ") | dim, - text(c.scan_from_file ? "scan grid file (CSV)" : "generated grid")}), - hbox({text("waypoints ") | dim, text(std::to_string(c.scan_grid.size()))}), - separator(), - }; + rows.push_back(hbox({text("capture ") | dim, active, filler(), + text(num(c.image_rate, 3) + " img/s") | dim})); + rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session"))); + rows.push_back(row("output", text(c.config.output_dir.empty() ? "—" : c.config.output_dir) | dim)); + + // --- Devices: identity + live telemetry --- + rows.push_back(separator()); + rows.push_back(text("DEVICES") | bold | color(Color::Blue)); + if (c.devices.empty()) rows.push_back(text("(no camera device info)") | dim); + for (size_t i = 0; i < c.devices.size(); ++i) { + const CameraDeviceView& d = c.devices[i]; + std::string label = i < c.labels.size() ? c.labels[i] : ("cam" + std::to_string(i)); + Element st = d.streaming ? (text("streaming") | color(Color::Green)) + : (text("stopped") | color(Color::Yellow)); + rows.push_back(hbox({text(label + " ") | bold, + text(d.model.empty() ? "?" : d.model) | color(Color::Cyan), + text(" " + d.id) | dim, filler(), st})); + rows.push_back(row(" serial", text(d.serial.empty() ? "—" : d.serial) | dim)); + rows.push_back(row(" frame", text(std::to_string(d.width) + "x" + std::to_string(d.height) + + " " + num(d.payload_bytes / 1048576.0, 1) + " MB"))); + rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " + + num(d.gain_db, 1) + " dB"))); + rows.push_back(row(" white bal", text("R " + num(d.wb_red, 2) + " B " + num(d.wb_blue, 2)))); + rows.push_back(row(" sensor", text(num(d.temperature_c, 1) + " °C " + + num(d.actual_fps, 2) + " fps"))); + rows.push_back(row(" frames", hbox({ + text(std::to_string(d.frames_delivered) + " ok ") | color(Color::Green), + text(std::to_string(d.frames_dropped) + " dropped ") | + (d.frames_dropped ? color(Color::Red) : color(Color::Default)), + text(std::to_string(d.restarts) + " restarts") | dim, + }))); + } + + // --- Imaging config --- + const CameraConfigView& cf = c.config; + rows.push_back(separator()); + rows.push_back(text("IMAGING CONFIG") | bold | color(Color::Blue)); + rows.push_back(row("mode", text(cf.mock ? "MOCK (simulated)" : "real camera (Vimba X)"))); + rows.push_back(row("format", text(cf.pixel_format + " binning " + std::to_string(cf.binning) + "x"))); + rows.push_back(row("ROI", text((cf.width > 0 || cf.height > 0) + ? (std::to_string(cf.width) + "x" + std::to_string(cf.height) + + " @ " + std::to_string(cf.offset_x) + "," + + std::to_string(cf.offset_y)) + : "full sensor"))); + rows.push_back(row("throughput", text(std::to_string(cf.throughput_mbytes) + " MB/s " + + "(stream " + num(cf.stream_fps, 1) + " fps)"))); + rows.push_back(row("exposure", text(!cf.exposure_auto ? std::string("manual") + : cf.exposure_max_us > 0 + ? ("auto (max " + num(cf.exposure_max_us, 0) + " us)") + : "auto (no cap)"))); + rows.push_back(row("gain", text(!cf.gain_auto ? std::string("manual") + : cf.gain_max_db > 0 + ? ("auto (max " + num(cf.gain_max_db, 1) + " dB)") + : "auto (no cap)"))); + rows.push_back(row("white bal", text(cf.white_balance_auto ? "auto (continuous)" : "manual"))); + rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) + + " effort " + std::to_string(cf.jxl_effort)))); + + // --- Last capture --- + if (c.has_last) { + long long now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + rows.push_back(row("last capture", text(c.last_label + " " + + formatDegrees(c.last_heading_deg) + " / " + + formatDegrees(c.last_pitch_deg) + " " + + formatTimeAgo(now, c.last_ts_ms)))); + } + + // --- Scan grid --- + rows.push_back(separator()); + rows.push_back(hbox({text("SCAN GRID ") | bold | color(Color::Blue), + text(c.scan_from_file ? "(CSV file)" : "(generated)") | dim, filler(), + text(std::to_string(c.scan_grid.size()) + " waypoints") | dim})); if (!c.scan_error.empty()) { rows.push_back(hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold, @@ -155,14 +232,13 @@ Element cameraDetailPanel(const CaptureView& c) { rows.push_back(hbox({text("reason ") | dim, text(c.scan_error) | color(Color::Red)})); rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup " "'Loaded config:' log line).") | dim); - return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), - vbox(std::move(rows))); + return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan), + vbox(std::move(rows)) | yframe); } - if (c.scan_grid.empty()) { rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim); - return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), - vbox(std::move(rows))); + return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan), + vbox(std::move(rows)) | yframe); } // " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis. @@ -192,8 +268,8 @@ Element cameraDetailPanel(const CaptureView& c) { rows.push_back(hbox({text(" # ") | dim, text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim})); rows.push_back(hbox(std::move(cols))); - return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), - vbox(std::move(rows))); + return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan), + vbox(std::move(rows)) | yframe); } Element connPanel(const ConnView& v) { @@ -803,6 +879,11 @@ void TuiUi::uiLoop() { screen_.store(&screen); if (running_) screen.Loop(root); screen_.store(nullptr); + // If the FTXUI loop exited on its own - e.g. the user hit Ctrl-C, which FTXUI + // catches - running_ is still set. Tell the app to shut down so the control-thread + // poll loop doesn't hang (which would force a kill, wedging the camera). When + // stop() drove the exit it already cleared running_, so this won't double-fire. + if (running_.exchange(false) && sink_) sink_("exit"); } } // namespace fgc diff --git a/tests/test_config.cpp b/tests/test_config.cpp index ebe90b4..f22114f 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -50,6 +50,58 @@ TEST_CASE("ConfigLoader validates input") { CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}})); } +TEST_CASE("Camera imaging + encoding config: defaults and overrides") { + // Defaults chosen for reliable transfer on the LattePanda USB3 host + near-lossless output. + AppConfig d = ConfigLoader::fromMap({}); + CHECK(d.camera.binning == 2); + CHECK(d.camera.pixel_format == "RGB8"); + CHECK(d.camera.throughput_mbytes == 250); + CHECK(d.camera.stream_fps == doctest::Approx(1.0)); + CHECK(d.camera.exposure_auto == true); + CHECK(d.camera.gain_auto == true); + CHECK(d.camera.white_balance_auto == true); + CHECK(d.camera.jxl_distance == doctest::Approx(0.8)); + CHECK(d.camera.jxl_effort == 4); + + AppConfig c = ConfigLoader::fromMap({ + {"Camera.binning", "1"}, + {"Camera.pixel_format", "BayerRG8"}, + {"Camera.width", "2256"}, + {"Camera.height", "2256"}, + {"Camera.throughput_mbytes", "300"}, + {"Camera.stream_fps", "1.5"}, + {"Camera.exposure_auto", "false"}, + {"Camera.exposure_max_us", "20000"}, + {"Camera.gain_auto", "false"}, + {"Camera.gain_max_db", "12"}, + {"Camera.white_balance_auto", "false"}, + {"Camera.jxl_distance", "0"}, + {"Camera.jxl_effort", "6"}, + }); + CHECK(c.camera.binning == 1); + CHECK(c.camera.pixel_format == "BayerRG8"); + CHECK(c.camera.width == 2256); + CHECK(c.camera.height == 2256); + CHECK(c.camera.throughput_mbytes == 300); + CHECK(c.camera.stream_fps == doctest::Approx(1.5)); + CHECK(c.camera.exposure_auto == false); + CHECK(c.camera.exposure_max_us == doctest::Approx(20000.0)); + CHECK(c.camera.gain_auto == false); + CHECK(c.camera.gain_max_db == doctest::Approx(12.0)); + CHECK(c.camera.white_balance_auto == false); + CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless + CHECK(c.camera.jxl_effort == 6); +} + +TEST_CASE("Camera config validates ranges") { + CHECK_THROWS(ConfigLoader::fromMap({{"Camera.binning", "0"}})); + CHECK_THROWS(ConfigLoader::fromMap({{"Camera.throughput_mbytes", "0"}})); + CHECK_THROWS(ConfigLoader::fromMap({{"Camera.stream_fps", "0"}})); + CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_distance", "-1"}})); + CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "0"}})); // effort 1..9 + CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "10"}})); +} + TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") { const std::string in = "[General]\n" diff --git a/tests/test_uisnapshot.cpp b/tests/test_uisnapshot.cpp index 7868442..aa73143 100644 --- a/tests/test_uisnapshot.cpp +++ b/tests/test_uisnapshot.cpp @@ -1,5 +1,6 @@ #include +#include "fgc/mock/MockCameraSource.h" #include "fgc/ui/UiSnapshot.h" using namespace fgc; @@ -36,6 +37,27 @@ TEST_CASE("formatTimeAgo: buckets and the never case") { CHECK(formatTimeAgo(5'000, 9'000) == "0s ago"); // future clamps to 0 } +TEST_CASE("MockCameraSource::deviceInfo reports identity, dims, streaming, frame count") { + MockCameraSource cam(1, 320, 240); + cam.start(); + auto info = cam.deviceInfo(); + REQUIRE(info.size() == 1); + CHECK(info[0].model == "MockCamera"); + CHECK(info[0].streaming == true); + CHECK(info[0].width == 320); + CHECK(info[0].height == 240); + CHECK(info[0].frames_delivered == 0); + + int got = 0; + cam.setFrameCallback([&](const Frame&) { ++got; }); + cam.trigger(); + CHECK(got == 1); + CHECK(cam.deviceInfo()[0].frames_delivered == 1); + + cam.stop(); + CHECK(cam.deviceInfo()[0].streaming == false); +} + TEST_CASE("pendingSensorsView: grouped by sensor, all absent until drivers land") { SensorsView v = pendingSensorsView(); CHECK_FALSE(v.imu.present);