Wait for a slow camera's next frame instead of restarting the stream, and read integer camera features for the exposure check

This commit is contained in:
pgdalmeida 2026-10-04 16:12:44 +02:00
parent fb0f5117df
commit 62131d8083
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
4 changed files with 88 additions and 11 deletions

View File

@ -28,6 +28,18 @@ long long firstFrameLimitMs(double exposure_us);
// `failed_restarts` counts restarts since the last frame that arrived. // `failed_restarts` counts restarts since the last frame that arrived.
StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_restarts); 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 // 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 // failed to bring a fresh frame. Restart again, or reset once restarts have
// stopped working. // stopped working.

View File

@ -164,12 +164,16 @@ private:
bool has_ = false; 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) { double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) {
FeaturePtr f; FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) != VmbErrorSuccess) return fallback;
double v = fallback; double v = fallback;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess) if (f->GetValue(v) == VmbErrorSuccess) return v;
return v; VmbInt64_t i = 0;
if (f->GetValue(i) == VmbErrorSuccess) return static_cast<double>(i);
return fallback; return fallback;
} }
std::string getString(const CameraPtr& cam, const char* name) { std::string getString(const CameraPtr& cam, const char* name) {
@ -278,6 +282,7 @@ struct VimbaCameraSource::Impl {
// when the current watch began. // when the current watch began.
std::vector<AutoExposure> watch_ae; std::vector<AutoExposure> watch_ae;
std::vector<long long> watch_from; std::vector<long long> watch_from;
bool watch_unknown_logged = false; // once per watch
// Begin/end a free-run acquisition session (fresh observers each time). Split out so // 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. // 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")); for (auto& cam : cameras) e = std::max(e, getFloat(cam, "ExposureTime"));
return e; 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 // Reboot one camera, reopen it by id and re-apply its profile. See
// CameraStall.h for why this exists. // CameraStall.h for why this exists.
@ -528,7 +542,15 @@ bool VimbaCameraSource::trigger() {
} }
if (newest <= impl_->last_captured_ts) { 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); for (auto& s : impl_->stats) s->restarts.fetch_add(1);
try { try {
impl_->stopAcquisition(); impl_->stopAcquisition();
@ -578,6 +600,7 @@ void VimbaCameraSource::beginExposureWatch() {
const size_t n = impl_->cameras.size(); const size_t n = impl_->cameras.size();
impl_->watch_ae.assign(n, {}); impl_->watch_ae.assign(n, {});
impl_->watch_from.assign(n, 0); impl_->watch_from.assign(n, 0);
impl_->watch_unknown_logged = false;
for (size_t i = 0; i < n; ++i) { for (size_t i = 0; i < n; ++i) {
impl_->watch_ae[i] = readAutoExposure(impl_->cameras[i]); impl_->watch_ae[i] = readAutoExposure(impl_->cameras[i]);
std::lock_guard<std::mutex> lock(impl_->stats[i]->bright_mutex); std::lock_guard<std::mutex> lock(impl_->stats[i]->bright_mutex);
@ -597,7 +620,15 @@ ExposureState VimbaCameraSource::exposureState() {
for (size_t i = 0; i < impl_->cameras.size(); ++i) { for (size_t i = 0; i < impl_->cameras.size(); ++i) {
const AutoExposure& ae = impl_->watch_ae[i]; const AutoExposure& ae = impl_->watch_ae[i];
CamStats& st = *impl_->stats[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; long long since;
double last, prev; double last, prev;
@ -610,6 +641,7 @@ ExposureState VimbaCameraSource::exposureState() {
// Two fresh frames: the first after standstill may have been exposed partly // Two fresh frames: the first after standstill may have been exposed partly
// during the slew. // during the slew.
ExposureState s = ExposureState::Converging; ExposureState s = ExposureState::Converging;
double exp = -1, gn = -1; // read only when off target
if (since >= 2) { if (since >= 2) {
const double half = ae.tolerance + kSettleMarginPct; const double half = ae.tolerance + kSettleMarginPct;
const bool dark = last < ae.target - half && prev < ae.target - half; const bool dark = last < ae.target - half && prev < ae.target - half;
@ -620,8 +652,8 @@ ExposureState VimbaCameraSource::exposureState() {
s = ExposureState::Settled; s = ExposureState::Settled;
} else if (dark || bright) { } else if (dark || bright) {
const auto& cam = impl_->cameras[i]; const auto& cam = impl_->cameras[i];
const double exp = getFloat(cam, "ExposureTime"); exp = getFloat(cam, "ExposureTime");
const double gn = getFloat(cam, "Gain"); gn = getFloat(cam, "Gain");
const bool exp_pinned = !ae.exposure_auto || const bool exp_pinned = !ae.exposure_auto ||
(dark ? exp >= ae.exposure_max * 0.98 : exp <= ae.exposure_min * 1.02); (dark ? exp >= ae.exposure_max * 0.98 : exp <= ae.exposure_min * 1.02);
const bool gain_pinned = !ae.gain_auto || const bool gain_pinned = !ae.gain_auto ||
@ -632,7 +664,13 @@ ExposureState VimbaCameraSource::exposureState() {
LOG_TRACE_CAT(LogCat::Camera) LOG_TRACE_CAT(LogCat::Camera)
<< "exposure cam" << i << ": brightness " << last << " % (previous " << prev << "exposure cam" << i << ": brightness " << last << " % (previous " << prev
<< ") vs target " << ae.target << " +- " << ae.tolerance << ", " << since << ") 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); worst = std::min(worst, s);
} }
return worst; return worst;

View File

@ -14,6 +14,17 @@ StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_r
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart; 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<long long>(2000, static_cast<long long>(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) { StallAction onRestartFailed(int failed_restarts) {
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart; return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
} }

View File

@ -25,3 +25,19 @@ TEST_CASE("a stale stream escalates to a reset once restarts stop working") {
CHECK(onRestartFailed(1) == StallAction::Restart); CHECK(onRestartFailed(1) == StallAction::Restart);
CHECK(onRestartFailed(kRestartsBeforeReset) == StallAction::Reset); 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);
}