Added IMU pitch to CamEvent; Changed IMU reading to before jxl encoding to avoid lag
This commit is contained in:
parent
5fce32aee7
commit
8eda0b4a36
|
|
@ -80,8 +80,10 @@ corrupted.
|
||||||
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
|
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
|
||||||
capture* below.
|
capture* below.
|
||||||
6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the
|
6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the
|
||||||
`ImagePipeline`. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes
|
`ImagePipeline`. `submit()` samples the gimbal pose there and then — yaw from the encoder, pitch from the IMU —
|
||||||
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders).
|
and the pose travels through the queue with the frame, so encode latency cannot attribute a later pose to earlier
|
||||||
|
pixels. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes
|
||||||
|
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` carrying that pose.
|
||||||
|
|
||||||
## Capture state machine
|
## Capture state machine
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
|
||||||
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
|
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
|
||||||
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
|
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
|
||||||
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always |
|
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always |
|
||||||
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread) | always |
|
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
|
||||||
|
|
||||||
## Mock / null implementations
|
## Mock / null implementations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,37 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `fwt` | string | Tower name (`config.ini` `tower_name`) |
|
| `fwt` | string | Tower name (`config.ini` `tower_name`) |
|
||||||
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
|
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
|
||||||
| `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time |
|
| `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time, from the **yaw encoder** |
|
||||||
| `pit` | int | Gimbal pitch elevation **× 10** at capture time (derived from the pitch encoder) |
|
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle |
|
||||||
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
|
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
|
||||||
|
|
||||||
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
|
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
|
||||||
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`.
|
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`.
|
||||||
|
|
||||||
|
**`pit` is measured against gravity, `hdg` against the encoder.** The two axes come from
|
||||||
|
different sources deliberately. The pitch encoder only measures the axis relative to the
|
||||||
|
gimbal's own base, so a leaning mast is invisible to it; the IMU measures against gravity
|
||||||
|
and therefore reports the elevation the imagery is actually pointing at. Yaw stays on the
|
||||||
|
encoder because IMU yaw drifts and depends on the heading reset and XKF profile, whereas
|
||||||
|
encoder yaw is calibrated against the IMU by `gimbal calib` and then holds.
|
||||||
|
|
||||||
|
Two consequences for consumers:
|
||||||
|
|
||||||
|
- **`pit` no longer agrees with the commanded waypoint by construction.** Commanding `+10.0`
|
||||||
|
and receiving `pit: 104` is correct — the residual is real tilt, not error. Before this
|
||||||
|
change both came off the same encoder scale and agreed trivially.
|
||||||
|
- **Archives spanning the change contain two different measurements** in this field: an axis
|
||||||
|
angle before it, a true elevation after. If the mast is level the two agree; if it leans,
|
||||||
|
they differ by the lean.
|
||||||
|
|
||||||
|
If the IMU is disabled (`[Features] enable_imu = false`), `pit` falls back to the encoder as
|
||||||
|
before. If the IMU is enabled but has no fresh sample, it also falls back — and logs a
|
||||||
|
warning saying so, once per outage, since that mixes the two measurements in one field.
|
||||||
|
|
||||||
|
Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG XL encode
|
||||||
|
finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
|
||||||
|
pixels.
|
||||||
|
|
||||||
### Env payload
|
### Env payload
|
||||||
|
|
||||||
Built by `MqttControlChannel::publishEnv` from an `EnvEvent`, sourced from whichever `IEnvSensor` backend is
|
Built by `MqttControlChannel::publishEnv` from an `EnvEvent`, sourced from whichever `IEnvSensor` backend is
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ public:
|
||||||
};
|
};
|
||||||
|
|
||||||
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
|
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
|
||||||
|
// Called from submit(), i.e. on the camera's delivery thread at the moment the
|
||||||
|
// frame arrives - see submit() for why it must not be sampled any later.
|
||||||
ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation, Params params);
|
ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation, Params params);
|
||||||
~ImagePipeline();
|
~ImagePipeline();
|
||||||
|
|
||||||
|
|
@ -55,6 +57,14 @@ public:
|
||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
// Enqueue a frame for processing (thread-safe; copies the frame).
|
// Enqueue a frame for processing (thread-safe; copies the frame).
|
||||||
|
//
|
||||||
|
// The gimbal orientation is sampled HERE, not in the worker, and travels with
|
||||||
|
// the frame. Sampling it after encoding would attribute to the image whatever
|
||||||
|
// direction the gimbal had reached by the time the encode finished - which is
|
||||||
|
// only harmless while captures happen at standstill and nothing overlaps. The
|
||||||
|
// moment encoding runs while the gimbal is already slewing to the next
|
||||||
|
// waypoint, or the queue backs up, the published heading and pitch belong to a
|
||||||
|
// different pose than the pixels.
|
||||||
void submit(const Frame& frame);
|
void submit(const Frame& frame);
|
||||||
|
|
||||||
// Runtime-adjustable encoder settings (console "set camera ..." commands).
|
// Runtime-adjustable encoder settings (console "set camera ..." commands).
|
||||||
|
|
@ -72,8 +82,15 @@ public:
|
||||||
long long imagesSaved() const { return saved_.load(); }
|
long long imagesSaved() const { return saved_.load(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// A frame and the pose it was taken at, captured together at submit() time so
|
||||||
|
// encoding latency and queue depth cannot separate them.
|
||||||
|
struct QueuedFrame {
|
||||||
|
Frame frame;
|
||||||
|
Orientation orientation;
|
||||||
|
};
|
||||||
|
|
||||||
void run();
|
void run();
|
||||||
void process(const Frame& frame);
|
void process(const QueuedFrame& queued);
|
||||||
// Output settings for a camera index, or sane defaults if the index is out of
|
// Output settings for a camera index, or sane defaults if the index is out of
|
||||||
// range (a source delivering more cameras than were configured).
|
// range (a source delivering more cameras than were configured).
|
||||||
const CameraOutput& outputFor(int cam_id) const;
|
const CameraOutput& outputFor(int cam_id) const;
|
||||||
|
|
@ -82,7 +99,7 @@ private:
|
||||||
std::function<Orientation()> orientation_;
|
std::function<Orientation()> orientation_;
|
||||||
Params params_;
|
Params params_;
|
||||||
|
|
||||||
std::queue<Frame> queue_;
|
std::queue<QueuedFrame> queue_;
|
||||||
std::mutex mutex_;
|
std::mutex mutex_;
|
||||||
std::condition_variable cv_;
|
std::condition_variable cv_;
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
|
|
|
||||||
|
|
@ -44,24 +44,28 @@ void ImagePipeline::stop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImagePipeline::submit(const Frame& frame) {
|
void ImagePipeline::submit(const Frame& frame) {
|
||||||
|
// Sample the pose now, while the frame is fresh, and carry it through the
|
||||||
|
// queue. Both sources behind this callback are mutex-guarded snapshot reads,
|
||||||
|
// so it costs the camera's delivery thread a lock and a copy.
|
||||||
|
QueuedFrame q{frame, orientation_ ? orientation_() : Orientation{}};
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
queue_.push(frame);
|
queue_.push(std::move(q));
|
||||||
}
|
}
|
||||||
cv_.notify_one();
|
cv_.notify_one();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImagePipeline::run() {
|
void ImagePipeline::run() {
|
||||||
while (running_) {
|
while (running_) {
|
||||||
Frame frame;
|
QueuedFrame queued;
|
||||||
{
|
{
|
||||||
std::unique_lock<std::mutex> lock(mutex_);
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
cv_.wait(lock, [this] { return !queue_.empty() || !running_; });
|
cv_.wait(lock, [this] { return !queue_.empty() || !running_; });
|
||||||
if (!running_ && queue_.empty()) break;
|
if (!running_ && queue_.empty()) break;
|
||||||
frame = std::move(queue_.front());
|
queued = std::move(queue_.front());
|
||||||
queue_.pop();
|
queue_.pop();
|
||||||
}
|
}
|
||||||
process(frame);
|
process(queued);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +78,8 @@ const ImagePipeline::CameraOutput& ImagePipeline::outputFor(int cam_id) const {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImagePipeline::process(const Frame& frame) {
|
void ImagePipeline::process(const QueuedFrame& queued) {
|
||||||
|
const Frame& frame = queued.frame;
|
||||||
if (frame.channels != 1 && frame.channels != 3) {
|
if (frame.channels != 1 && frame.channels != 3) {
|
||||||
LOG_WARN << "Dropping frame with unsupported channel count " << frame.channels;
|
LOG_WARN << "Dropping frame with unsupported channel count " << frame.channels;
|
||||||
return;
|
return;
|
||||||
|
|
@ -122,7 +127,9 @@ void ImagePipeline::process(const Frame& frame) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Orientation o = orientation_ ? orientation_() : Orientation{};
|
// Sampled at submit(), not here: by this point the encode above has run and
|
||||||
|
// the gimbal may already be slewing to the next waypoint.
|
||||||
|
const Orientation& o = queued.orientation;
|
||||||
CamEvent ev;
|
CamEvent ev;
|
||||||
ev.tower = params_.tower;
|
ev.tower = params_.tower;
|
||||||
ev.camera = label;
|
ev.camera = label;
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,10 @@ struct Application::Impl {
|
||||||
mutable ImuConfigView imu_config_view_; // formatted MTi config (read once)
|
mutable ImuConfigView imu_config_view_; // formatted MTi config (read once)
|
||||||
mutable bool imu_config_done_ = false;
|
mutable bool imu_config_done_ = false;
|
||||||
mutable long long last_env_published_ts_ = 0; // gate MQTT publish to one per fresh sample
|
mutable long long last_env_published_ts_ = 0; // gate MQTT publish to one per fresh sample
|
||||||
|
// Latches the "no IMU sample, publishing encoder pitch" warning so an outage
|
||||||
|
// costs one line, not one per frame. Written from the camera delivery
|
||||||
|
// thread(s) via the orientation supplier, hence atomic.
|
||||||
|
std::atomic<bool> imu_pitch_warned_{false};
|
||||||
|
|
||||||
std::atomic<bool> running{true};
|
std::atomic<bool> running{true};
|
||||||
std::mutex cmd_mutex;
|
std::mutex cmd_mutex;
|
||||||
|
|
@ -1746,13 +1750,35 @@ struct Application::Impl {
|
||||||
// cameras in this order too.
|
// cameras in this order too.
|
||||||
for (const auto* c : cfg.camera.active())
|
for (const auto* c : cfg.camera.active())
|
||||||
pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort});
|
pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort});
|
||||||
// Orientation supplier: convert the per-axis encoder counts to degrees.
|
// Orientation supplier, called by ImagePipeline::submit() as each frame
|
||||||
|
// arrives. The two axes come from different sources on purpose:
|
||||||
|
//
|
||||||
|
// yaw - the encoder. IMU yaw drifts and depends on the heading reset
|
||||||
|
// and the XKF profile, whereas encoder yaw is calibrated against
|
||||||
|
// the IMU by `gimbal calib` and then stays put.
|
||||||
|
// pitch - the IMU. The encoder only measures the axis relative to the
|
||||||
|
// gimbal's own base, so a leaning mast is invisible to it; the
|
||||||
|
// IMU measures against gravity, which is the true elevation the
|
||||||
|
// imagery needs to be georeferenced against.
|
||||||
pipeline = std::make_unique<ImagePipeline>(
|
pipeline = std::make_unique<ImagePipeline>(
|
||||||
*channel,
|
*channel,
|
||||||
[this] {
|
[this] {
|
||||||
MotorTelemetry t = motor->telemetry();
|
MotorTelemetry t = motor->telemetry();
|
||||||
return Orientation{static_cast<float>(cfg.geometry.yaw.toDeg(t.yaw.xenc)),
|
Orientation o{static_cast<float>(cfg.geometry.yaw.toDeg(t.yaw.xenc)),
|
||||||
static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))};
|
static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))};
|
||||||
|
if (!imu) return o; // IMU disabled in config: encoder pitch, as before
|
||||||
|
if (auto s = imu->sample()) {
|
||||||
|
o.pitch_deg = s->pitch_deg;
|
||||||
|
imu_pitch_warned_ = false;
|
||||||
|
} else if (!imu_pitch_warned_) {
|
||||||
|
// Falling back silently would put two different measurements -
|
||||||
|
// true elevation and an axis angle - into one archive field
|
||||||
|
// with nothing recording which is which. Say it once per outage.
|
||||||
|
imu_pitch_warned_ = true;
|
||||||
|
LOG_WARN << "IMU has no fresh sample; publishing encoder pitch instead "
|
||||||
|
"(CamEvent 'pit' is the axis angle, not true elevation)";
|
||||||
|
}
|
||||||
|
return o;
|
||||||
},
|
},
|
||||||
pp);
|
pp);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue