Added expanded camera view with scan grid coordinates

This commit is contained in:
pgdalmeida 2026-06-29 17:28:56 +02:00
parent e721ac74c2
commit 5643e909d4
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
7 changed files with 144 additions and 9 deletions

View File

@ -75,9 +75,10 @@ private:
int control_code_ = 0; int control_code_ = 0;
std::string target_heading_ = "0"; std::string target_heading_ = "0";
bool moving_ = false; // a MOVE has been issued, awaiting settle bool moving_ = false; // a MOVE has been issued, awaiting settle
long yaw_target_ = 0; // current target, encoder counts bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig)
long pitch_target_ = 0; long yaw_target_ = 0; // current target, encoder counts
long pitch_target_ = 0;
}; };
} // namespace fgc } // namespace fgc

View File

@ -31,6 +31,7 @@ public:
bool empty() const { return points_.empty(); } bool empty() const { return points_.empty(); }
std::size_t size() const { return points_.size(); } std::size_t size() const { return points_.size(); }
std::size_t index() const { return index_; } // live cursor position
const ScanPoint& current() const { return points_[index_]; } const ScanPoint& current() const { return points_[index_]; }
const std::vector<ScanPoint>& points() const { return points_; } const std::vector<ScanPoint>& points() const { return points_; }

View File

@ -76,12 +76,24 @@ struct SensorsView {
SensorGroup dht; // DHT11: ambient temperature + humidity SensorGroup dht; // DHT11: ambient temperature + humidity
}; };
// One auto-sweep scan-grid waypoint, for the expanded camera view.
struct ScanWaypointView {
double yaw_deg = 0.0;
double pitch_deg = 0.0;
bool current = false; // the scheduler's live cursor position
};
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;
// Defined scan grid (ControlCode 0 auto-sweep waypoints) with the live cursor
// flagged, surfaced in the expanded camera view.
std::vector<ScanWaypointView> scan_grid;
bool scan_from_file = false; // grid_file CSV vs generated
bool scan_pitch = false; // 2-axis rig (show pitch column)
// Last published capture (from ImagePipeline::lastEvent()). // Last published capture (from ImagePipeline::lastEvent()).
bool has_last = false; bool has_last = false;
std::string last_label; std::string last_label;

View File

@ -351,6 +351,15 @@ 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;
// 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();
s.capture.scan_pitch = t.pitch_present;
s.capture.scan_grid.clear();
const auto& pts = grid.points();
s.capture.scan_grid.reserve(pts.size());
for (std::size_t i = 0; i < pts.size(); ++i)
s.capture.scan_grid.push_back({pts[i].yaw_deg, pts[i].pitch_deg, i == grid.index()});
if (pipeline) { if (pipeline) {
if (auto ev = pipeline->lastEvent()) { if (auto ev = pipeline->lastEvent()) {
s.capture.has_last = true; s.capture.has_last = true;
@ -485,6 +494,16 @@ struct Application::Impl {
} }
void startCapture() { void startCapture() {
// The scheduler arms regardless, but a scan can't progress until the axes
// are homed: before READY the firmware leaves coils disabled and clamps
// MOVE targets to a coarse bench guard, so no waypoint is ever reached and
// the move/settle cycle stalls silently. Refuse with a clear message
// instead. (Single-axis builds report no P: segment, so skip pitch.)
MotorTelemetry t = motor->telemetry();
if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) {
LOG_WARN << "Capture not started: gimbal not homed (run init/home first)";
return;
}
camera->start(); camera->start();
scheduler->setCaptureActive(true); scheduler->setCaptureActive(true);
LOG_INFO << "Capture started"; LOG_INFO << "Capture started";

View File

@ -3,7 +3,6 @@
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <chrono> #include <chrono>
#include <cmath>
#include <cstdlib> #include <cstdlib>
namespace fgc { namespace fgc {
@ -46,12 +45,21 @@ long long CaptureScheduler::elapsedMs() const { return now_ms_() - timer_start_;
void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); } void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); }
void CaptureScheduler::sendMove() { void CaptureScheduler::sendMove() {
motor_.sendCommand("MOVE " + std::to_string(yaw_target_) + "," + std::to_string(pitch_target_)); // On a 1-axis (yaw-only) rig the firmware rejects the two-axis `MOVE y,p`
// form ("ERR axis not present") and moves nothing, so address yaw alone.
if (pitch_present_) {
motor_.sendCommand("MOVE " + std::to_string(yaw_target_) + "," +
std::to_string(pitch_target_));
} else {
motor_.sendCommand("MOVE Y " + std::to_string(yaw_target_));
}
} }
bool CaptureScheduler::settledAt(const MotorTelemetry& t) const { bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {
return t.yaw.standstill && t.pitch.standstill && bool yaw_ok = t.yaw.standstill && std::labs(t.yaw.xenc - yaw_target_) <= kSettleTolCounts;
std::labs(t.yaw.xenc - yaw_target_) <= kSettleTolCounts && // A 1-axis rig never reports pitch standstill, so don't gate the capture on it.
if (!t.pitch_present) return yaw_ok;
return yaw_ok && t.pitch.standstill &&
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts; std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
} }
@ -70,6 +78,7 @@ void CaptureScheduler::tick() {
// 2. Telemetry. // 2. Telemetry.
MotorTelemetry t = motor_.telemetry(); MotorTelemetry t = motor_.telemetry();
pitch_present_ = t.pitch_present;
// 3. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act. // 3. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act.
if (control_code_ != 0 && control_code_ != 1) return; if (control_code_ != 0 && control_code_ != 1) return;

View File

@ -130,6 +130,58 @@ 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
// cursor highlighted. Long grids wrap into side-by-side columns so the whole
// list stays visible.
Element cameraDetailPanel(const CaptureView& c) {
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(),
};
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)));
}
// " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis.
constexpr size_t kRows = 18; // rows per column before wrapping
std::vector<Element> cols;
std::vector<Element> col;
auto flushColumn = [&] {
if (col.empty()) return;
if (!cols.empty()) cols.push_back(text(" "));
cols.push_back(vbox(std::move(col)));
col.clear();
};
for (size_t i = 0; i < c.scan_grid.size(); ++i) {
const ScanWaypointView& w = c.scan_grid[i];
std::string idx = std::to_string(i + 1);
if (idx.size() < 3) idx = std::string(3 - idx.size(), ' ') + idx;
std::string coords = c.scan_pitch
? formatDegrees(w.yaw_deg) + " / " + formatDegrees(w.pitch_deg)
: formatDegrees(w.yaw_deg);
Element line = hbox({text(idx + " ") | dim, text(coords)});
if (w.current) line = line | inverted;
col.push_back(line);
if (col.size() == kRows) flushColumn();
}
flushColumn();
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)));
}
Element connPanel(const ConnView& v) { Element connPanel(const ConnView& v) {
Element mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim) Element mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim)
: v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold) : v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold)
@ -528,7 +580,7 @@ void TuiUi::refreshLoop() {
void TuiUi::uiLoop() { void TuiUi::uiLoop() {
std::string cmd_buffer; std::string cmd_buffer;
bool command_mode = false; bool command_mode = false;
enum class Overlay { None, Help, Gimbal, Sensors }; enum class Overlay { None, Help, Gimbal, Sensors, Cameras };
Overlay overlay = Overlay::None; // which takeover panel owns the main area Overlay overlay = Overlay::None; // which takeover panel owns the main area
int help_sel = 0; int help_sel = 0;
bool gimbal_dump_requested = false; // auto-pull a dump the first time bool gimbal_dump_requested = false; // auto-pull a dump the first time
@ -563,7 +615,8 @@ void TuiUi::uiLoop() {
} else { } else {
bottom = hbox({ bottom = hbox({
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"), keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
keyHint("g", "Gimbal"), keyHint("i", "IMU"), keyHint("r", "Refresh"), keyHint("g", "Gimbal"), keyHint("i", "IMU"), keyHint("c", "Scan"),
keyHint("r", "Refresh"),
keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"), keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"),
keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"), keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"),
}); });
@ -580,6 +633,8 @@ void TuiUi::uiLoop() {
gimbalDetailPanel(s.gimbal, s.dump, s.calib) | flex, bottom}); gimbalDetailPanel(s.gimbal, s.dump, s.calib) | flex, bottom});
case Overlay::Sensors: case Overlay::Sensors:
return vbox({header, separator(), imuDetailPanel(s.imu) | flex, bottom}); return vbox({header, separator(), imuDetailPanel(s.imu) | flex, bottom});
case Overlay::Cameras:
return vbox({header, separator(), cameraDetailPanel(s.capture) | flex, bottom});
default: { default: {
// Activity strip sits between the log and the key bar; shown only // Activity strip sits between the log and the key bar; shown only
// once a special op has run or is running, else it costs no space. // once a special op has run or is running, else it costs no space.
@ -653,6 +708,10 @@ void TuiUi::uiLoop() {
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors; overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
return true; return true;
} }
if (c == "c") {
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
return true;
}
if (overlay == Overlay::Help) { // vim-style section nav while help is open if (overlay == Overlay::Help) { // vim-style section nav while help is open
if (c == "j") { help_sel = (help_sel + 1) % n; return true; } if (c == "j") { help_sel = (help_sel + 1) % n; return true; }
if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; } if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; }

View File

@ -12,6 +12,8 @@ namespace {
struct FakeMotor : IMotorController { struct FakeMotor : IMotorController {
MotorTelemetry tel; MotorTelemetry tel;
std::vector<std::string> cmds; std::vector<std::string> cmds;
FakeMotor() { tel.pitch_present = true; } // 2-axis rig by default; 1-axis tests clear this
void start() override {} void start() override {}
void stop() override {} void stop() override {}
void sendCommand(const std::string& c) override { cmds.push_back(c); } void sendCommand(const std::string& c) override { cmds.push_back(c); }
@ -107,6 +109,38 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
CHECK(motor.cmds.back() == "MOVE 900,0"); CHECK(motor.cmds.back() == "MOVE 900,0");
} }
TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") {
long long clock = 0;
FakeMotor motor;
motor.tel.pitch_present = false; // yaw-only gimbal: no P: segment in telemetry
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 0;
// The MOVE must address yaw alone, not the two-axis form a 1-axis firmware rejects.
clock = 1600;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
CHECK(motor.cmds[0] == "MOVE Y 0");
// Settle on yaw only (pitch never reports standstill on this rig) -> capture fires.
motor.tel.yaw.xenc = 0;
motor.tel.yaw.standstill = true;
motor.tel.yaw.state = AxisState::Ready;
clock = 1700;
sch.tick();
CHECK(cam.triggers == 1);
clock = 2800;
sch.tick();
CHECK(motor.cmds.back() == "MOVE Y 900");
}
TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") { TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") {
long long clock = 0; long long clock = 0;
FakeMotor motor; FakeMotor motor;