Added expanded camera view with scan grid coordinates
This commit is contained in:
parent
e721ac74c2
commit
5643e909d4
|
|
@ -75,9 +75,10 @@ private:
|
|||
int control_code_ = 0;
|
||||
std::string target_heading_ = "0";
|
||||
|
||||
bool moving_ = false; // a MOVE has been issued, awaiting settle
|
||||
long yaw_target_ = 0; // current target, encoder counts
|
||||
long pitch_target_ = 0;
|
||||
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 pitch_target_ = 0;
|
||||
};
|
||||
|
||||
} // namespace fgc
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public:
|
|||
|
||||
bool empty() const { return points_.empty(); }
|
||||
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 std::vector<ScanPoint>& points() const { return points_; }
|
||||
|
||||
|
|
|
|||
|
|
@ -76,12 +76,24 @@ struct SensorsView {
|
|||
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 {
|
||||
bool present = false;
|
||||
bool active = false;
|
||||
double image_rate = 0.0; // img/s
|
||||
int camera_count = 0;
|
||||
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()).
|
||||
bool has_last = false;
|
||||
std::string last_label;
|
||||
|
|
|
|||
|
|
@ -351,6 +351,15 @@ 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;
|
||||
// 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 (auto ev = pipeline->lastEvent()) {
|
||||
s.capture.has_last = true;
|
||||
|
|
@ -485,6 +494,16 @@ struct Application::Impl {
|
|||
}
|
||||
|
||||
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();
|
||||
scheduler->setCaptureActive(true);
|
||||
LOG_INFO << "Capture started";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
#include "fgc/Logger.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
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::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 {
|
||||
return t.yaw.standstill && t.pitch.standstill &&
|
||||
std::labs(t.yaw.xenc - yaw_target_) <= kSettleTolCounts &&
|
||||
bool yaw_ok = t.yaw.standstill && 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;
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +78,7 @@ void CaptureScheduler::tick() {
|
|||
|
||||
// 2. Telemetry.
|
||||
MotorTelemetry t = motor_.telemetry();
|
||||
pitch_present_ = t.pitch_present;
|
||||
|
||||
// 3. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act.
|
||||
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)));
|
||||
}
|
||||
|
||||
// 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 mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim)
|
||||
: v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold)
|
||||
|
|
@ -528,7 +580,7 @@ void TuiUi::refreshLoop() {
|
|||
void TuiUi::uiLoop() {
|
||||
std::string cmd_buffer;
|
||||
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
|
||||
int help_sel = 0;
|
||||
bool gimbal_dump_requested = false; // auto-pull a dump the first time
|
||||
|
|
@ -563,7 +615,8 @@ void TuiUi::uiLoop() {
|
|||
} else {
|
||||
bottom = hbox({
|
||||
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(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"),
|
||||
});
|
||||
|
|
@ -580,6 +633,8 @@ void TuiUi::uiLoop() {
|
|||
gimbalDetailPanel(s.gimbal, s.dump, s.calib) | flex, bottom});
|
||||
case Overlay::Sensors:
|
||||
return vbox({header, separator(), imuDetailPanel(s.imu) | flex, bottom});
|
||||
case Overlay::Cameras:
|
||||
return vbox({header, separator(), cameraDetailPanel(s.capture) | flex, bottom});
|
||||
default: {
|
||||
// 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.
|
||||
|
|
@ -653,6 +708,10 @@ void TuiUi::uiLoop() {
|
|||
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
|
||||
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 (c == "j") { help_sel = (help_sel + 1) % n; return true; }
|
||||
if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ namespace {
|
|||
struct FakeMotor : IMotorController {
|
||||
MotorTelemetry tel;
|
||||
std::vector<std::string> cmds;
|
||||
FakeMotor() { tel.pitch_present = true; } // 2-axis rig by default; 1-axis tests clear this
|
||||
|
||||
void start() override {}
|
||||
void stop() override {}
|
||||
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");
|
||||
}
|
||||
|
||||
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") {
|
||||
long long clock = 0;
|
||||
FakeMotor motor;
|
||||
|
|
|
|||
Loading…
Reference in New Issue