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.
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.

View File

@ -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<double>(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<AutoExposure> watch_ae;
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
// 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<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) {
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;

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;
}
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) {
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(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);
}