Added camera control
This commit is contained in:
parent
bdb4b82885
commit
c41cb6fc6a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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<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 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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,28 @@ struct SerialConfig {
|
|||
struct CameraConfig {
|
||||
std::vector<std::string> ids; // GigE IP or USB DEV_ id, in order
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<CameraDeviceInfo> deviceInfo() { return {}; }
|
||||
};
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ public:
|
|||
// UI's camera panel. Thread-safe; nullopt until the first capture is saved.
|
||||
std::optional<CamEvent> 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<CamEvent> last_event_;
|
||||
std::atomic<long long> saved_{0}; // images successfully written this session
|
||||
};
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
#pragma once
|
||||
|
||||
#include "fgc/Config.h"
|
||||
#include "fgc/ICameraSource.h"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::string> 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<CameraDeviceInfo> deviceInfo() override;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "fgc/Logger.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
|
||||
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<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:
|
||||
int count_;
|
||||
uint32_t width_;
|
||||
uint32_t height_;
|
||||
bool started_ = false;
|
||||
long long frames_ = 0;
|
||||
FrameCallback callback_;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
bool present = false;
|
||||
bool active = false;
|
||||
double image_rate = 0.0; // img/s
|
||||
int camera_count = 0;
|
||||
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
|
||||
// flagged, surfaced in the expanded camera view.
|
||||
std::vector<ScanWaypointView> scan_grid;
|
||||
|
|
|
|||
|
|
@ -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 << ")";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
#include "fgc/Logger.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
|
|
@ -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<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 {
|
||||
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<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 {
|
||||
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<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)
|
||||
<< "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);
|
||||
}
|
||||
|
||||
private:
|
||||
int cam_id_;
|
||||
ICameraSource::FrameCallback cb_;
|
||||
std::atomic<int> 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<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
|
||||
|
||||
struct VimbaCameraSource::Impl {
|
||||
explicit Impl(std::vector<std::string> ids)
|
||||
: camera_ids(std::move(ids)), sys(VmbSystem::GetInstance()) {}
|
||||
explicit Impl(CameraConfig cfg) : config(std::move(cfg)), sys(VmbSystem::GetInstance()) {}
|
||||
|
||||
std::vector<std::string> camera_ids;
|
||||
VmbSystem& sys;
|
||||
std::vector<CameraPtr> cameras;
|
||||
std::vector<IFrameObserverPtr> 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<CameraPtr> cameras;
|
||||
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;
|
||||
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)
|
||||
: impl_(std::make_unique<Impl>(std::move(camera_ids))) {}
|
||||
VimbaCameraSource::VimbaCameraSource(CameraConfig config)
|
||||
: impl_(std::make_unique<Impl>(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<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() {
|
||||
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<FrameObserver>(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<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) {
|
||||
|
|
@ -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<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
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
|
|
@ -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<bool> g_shutdown_requested{false};
|
||||
void handleShutdownSignal(int) { g_shutdown_requested.store(true); }
|
||||
|
||||
long long nowEpochMs() {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
|
|
@ -266,7 +274,7 @@ struct Application::Impl {
|
|||
return std::make_unique<MockCameraSource>(count);
|
||||
}
|
||||
#if FGC_WITH_VIMBA
|
||||
return std::make_unique<VimbaCameraSource>(cfg.camera.ids);
|
||||
return std::make_unique<VimbaCameraSource>(cfg.camera);
|
||||
#else
|
||||
return std::make_unique<MockCameraSource>(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<ImagePipeline>(
|
||||
*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();
|
||||
|
|
|
|||
|
|
@ -144,6 +144,33 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& 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);
|
||||
|
||||
|
|
|
|||
117
src/ui/TuiUi.cpp
117
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<Element> rows;
|
||||
|
||||
// --- Status ---
|
||||
Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold)
|
||||
: (text(" idle ") | dim);
|
||||
std::vector<Element> 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::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()) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include <doctest/doctest.h>
|
||||
|
||||
#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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue