Added expanded camera view with scan grid coordinates
This commit is contained in:
parent
e721ac74c2
commit
5643e909d4
|
|
@ -76,6 +76,7 @@ private:
|
||||||
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
|
||||||
|
bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig)
|
||||||
long yaw_target_ = 0; // current target, encoder counts
|
long yaw_target_ = 0; // current target, encoder counts
|
||||||
long pitch_target_ = 0;
|
long pitch_target_ = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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_; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue