#include "fgc/VimbaCameraSource.h" // The SDK comes first on purpose. pulls in // VmbCPP/LoggerDefines.h, which on some SDK versions (the 2024-era /opt/VimbaX on // the LattePanda, though not 2025-3) defines a FUNCTION-like LOG_ERROR(txt, // errCode). Ours is object-like - `LOG_ERROR << "..."` - so whichever header is // included last wins the redefinition, and when theirs wins the stream form never // expands and fails to compile. Take the SDK's macros, drop them, then define // ours over the top. The #undefs are harmless no-ops on SDKs that lack them. #include #undef LOG_ERROR #undef LOG_FREE_TEXT #include "fgc/Logger.h" #include #include #include #include #include #include #include #include using namespace VmbCPP; namespace fgc { namespace { long long nowMs() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) .count(); } // 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 delivered{0}; std::atomic dropped{0}; std::atomic restarts{0}; std::atomic last_w{0}; std::atomic last_h{0}; std::atomic last_bytes{0}; }; // Frame observer for the 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 time to converge to the current // light. class FrameObserver : public IFrameObserver { public: FrameObserver(CameraPtr camera, int cam_id, CamStats* stats) : IFrameObserver(camera), cam_id_(cam_id), stats_(stats) {} // Copy out the latest complete frame; false if none has arrived yet. bool takeLatest(Frame& out) { std::lock_guard 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 lock(mutex_); return has_ ? latest_.timestamp_ms : 0; } void FrameReceived(const FramePtr pFrame) override { VmbFrameStatusType status; 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 && pFrame->GetBufferSize(sz) == VmbErrorSuccess && pFrame->GetBuffer(buf) == VmbErrorSuccess && buf && w && h) { Frame f; f.width = w; f.height = h; f.channels = static_cast(sz / (static_cast(w) * h)); f.cam_id = cam_id_; f.timestamp_ms = nowMs(); f.data.assign(buf, buf + sz); { std::lock_guard 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'; } } 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(status) : -1) << ')'; } m_pCamera->QueueFrame(pFrame); } private: int cam_id_; CamStats* stats_; std::mutex mutex_; Frame latest_; bool has_ = false; }; // 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 {}; } // Apply a profile by handing its settings XML to the SDK. // // This replaces a hand-rolled feature applier, and the reason is validity rather // than brevity: GenICam writability is stateful (AcquisitionFrameRate is only // writable once AcquisitionFrameRateEnable is set, Width's maximum depends on // binning, and so on), so a text list applied line by line can be individually // plausible and collectively wrong. The XML comes out of the VimbaX Viewer, // which greys out invalid combinations as the operator configures, and // LoadSettings replays exactly that. Nothing else may write settings. bool applyProfile(const CameraPtr& cam, const CameraProfile& profile) { VmbFeaturePersistSettings settings{}; settings.persistType = VmbFeaturePersistNoLUT; // RemoteDevice only: the transport-layer and stream sections of the export // describe the host that produced it, not the sensor. settings.modulePersistFlags = VmbModulePersistFlagsRemoteDevice; settings.maxIterations = 10; // features can need several passes to settle settings.loggingLevel = VmbLogLevelWarn; const VmbErrorType rc = SP_ACCESS(cam)->LoadSettings(profile.xml_path.c_str(), &settings); if (rc != VmbErrorSuccess) { LOG_ERROR << "camera: applying profile '" << profile.name << "' from " << profile.xml_path << " failed (error " << rc << ')'; return false; } LOG_INFO << "camera profile '" << profile.name << "' applied from " << profile.xml_path << " (" << profile.settings.entries.size() << " settings)"; return true; } } // namespace struct VimbaCameraSource::Impl { Impl(std::vector s, std::vector p) : specs(std::move(s)), profiles(std::move(p)), sys(VmbSystem::GetInstance()) {} struct Identity { std::string id, model, serial; }; struct Telemetry { double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0; }; // One spec and one profile per camera, index-aligned and in open order, so a // camera's settings are never those of the camera next to it. std::vector specs; std::vector profiles; VmbSystem& sys; std::vector cameras; std::vector observers; std::vector> stats; // one per camera; survives restart std::vector identity; // read once at open() std::vector 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() { // Stop first, unconditionally. Clearing `observers` drops the last // references to the FrameObserver objects, and until // StopContinuousImageAcquisition() has returned the SDK's acquisition // thread may still be calling FrameReceived on them - destroying them // under it is a use-after-free, not a leak. stopAcquisition(); // A fresh session has delivered nothing, so a watermark from the previous // one would make every genuinely-new frame look stale to trigger(). last_captured_ts = 0; 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(obs)->latestTimestamp()); return t; } }; VimbaCameraSource::VimbaCameraSource(std::vector specs, std::vector profiles) : impl_(std::make_unique(std::move(specs), std::move(profiles))) {} VimbaCameraSource::~VimbaCameraSource() { try { stop(); close(); } catch (const std::exception& e) { LOG_WARN << "VimbaCameraSource shutdown: " << e.what(); } } void VimbaCameraSource::open() { if (impl_->sys.Startup() != VmbErrorSuccess) throw std::runtime_error("Could not start Vimba X API"); for (const auto& spec : impl_->specs) { const std::string& id = spec.id; CameraPtr cam; if (impl_->sys.GetCameraByID(id.c_str(), cam) != VmbErrorSuccess) throw std::runtime_error("Camera '" + spec.name + "' not found: " + id); if (const VmbErrorType rc = cam->Open(VmbAccessModeFull); rc != VmbErrorSuccess) { std::string why; if (rc == VmbErrorInvalidAccess || rc == VmbErrorBusy) why = " - another process has it open (is the VimbaX Viewer still running?)"; else if (rc == VmbErrorNotFound) why = " - it disappeared between discovery and open (USB reset?)"; throw std::runtime_error("Could not open camera '" + spec.name + "': " + id + " (Vimba error " + std::to_string(rc) + ")" + why); } impl_->cameras.push_back(cam); // Per-camera stats (persist across restart) + identity (read once here). impl_->stats.push_back(std::make_unique()); impl_->telemetry.emplace_back(); impl_->identity.push_back({id, getString(cam, "DeviceModelName"), getString(cam, "DeviceSerialNumber")}); LOG_INFO << "Opened camera '" << spec.name << "' " << id << " (" << impl_->identity.back().model << ')'; } // Configure each camera ONCE, here. Settings are GenICam features that live on // the device and survive a stop/start of acquisition, so re-applying them // whenever the stream starts is both wasted work (thousands of features) and // wrong in principle: it lets a request to *stream* alter the *configuration*. // A scan frame and a manual snapshot must come off an identically configured // camera, and the only way to guarantee that is to never touch the // configuration in between. for (size_t i = 0; i < impl_->cameras.size() && i < impl_->profiles.size(); ++i) applyProfile(impl_->cameras[i], impl_->profiles[i]); } void VimbaCameraSource::close() { for (auto& cam : impl_->cameras) cam->Close(); impl_->cameras.clear(); impl_->sys.Shutdown(); } // Start/stop the free-running stream. Configuration is NOT touched here - that // happened at open(), and changes only through setProfile(). void VimbaCameraSource::start() { // Idempotent. Several callers reach here without knowing whether another // already has - `start` (scan), `snapshot`, and --start on the command line - // and re-entering would restart acquisition under the SDK's feet. if (impl_->started) return; impl_->startAcquisition(); impl_->started = true; } bool VimbaCameraSource::setProfile(int camera_index, const CameraProfile& profile) { const size_t idx = static_cast(camera_index); if (camera_index < 0 || idx >= impl_->cameras.size() || idx >= impl_->profiles.size()) { LOG_WARN << "camera: setProfile for unknown camera index " << camera_index; return false; } impl_->profiles[idx] = profile; if (!impl_->started) { // Idle: every feature is writable, so apply directly. start() no longer // applies profiles, so skipping this would leave the camera on the old one. return applyProfile(impl_->cameras[idx], profile); } // Binning, ROI and pixel format cannot be written while the stream runs, and a // profile may change any of them, so always cycle the stream. Profile switches // are rare; a per-feature "is this writable live" allow-list would be fragile. try { impl_->stopAcquisition(); const bool ok = applyProfile(impl_->cameras[idx], profile); impl_->startAcquisition(); if (!ok) return false; } catch (const std::exception& e) { LOG_ERROR << "camera: applying profile '" << profile.name << "' failed: " << e.what(); return false; } // The next trigger() must not hand back a frame exposed under the old profile. impl_->last_captured_ts = nowMs(); return true; } void VimbaCameraSource::stop() { if (!impl_->started) return; impl_->stopAcquisition(); impl_->started = false; } bool VimbaCameraSource::trigger() { // 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). const long long newest = impl_->newestTs(); // A session that has delivered NOTHING is warming up, not stalled. These are // different states that both fail the "no newer frame" test, and conflating // them is expensive: restarting throws away the warm-up and blocks the control // thread for 3 s, so a cold stream would be repeatedly reset just as it was // about to produce its first frame. Return empty-handed instead; the caller // retries every tick. if (newest == 0) { long long now = nowMs(); if (now - impl_->last_no_frame_warn_ms > 5000) { LOG_WARN << "camera: no frame yet (stream warming up)"; impl_->last_no_frame_warn_ms = now; } return false; } if (newest <= 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)); } bool any = false; for (auto& obs : impl_->observers) { Frame f; if (SP_DYN_CAST(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) { // Frames HAVE arrived this session (the newest == 0 case returned above), so // this is the restart-did-not-help path. The scheduler retries every tick, // so rate-limit. long long now = nowMs(); if (now - impl_->last_no_frame_warn_ms > 5000) { LOG_WARN << "camera: stream stalled and did not recover after a restart"; impl_->last_no_frame_warn_ms = now; } } return any; } void VimbaCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = std::move(cb); } int VimbaCameraSource::cameraCount() const { return static_cast(impl_->cameras.size()); } std::vector 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"); } } std::vector 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.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