Added camera control

This commit is contained in:
pgdalmeida 2026-07-13 19:15:38 +02:00
parent bdb4b82885
commit c41cb6fc6a
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
16 changed files with 757 additions and 83 deletions

View File

@ -29,6 +29,33 @@ id_Cam1 = DEV_0000000000
id_Cam2 = id_Cam2 =
id_Cam3 = id_Cam3 =
id_Cam4 = 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] [Serial]
; Motor-controller serial device and baud rate. ; Motor-controller serial device and baud rate.

View File

@ -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` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
| `Serial` | `baud` | int | `115200` | Serial baud rate | | `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` | `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` | | `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_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) | | `Features` | `enable_camera` | bool | `true` | (reserved) |

View File

@ -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. | | 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. | | 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<N>/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 35 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 ## Verification caveats
- **Full build verified on the LattePanda**: a `WITH_VIMBA=ON WITH_MQTT=ON` build compiles and links on the - **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) ## Possible follow-ups (not done)
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown). - 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 - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed). removed).
- **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live - **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live

View File

@ -32,6 +32,28 @@ struct SerialConfig {
struct CameraConfig { struct CameraConfig {
std::vector<std::string> ids; // GigE IP or USB DEV_ id, in order std::vector<std::string> ids; // GigE IP or USB DEV_ id, in order
std::vector<std::string> labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder std::vector<std::string> 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 { struct PathsConfig {

View File

@ -2,6 +2,7 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <string>
#include <vector> #include <vector>
namespace fgc { namespace fgc {
@ -16,6 +17,34 @@ struct Frame {
int cam_id = 0; // index into the configured camera list 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 // Abstraction over the camera array. Implemented by VimbaCameraSource (Allied
// Vision Vimba X) and MockCameraSource (synthetic frames, no hardware). // Vision Vimba X) and MockCameraSource (synthetic frames, no hardware).
// //
@ -42,6 +71,10 @@ public:
// Number of cameras this source manages. // Number of cameras this source manages.
virtual int cameraCount() const = 0; 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<CameraDeviceInfo> deviceInfo() { return {}; }
}; };
} // namespace fgc } // namespace fgc

View File

@ -58,6 +58,9 @@ public:
// UI's camera panel. Thread-safe; nullopt until the first capture is saved. // UI's camera panel. Thread-safe; nullopt until the first capture is saved.
std::optional<CamEvent> lastEvent() const; std::optional<CamEvent> lastEvent() const;
// Number of images successfully written this session (for the camera view).
long long imagesSaved() const { return saved_.load(); }
private: private:
void run(); void run();
void process(const Frame& frame); void process(const Frame& frame);
@ -75,6 +78,7 @@ private:
mutable std::mutex last_event_mutex_; mutable std::mutex last_event_mutex_;
std::optional<CamEvent> last_event_; std::optional<CamEvent> last_event_;
std::atomic<long long> saved_{0}; // images successfully written this session
}; };
} // namespace fgc } // namespace fgc

View File

@ -1,20 +1,20 @@
#pragma once #pragma once
#include "fgc/Config.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include <memory> #include <memory>
#include <string>
#include <vector>
namespace fgc { namespace fgc {
// Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens // Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the
// cameras by ID, runs continuous acquisition with a settle-then-keep frame // cameras in CameraConfig, applies the on-camera imaging settings in-session, runs a
// observer, and delivers completed frames to the callback. The encode/save // PACED FREE-RUN stream (fixed low frame rate, no software trigger - large frames
// step lives in ImagePipeline, so this class only produces 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 { class VimbaCameraSource : public ICameraSource {
public: public:
explicit VimbaCameraSource(std::vector<std::string> camera_ids); explicit VimbaCameraSource(CameraConfig config);
~VimbaCameraSource() override; ~VimbaCameraSource() override;
void open() override; void open() override;
@ -25,6 +25,7 @@ public:
bool setFrameRate(double fps) override; bool setFrameRate(double fps) override;
void setFrameCallback(FrameCallback cb) override; void setFrameCallback(FrameCallback cb) override;
int cameraCount() const override; int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override;
private: private:
struct Impl; struct Impl;

View File

@ -4,6 +4,7 @@
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <chrono> #include <chrono>
#include <string>
namespace fgc { namespace fgc {
@ -17,8 +18,8 @@ public:
void open() override { LOG_INFO << "[mock] camera opened (" << count_ << ")"; } void open() override { LOG_INFO << "[mock] camera opened (" << count_ << ")"; }
void close() override {} void close() override {}
void start() override { LOG_INFO << "[mock] camera acquisition started"; } void start() override { started_ = true; LOG_INFO << "[mock] camera acquisition started"; }
void stop() override {} void stop() override { started_ = false; }
bool trigger() override { bool trigger() override {
if (!callback_) return false; if (!callback_) return false;
@ -43,6 +44,7 @@ public:
} }
LOG_TRACE_CAT(LogCat::Camera) LOG_TRACE_CAT(LogCat::Camera)
<< "RX frame cam0 " << width_ << 'x' << height_ << ' ' << f.data.size() << "B (mock)"; << "RX frame cam0 " << width_ << 'x' << height_ << ' ' << f.data.size() << "B (mock)";
++frames_;
callback_(f); callback_(f);
return true; return true;
} }
@ -50,10 +52,32 @@ public:
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); } void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
int cameraCount() const override { return count_; } 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<CameraDeviceInfo> deviceInfo() override {
std::vector<CameraDeviceInfo> 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<long long>(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: private:
int count_; int count_;
uint32_t width_; uint32_t width_;
uint32_t height_; uint32_t height_;
bool started_ = false;
long long frames_ = 0;
FrameCallback callback_; FrameCallback callback_;
}; };

View File

@ -90,12 +90,41 @@ struct ScanWaypointView {
bool current = false; // the scheduler's live cursor position 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<std::string> 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 { struct CaptureView {
bool present = false; bool present = false;
bool active = false; bool active = false;
double image_rate = 0.0; // img/s double image_rate = 0.0; // img/s
int camera_count = 0; int camera_count = 0;
std::vector<std::string> labels; std::vector<std::string> labels;
// Full camera-system detail for the expanded ('c') view.
CameraConfigView config;
std::vector<CameraDeviceView> devices;
long long images_saved = 0;
// Defined scan grid (ControlCode 0 auto-sweep waypoints) with the live cursor // Defined scan grid (ControlCode 0 auto-sweep waypoints) with the live cursor
// flagged, surfaced in the expanded camera view. // flagged, surfaced in the expanded camera view.
std::vector<ScanWaypointView> scan_grid; std::vector<ScanWaypointView> scan_grid;

View File

@ -110,6 +110,7 @@ void ImagePipeline::process(const Frame& frame) {
last_event_ = ev; last_event_ = ev;
} }
saved_.fetch_add(1);
LOG_DEBUG << "Saved " << file.string() << " (" << img.cols << "x" << img.rows << ")"; LOG_DEBUG << "Saved " << file.string() << " (" << img.cols << "x" << img.rows << ")";
} }

View File

@ -2,8 +2,11 @@
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <algorithm>
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <memory>
#include <mutex>
#include <stdexcept> #include <stdexcept>
#include <thread> #include <thread>
@ -21,23 +24,45 @@ long long nowMs() {
.count(); .count();
} }
// Frame observer: dumps the first few frames after each trigger (sensor // Cumulative per-camera acquisition stats. Lives in Impl (NOT the observer) so it
// settling), then delivers completed frames to the source callback. // survives the restart-on-stall, which recreates the observer each session.
struct CamStats {
std::atomic<long long> delivered{0};
std::atomic<long long> dropped{0};
std::atomic<long long> restarts{0};
std::atomic<unsigned> last_w{0};
std::atomic<unsigned> last_h{0};
std::atomic<long long> 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 { class FrameObserver : public IFrameObserver {
public: public:
FrameObserver(CameraPtr camera, int cam_id, ICameraSource::FrameCallback cb) FrameObserver(CameraPtr camera, int cam_id, CamStats* stats)
: IFrameObserver(camera), cam_id_(cam_id), cb_(std::move(cb)) {} : 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<std::mutex> 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<std::mutex> lock(mutex_);
return has_ ? latest_.timestamp_ms : 0;
}
void FrameReceived(const FramePtr pFrame) override { void FrameReceived(const FramePtr pFrame) override {
if (settle_.fetch_sub(1) > 0) { // still settling -> discard
m_pCamera->QueueFrame(pFrame);
return;
}
VmbFrameStatusType status; VmbFrameStatusType status;
if (pFrame->GetReceiveStatus(status) == VmbErrorSuccess && VmbErrorType rc = pFrame->GetReceiveStatus(status);
status == VmbFrameStatusComplete) { if (rc == VmbErrorSuccess && status == VmbFrameStatusComplete) {
VmbUint32_t w = 0, h = 0, sz = 0; VmbUint32_t w = 0, h = 0, sz = 0;
const VmbUchar_t* buf = nullptr; const VmbUchar_t* buf = nullptr;
if (pFrame->GetWidth(w) == VmbErrorSuccess && if (pFrame->GetWidth(w) == VmbErrorSuccess &&
@ -51,36 +76,192 @@ public:
f.cam_id = cam_id_; f.cam_id = cam_id_;
f.timestamp_ms = nowMs(); f.timestamp_ms = nowMs();
f.data.assign(buf, buf + sz); f.data.assign(buf, buf + sz);
{
std::lock_guard<std::mutex> 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) LOG_TRACE_CAT(LogCat::Camera)
<< "RX frame cam" << cam_id_ << ' ' << w << 'x' << h << ' ' << sz << 'B'; << "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<int>(status) : -1) << ')';
} }
m_pCamera->QueueFrame(pFrame); m_pCamera->QueueFrame(pFrame);
} }
private: private:
int cam_id_; int cam_id_;
ICameraSource::FrameCallback cb_; CamStats* stats_;
std::atomic<int> settle_{3}; 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<VmbInt64_t>(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 } // namespace
struct VimbaCameraSource::Impl { struct VimbaCameraSource::Impl {
explicit Impl(std::vector<std::string> ids) explicit Impl(CameraConfig cfg) : config(std::move(cfg)), sys(VmbSystem::GetInstance()) {}
: camera_ids(std::move(ids)), sys(VmbSystem::GetInstance()) {}
std::vector<std::string> camera_ids; 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; VmbSystem& sys;
std::vector<CameraPtr> cameras; std::vector<CameraPtr> cameras;
std::vector<IFrameObserverPtr> observers; std::vector<IFrameObserverPtr> observers;
std::vector<std::unique_ptr<CamStats>> stats; // one per camera; survives restart
std::vector<Identity> identity; // read once at open()
std::vector<Telemetry> telemetry; // live values, cached ~1 Hz
long long telemetry_ms = 0; // last telemetry refresh
ICameraSource::FrameCallback callback; ICameraSource::FrameCallback callback;
bool started = false; 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<FrameObserver>(obs)->latestTimestamp());
return t;
}
}; };
VimbaCameraSource::VimbaCameraSource(std::vector<std::string> camera_ids) VimbaCameraSource::VimbaCameraSource(CameraConfig config)
: impl_(std::make_unique<Impl>(std::move(camera_ids))) {} : impl_(std::make_unique<Impl>(std::move(config))) {}
VimbaCameraSource::~VimbaCameraSource() { VimbaCameraSource::~VimbaCameraSource() {
try { try {
@ -94,14 +275,19 @@ VimbaCameraSource::~VimbaCameraSource() {
void VimbaCameraSource::open() { void VimbaCameraSource::open() {
if (impl_->sys.Startup() != VmbErrorSuccess) if (impl_->sys.Startup() != VmbErrorSuccess)
throw std::runtime_error("Could not start Vimba X API"); 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; CameraPtr cam;
if (impl_->sys.GetCameraByID(id.c_str(), cam) != VmbErrorSuccess) if (impl_->sys.GetCameraByID(id.c_str(), cam) != VmbErrorSuccess)
throw std::runtime_error("Camera not found: " + id); throw std::runtime_error("Camera not found: " + id);
if (cam->Open(VmbAccessModeFull) != VmbErrorSuccess) if (cam->Open(VmbAccessModeFull) != VmbErrorSuccess)
throw std::runtime_error("Could not open camera: " + id); throw std::runtime_error("Could not open camera: " + id);
impl_->cameras.push_back(cam); 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<CamStats>());
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() { void VimbaCameraSource::start() {
int cam_id = 0; for (auto& cam : impl_->cameras) configureCamera(cam, impl_->config);
for (auto& cam : impl_->cameras) { impl_->startAcquisition();
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;
}
impl_->started = true; impl_->started = true;
} }
void VimbaCameraSource::stop() { void VimbaCameraSource::stop() {
for (auto& cam : impl_->cameras) cam->StopContinuousImageAcquisition(); impl_->stopAcquisition();
impl_->observers.clear();
impl_->started = false; impl_->started = false;
} }
bool VimbaCameraSource::trigger() { bool VimbaCameraSource::trigger() {
if (impl_->cameras.empty()) return false; // Paced free-run: deliver the freshest complete frame per waypoint. This host's USB3
LOG_TRACE_CAT(LogCat::Camera) << "TX trigger cam0"; // stream reliably delivers the first frames of a session but stalls after a burst; if
for (auto& obs : impl_->observers) // no frame newer than the last capture is available, the stream has stalled - restart
SP_DYN_CAST<FrameObserver>(obs)->resetSettle(); // 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).
VmbErrorType result = VmbErrorSuccess; if (impl_->newestTs() <= impl_->last_captured_ts) {
for (int i = 0; i < 4; ++i) { // 3 settle frames + 1 real LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition";
FeaturePtr feature; for (auto& s : impl_->stats) s->restarts.fetch_add(1);
if (SP_ACCESS(impl_->cameras[0])->GetFeatureByName("TriggerSoftware", feature) == try {
VmbErrorSuccess) impl_->stopAcquisition();
result = feature->RunCommand(); impl_->startAcquisition();
std::this_thread::sleep_for(std::chrono::milliseconds(400)); } catch (const std::exception& e) {
LOG_WARN << "camera: acquisition restart failed: " << e.what();
} }
return result == VmbErrorSuccess; const long long deadline = nowMs() + 3000;
while (impl_->newestTs() <= impl_->last_captured_ts && nowMs() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
bool any = false;
for (auto& obs : impl_->observers) {
Frame f;
if (SP_DYN_CAST<FrameObserver>(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) { bool VimbaCameraSource::setFrameRate(double fps) {
@ -153,7 +357,10 @@ bool VimbaCameraSource::setFrameRate(double fps) {
if (SP_ACCESS(cam)->GetFeatureByName("AcquisitionFrameRate", feature) == VmbErrorSuccess) if (SP_ACCESS(cam)->GetFeatureByName("AcquisitionFrameRate", feature) == VmbErrorSuccess)
result = feature->SetValue(fps); 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; return result == VmbErrorSuccess;
} }
@ -161,4 +368,50 @@ void VimbaCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = s
int VimbaCameraSource::cameraCount() const { return static_cast<int>(impl_->cameras.size()); } int VimbaCameraSource::cameraCount() const { return static_cast<int>(impl_->cameras.size()); }
std::vector<CameraDeviceInfo> 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<CameraDeviceInfo> 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 } // namespace fgc

View File

@ -31,6 +31,7 @@
#include <cctype> #include <cctype>
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <csignal>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
@ -56,6 +57,13 @@ namespace fgc {
namespace { 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<bool> g_shutdown_requested{false};
void handleShutdownSignal(int) { g_shutdown_requested.store(true); }
long long nowEpochMs() { long long nowEpochMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>( return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()) std::chrono::system_clock::now().time_since_epoch())
@ -266,7 +274,7 @@ struct Application::Impl {
return std::make_unique<MockCameraSource>(count); return std::make_unique<MockCameraSource>(count);
} }
#if FGC_WITH_VIMBA #if FGC_WITH_VIMBA
return std::make_unique<VimbaCameraSource>(cfg.camera.ids); return std::make_unique<VimbaCameraSource>(cfg.camera);
#else #else
return std::make_unique<MockCameraSource>(1); return std::make_unique<MockCameraSource>(1);
#endif #endif
@ -399,6 +407,44 @@ struct Application::Impl {
s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0; s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0;
s.capture.camera_count = camera ? camera->cameraCount() : 0; s.capture.camera_count = camera ? camera->cameraCount() : 0;
s.capture.labels = cfg.camera.labels; 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 // Defined scan grid + live cursor (read on the control thread, same as the
// scheduler that mutates it, so no locking is needed). // scheduler that mutates it, so no locking is needed).
s.capture.scan_from_file = !cfg.scan.grid_file.empty(); s.capture.scan_from_file = !cfg.scan.grid_file.empty();
@ -1309,6 +1355,8 @@ struct Application::Impl {
pp.labels = cfg.camera.labels; pp.labels = cfg.camera.labels;
pp.tower = cfg.general.tower_name; pp.tower = cfg.general.tower_name;
pp.demo = opts.demo; 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. // Orientation supplier: convert the per-axis encoder counts to degrees.
pipeline = std::make_unique<ImagePipeline>( pipeline = std::make_unique<ImagePipeline>(
*channel, *channel,
@ -1367,8 +1415,13 @@ struct Application::Impl {
} }
if (opts.start) startCapture(); if (opts.start) startCapture();
LOG_INFO << "Entering control loop (type 'exit' to quit)"; // Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the
while (running) { // 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(); drainCommands();
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
scheduler->tick(); scheduler->tick();

View File

@ -144,6 +144,33 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
if (!id.empty()) cfg.camera.ids.push_back(id); 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"); std::string out = get(kv, "Paths.output_dir");
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);

View File

@ -134,20 +134,97 @@ Element cameraPanel(const CaptureView& c) {
return panel("CAMERA", Color::Blue, vbox(std::move(rows))); return panel("CAMERA", Color::Blue, vbox(std::move(rows)));
} }
// Expanded camera view ('c'): the defined auto-sweep scan grid with the live // Expanded camera view ('c'): the full camera system — identity, live sensor
// cursor highlighted. Long grids wrap into side-by-side columns so the whole // telemetry, imaging config, acquisition health, encoding/output — with the
// list stays visible. // auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows.
Element cameraDetailPanel(const CaptureView& c) { 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<Element> rows;
// --- Status ---
Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold) Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold)
: (text(" idle ") | dim); : (text(" idle ") | dim);
std::vector<Element> rows = { rows.push_back(hbox({text("capture ") | dim, active, filler(),
hbox({text("capture ") | dim, active, filler(), text(num(c.image_rate, 3) + " img/s") | dim}));
text(std::to_string(c.image_rate) + " img/s") | dim}), rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session")));
hbox({text("source ") | dim, rows.push_back(row("output", text(c.config.output_dir.empty() ? "" : c.config.output_dir) | dim));
text(c.scan_from_file ? "scan grid file (CSV)" : "generated grid")}),
hbox({text("waypoints ") | dim, text(std::to_string(c.scan_grid.size()))}), // --- Devices: identity + live telemetry ---
separator(), 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::milliseconds>(
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()) { if (!c.scan_error.empty()) {
rows.push_back(hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold, 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(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 " rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup "
"'Loaded config:' log line).") | dim); "'Loaded config:' log line).") | dim);
return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
vbox(std::move(rows))); vbox(std::move(rows)) | yframe);
} }
if (c.scan_grid.empty()) { if (c.scan_grid.empty()) {
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim); rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
vbox(std::move(rows))); vbox(std::move(rows)) | yframe);
} }
// " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis. // " # | 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, rows.push_back(hbox({text(" # ") | dim,
text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim})); text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim}));
rows.push_back(hbox(std::move(cols))); rows.push_back(hbox(std::move(cols)));
return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
vbox(std::move(rows))); vbox(std::move(rows)) | yframe);
} }
Element connPanel(const ConnView& v) { Element connPanel(const ConnView& v) {
@ -803,6 +879,11 @@ void TuiUi::uiLoop() {
screen_.store(&screen); screen_.store(&screen);
if (running_) screen.Loop(root); if (running_) screen.Loop(root);
screen_.store(nullptr); 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 } // namespace fgc

View File

@ -50,6 +50,58 @@ TEST_CASE("ConfigLoader validates input") {
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}})); 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") { TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") {
const std::string in = const std::string in =
"[General]\n" "[General]\n"

View File

@ -1,5 +1,6 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include "fgc/mock/MockCameraSource.h"
#include "fgc/ui/UiSnapshot.h" #include "fgc/ui/UiSnapshot.h"
using namespace fgc; 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 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") { TEST_CASE("pendingSensorsView: grouped by sensor, all absent until drivers land") {
SensorsView v = pendingSensorsView(); SensorsView v = pendingSensorsView();
CHECK_FALSE(v.imu.present); CHECK_FALSE(v.imu.present);