From 62131d8083b51f9bcf0c58351d92523147815177 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Sun, 4 Oct 2026 16:12:44 +0200 Subject: [PATCH] Wait for a slow camera's next frame instead of restarting the stream, and read integer camera features for the exposure check --- include/fgc/CameraStall.h | 12 +++++++ src/camera/VimbaCameraSource.cpp | 60 ++++++++++++++++++++++++++------ src/core/CameraStall.cpp | 11 ++++++ tests/test_camerastall.cpp | 16 +++++++++ 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/include/fgc/CameraStall.h b/include/fgc/CameraStall.h index 0ca4e64..1baaab8 100644 --- a/include/fgc/CameraStall.h +++ b/include/fgc/CameraStall.h @@ -28,6 +28,18 @@ long long firstFrameLimitMs(double exposure_us); // `failed_restarts` counts restarts since the last frame that arrived. StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_restarts); +// How long a stream that has delivered may go without a newer frame before it +// counts as stalled: twice the longer of the exposure and the frame period, plus +// a second, and never under 2 s. A slow frame is not a stall - tower 111 runs +// multi-second exposures, one frame every 7-9 s, and restarting the stream the +// moment a capture fell due before the next frame threw that exposure away, so +// the next frame came later still (2026-10-04). +long long staleFrameLimitMs(double exposure_us, double frame_period_ms); + +// No frame newer than the last one kept, the newest having arrived +// `since_newest_ms` ago: wait for the next one, or restart the stream. +StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms); + // A stream that delivered before but has gone stale, and a restart has just // failed to bring a fresh frame. Restart again, or reset once restarts have // stopped working. diff --git a/src/camera/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index d4d566a..186c222 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -164,12 +164,16 @@ private: bool has_ = false; }; -// Read a float/string feature, returning `fallback`/"" if absent (tolerant, for telemetry). +// Read a numeric/string feature, returning `fallback`/"" if absent (tolerant, for +// telemetry). Numeric takes an integer feature too: some are Int on one model and +// Float on another (IntensityControllerTolerance is Int on the Alvium 1800 U-1240c). 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; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) != VmbErrorSuccess) return fallback; + double v = fallback; + if (f->GetValue(v) == VmbErrorSuccess) return v; + VmbInt64_t i = 0; + if (f->GetValue(i) == VmbErrorSuccess) return static_cast(i); return fallback; } std::string getString(const CameraPtr& cam, const char* name) { @@ -278,6 +282,7 @@ struct VimbaCameraSource::Impl { // when the current watch began. std::vector watch_ae; std::vector watch_from; + bool watch_unknown_logged = false; // once per watch // 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. @@ -311,6 +316,15 @@ struct VimbaCameraSource::Impl { for (auto& cam : cameras) e = std::max(e, getFloat(cam, "ExposureTime")); return e; } + // Longest frame period any camera runs at (the rate it reports), 0 if unknown. + double slowestFramePeriodMs() { + double p = 0; + for (auto& cam : cameras) { + const double fps = getFloat(cam, "AcquisitionFrameRate"); + if (fps > 0) p = std::max(p, 1000.0 / fps); + } + return p; + } // Reboot one camera, reopen it by id and re-apply its profile. See // CameraStall.h for why this exists. @@ -528,7 +542,15 @@ bool VimbaCameraSource::trigger() { } if (newest <= impl_->last_captured_ts) { - LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition"; + // Nothing newer than the last frame kept - but with long exposures the + // next one may simply not be due yet. Restarting now would throw away the + // exposure in progress, so wait while that is still plausible. + const long long since_newest = nowMs() - newest; + if (onStaleFrame(since_newest, impl_->longestExposureUs(), + impl_->slowestFramePeriodMs()) == StallAction::Wait) + return false; + LOG_TRACE_CAT(LogCat::Camera) << "stream stale (" << since_newest + << " ms since the newest frame); restarting acquisition"; for (auto& s : impl_->stats) s->restarts.fetch_add(1); try { impl_->stopAcquisition(); @@ -578,6 +600,7 @@ void VimbaCameraSource::beginExposureWatch() { const size_t n = impl_->cameras.size(); impl_->watch_ae.assign(n, {}); impl_->watch_from.assign(n, 0); + impl_->watch_unknown_logged = false; for (size_t i = 0; i < n; ++i) { impl_->watch_ae[i] = readAutoExposure(impl_->cameras[i]); std::lock_guard lock(impl_->stats[i]->bright_mutex); @@ -597,7 +620,15 @@ ExposureState VimbaCameraSource::exposureState() { for (size_t i = 0; i < impl_->cameras.size(); ++i) { const AutoExposure& ae = impl_->watch_ae[i]; CamStats& st = *impl_->stats[i]; - if (!ae.known || !st.measurable.load()) return ExposureState::Unknown; + if (!ae.known || !st.measurable.load()) { + if (!impl_->watch_unknown_logged) { + impl_->watch_unknown_logged = true; + LOG_DEBUG << "exposure cam" << i << ": cannot tell - " + << (!ae.known ? "no IntensityControllerTarget/Tolerance readout" + : "pixel format is not 8-bit"); + } + return ExposureState::Unknown; + } long long since; double last, prev; @@ -609,7 +640,8 @@ ExposureState VimbaCameraSource::exposureState() { } // Two fresh frames: the first after standstill may have been exposed partly // during the slew. - ExposureState s = ExposureState::Converging; + ExposureState s = ExposureState::Converging; + double exp = -1, gn = -1; // read only when off target if (since >= 2) { const double half = ae.tolerance + kSettleMarginPct; const bool dark = last < ae.target - half && prev < ae.target - half; @@ -619,9 +651,9 @@ ExposureState VimbaCameraSource::exposureState() { if (in_band || (!ae.exposure_auto && !ae.gain_auto)) { s = ExposureState::Settled; } else if (dark || bright) { - const auto& cam = impl_->cameras[i]; - const double exp = getFloat(cam, "ExposureTime"); - const double gn = getFloat(cam, "Gain"); + const auto& cam = impl_->cameras[i]; + exp = getFloat(cam, "ExposureTime"); + gn = getFloat(cam, "Gain"); const bool exp_pinned = !ae.exposure_auto || (dark ? exp >= ae.exposure_max * 0.98 : exp <= ae.exposure_min * 1.02); const bool gain_pinned = !ae.gain_auto || @@ -632,7 +664,13 @@ ExposureState VimbaCameraSource::exposureState() { LOG_TRACE_CAT(LogCat::Camera) << "exposure cam" << i << ": brightness " << last << " % (previous " << prev << ") vs target " << ae.target << " +- " << ae.tolerance << ", " << since - << " frames since rest"; + << " frames since rest" + << (exp >= 0 ? ", exposure " + std::to_string(exp) + " us (auto " + + std::to_string(ae.exposure_min) + ".." + + std::to_string(ae.exposure_max) + "), gain " + + std::to_string(gn) + " dB (auto max " + + std::to_string(ae.gain_max) + ")" + : std::string()); worst = std::min(worst, s); } return worst; diff --git a/src/core/CameraStall.cpp b/src/core/CameraStall.cpp index 275b349..6b952b3 100644 --- a/src/core/CameraStall.cpp +++ b/src/core/CameraStall.cpp @@ -14,6 +14,17 @@ StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_r return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart; } +long long staleFrameLimitMs(double exposure_us, double frame_period_ms) { + const double exposure_ms = exposure_us > 0 ? exposure_us / 1000 : 0; + const double slowest = std::max(exposure_ms, frame_period_ms > 0 ? frame_period_ms : 0); + return std::max(2000, static_cast(2 * slowest) + 1000); +} + +StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms) { + return since_newest_ms < staleFrameLimitMs(exposure_us, frame_period_ms) ? StallAction::Wait + : StallAction::Restart; +} + StallAction onRestartFailed(int failed_restarts) { return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart; } diff --git a/tests/test_camerastall.cpp b/tests/test_camerastall.cpp index 561f299..b427c6b 100644 --- a/tests/test_camerastall.cpp +++ b/tests/test_camerastall.cpp @@ -25,3 +25,19 @@ TEST_CASE("a stale stream escalates to a reset once restarts stop working") { CHECK(onRestartFailed(1) == StallAction::Restart); CHECK(onRestartFailed(kRestartsBeforeReset) == StallAction::Reset); } + +TEST_CASE("a slow next frame is not a stale stream") { + // Fast stream (5 ms exposure, 5 fps): the 2 s floor. + CHECK(staleFrameLimitMs(5000, 200) == 2000); + // Tower 111 at dusk: 9 s exposures, a frame every ~9 s. + CHECK(staleFrameLimitMs(9e6, 9000) == 2 * 9000 + 1000); + // A frame-rate cap slower than the exposure governs (night: 0.5 s at 0.215 fps). + CHECK(staleFrameLimitMs(5e5, 1000.0 / 0.215) == 2 * 4651 + 1000); + + // Asked 8 s after the last frame of a 9 s exposure: wait for it, do not + // restart the stream and throw that exposure away (tower 111, 2026-10-04). + CHECK(onStaleFrame(8000, 9e6, 9000) == StallAction::Wait); + CHECK(onStaleFrame(20000, 9e6, 9000) == StallAction::Restart); + // A fast stream that has delivered nothing new for seconds is stale. + CHECK(onStaleFrame(2500, 5000, 200) == StallAction::Restart); +}