chore: preserve existing library source changes for MatchVerify migration
Isolated source snapshot; original checkout and index unchanged. This migration does not claim new build or hardware validation.
This commit is contained in:
parent
51e364997a
commit
11236718ea
@ -26,7 +26,7 @@ target_link_libraries(your_app PRIVATE TargetExposureControl::Control)
|
||||
|
||||
## 连续在线反馈(1.2.0)
|
||||
|
||||
需要每一帧都依据成像亮度调整曝光时,使用独立的 `ContinuousExposureControl`。它复用原比例/PI、死区、迟滞、单步限幅和积分保护,但没有“目标确认 → 丢失冻结 → 单次恢复预算”,也不调用预测。原 `ExposureControl` 的状态和默认行为保持不变。
|
||||
需要每一帧都依据成像亮度调整曝光时,使用独立的 `ContinuousExposureControl`。它复用原比例/PI、死区、迟滞、单步限幅和积分保护,但没有“目标确认 → 丢失冻结 → 单次恢复预算”,不在库内拟合预测。调用方可提供已验证的下一帧灰度预测,进入同一个反馈控制器。原 `ExposureControl` 的状态和默认行为保持不变。
|
||||
|
||||
```cpp
|
||||
target_exposure::ContinuousExposureControl feedback; // 每路相机一个,跨帧保留
|
||||
@ -54,6 +54,10 @@ const auto result = feedback.update(input, config);
|
||||
|
||||
日志可直接记录返回的 `requested_source`、`effective_source`、`fallback_reason`、`reset_reason`、`state`、`reason`、实际/建议曝光、参考/反馈灰度、完整 `metering` 以及 `control`。`control` 包含当前/控制误差、积分、同向计数、原始/最终增量、步长限制、曝光限制和积分抗饱和标志。近黑率与饱和率作为测光事实保留,不额外引入新阈值控制策略。库不写日志文件,不接触相机,也不承诺现场识别率。
|
||||
|
||||
可选 `ContinuousFrameInput::predicted_gray` 表示下一帧在当前实际曝光下的灰度,由调用方保证相同参数、稳定区域和可信时序。仅在当前区域测光位于死区/迟滞保持区、没有重置或积分抑制时使用;全局回退和正在调节的实际反馈优先。预测与测量共用一套死区、迟滞、同向计数、时间确认、PI 和限幅,预测单步上限 `prediction_max_step_ratio` 默认 10%,还受原曝光/时间限幅约束。没有提供该字段的调用保持原行为。
|
||||
|
||||
`prediction_used` 只说明预测进入了本帧控制器,不能表示曝光已写入。`control_gray` 与控制误差反映控制器实际输入;`metering`、`raw_control_gray`、`control.filtered_gray` 始终保留本帧真实测量。`feedback_control_gray`、`feedback_reason`、`feedback_suggested_exposure_us` 保留实际反馈依据,供增益与保护策略使用;预测不进入测量滤波历史。
|
||||
|
||||
连续控制的 `state` 是 `Holding`、`Adjusting`、`Limited` 或拒绝输入时的 `Invalid`,不表示检测到/丢失工件。新增独立类型保持旧源码调用兼容,静态库与应用应一起重新编译。
|
||||
|
||||
## 独立离线测光(1.3.0,兼容原 1.1.0 调用)
|
||||
|
||||
@ -36,7 +36,7 @@ r = dG / G_target 无量纲,0.10 为 10%
|
||||
|
||||
大误差按附件第 7 章伪代码立即修正。大、小误差均经过单拍步长限制,再经过曝光上下限。当前真实曝光不在配置范围时拒绝该帧控制,避免在满足绝对范围时又无法同时满足相对步长的矛盾,不擅自把真实曝光裁剪成一个假输入。
|
||||
|
||||
I 是逐次有效 PI 调节累计的相对误差,不按秒积分。仅在新目标反馈有效且小误差连续确认满足时尝试累计;积分自身限幅。若候选累计导致步长或绝对曝光限幅阻挡同方向修正,则拒绝这次积分累计,使用此前积分计算建议。死区与等待确认不累计,无效目标测光保持积分但清除确认计数。目标丢失、进入大误差分支、复位和初始化清除积分。
|
||||
原接口及未启用时间模式时,I 是逐次有效 PI 调节累计的相对误差,不按秒积分。仅在新目标反馈有效且小误差连续确认满足时尝试累计;积分自身限幅。若候选累计导致步长或绝对曝光限幅阻挡同方向修正,则拒绝这次积分累计,使用此前积分计算建议。死区、迟滞保持和误差方向反转清零积分,防止先前暗场积分在当前过亮时继续增加曝光。等待确认不累计,无效目标测光保持积分但清除确认计数。目标丢失、进入大误差分支、复位和初始化也清除积分。
|
||||
|
||||
所有建议以输入的 `actual_exposure_us` 为基准。控制器没有“上次建议已经生效”的隐含状态,不会在重复帧上继续叠加修正。
|
||||
|
||||
@ -67,6 +67,25 @@ I 是逐次有效 PI 调节累计的相对误差,不按秒积分。仅在新
|
||||
|
||||
当前参考灰度由实际来源选择 `global_controller` 或 `region_controller`;配置、来源或闲置超时变化清反馈状态,拒绝帧不消费时间水位。曝光建议仍是库输出,调用方必须落实“成功写入 → 读回 → 下一帧用实际值反馈”,才能构成设备闭环;库内验证不代表相机已生效。
|
||||
|
||||
### 稀疏采集与外部执行器保护
|
||||
|
||||
`ContinuousConfig::preserve_confirmation_on_idle` 默认 `false`,保留上述历史行为。定时采集调用方可设为 `true`:自动检测到 `IdleTimeout` 时保留同向误差确认和迟滞状态,清零旧 PI 积分,并禁止该帧累积积分。这样即使每帧间隔超过 300 秒,中等偏差仍能在第二次同向有效反馈后调整,而不会永久等待第一帧确认。若下一帧间隔正常,允许重新积累积分。配置切换、来源切换和任何显式 `reset()` 仍完整清空反馈状态,包括显式 `reset(IdleTimeout)`。时间倒退仍拒绝;切换时钟必须显式 `reset(ClockChanged)`,不隐式接纳乱序帧。
|
||||
|
||||
外部饱和保护或执行器策略预先确定要覆盖 PI 输出时,调用方应在 **本帧 `update()` 前** 设置 `ContinuousFrameInput::suppress_integral=true`。通过校验后,该帧清零已有积分并禁止新增积分,比例项、同向确认、迟滞和时间水位继续正常推进。下一帧未设置此标志即可恢复正常积分;这不是完整状态重置。拒绝帧不会因该标志而改变任何控制状态。
|
||||
|
||||
这两种抑制都在 `ControlTrace::integral_suppressed` 中明确记录;自动闲置仍另外报告 `reset_reason=IdleTimeout`。标志只表示接受的反馈执行了积分抑制,不等于实际相机参数已写入。原 `ExposureControl::processFrame()` 未启用这些行为,默认源码调用保持兼容;公共结构增加成员后消费工程需要与静态库一起重新编译。
|
||||
|
||||
### 按实际经过时间的连续反馈
|
||||
|
||||
`ContinuousConfig::time_aware=true` 启用可选时序控制,默认关闭以保留旧接口节拍。时间取调用方提供的单调采集时间戳,拒绝帧不会更新时钟、滤波或确认历史。
|
||||
|
||||
- 默认 `nominal_interval_us=5000000`:积分乘 `min(Δt / 5秒, 1)`;间隔再长也不把空白期间当成持续误差累积。闲置抑制帧的积分系数为0。
|
||||
- 默认 `smoothing_tau_us=2000000`:对 `灰度/本帧实际曝光` 做 `α=1-exp(-Δt/τ)` 的指数平滑,再映射到本帧曝光。曝光本身改变不会被当成环境变化。该局部近似不假设增益单位或增益响应;**调用方检测实际增益变化后必须 reset**。大误差、外部积分抑制、闲置或其他重置使用当前原始灰度。滤波误差与原始误差反向时也改用当前原始值,原始值已经进入死区时立即保持。
|
||||
- 默认 `confirmation_us=1000000`:小误差同时满足至少2次同方向观测及持续1秒才允许调整。大误差跳过确认,仍遵守限速。
|
||||
- 默认 `max_step_ratio_per_second=0.05`:曝光单次变化上限是原每帧上限与 `每秒速率×Δt秒` 中较小者。首帧尚无时间差时使用5秒基准;长间隔不超过原每帧上限。该值限制库的普通曝光建议,不声称限速外部紧急保护或相机增益变化。
|
||||
|
||||
`raw_control_gray` 保留原始选中灰度,`control_gray` 是本次实际控制采用值;`ControlTrace` 记录 `elapsed_us`、`filtered_gray`、`filter_alpha`、`integral_time_scale`、`effective_max_step_ratio`、`same_direction_duration_us`、`confirmation_time_satisfied` 和 `time_rate_limited`。仅时间模式填入过滤灰度;该机制做平滑和节拍约束,不进行趋势外推,也不改变原 CAPTURE/TRACK/LOST 状态机。
|
||||
|
||||
## 单次无目标恢复
|
||||
|
||||
从首次未检出开始,到连续检出达到配置数量结束,视为同一丢失过程。恢复机会独立于三态及初始化记录。
|
||||
|
||||
@ -166,6 +166,17 @@ struct ControlTrace {
|
||||
bool integral_blocked_by_limit = false;
|
||||
bool step_limited = false;
|
||||
bool exposure_limited = false;
|
||||
// Accepted feedback deliberately clears old integral and skips accumulation.
|
||||
bool integral_suppressed = false;
|
||||
bool time_aware = false;
|
||||
TimestampUs elapsed_us = 0;
|
||||
std::optional<double> filtered_gray;
|
||||
double filter_alpha = 1.0;
|
||||
double integral_time_scale = 1.0;
|
||||
double effective_max_step_ratio = 0.0;
|
||||
TimestampUs same_direction_duration_us = 0;
|
||||
bool confirmation_time_satisfied = true;
|
||||
bool time_rate_limited = false;
|
||||
};
|
||||
struct FrameInput {
|
||||
cv::Mat image; // Gray8, BGR8 or Gray16, read synchronously; no image retained
|
||||
@ -239,6 +250,15 @@ struct ContinuousConfig {
|
||||
TimestampUs idle_timeout_us = 300'000'000;
|
||||
ControllerConfig region_controller;
|
||||
ControllerConfig global_controller;
|
||||
// For scheduled sparse captures: idle keeps direction/hysteresis, but clears
|
||||
// integral and disables accumulation on that frame. Other resets stay full.
|
||||
bool preserve_confirmation_on_idle = false;
|
||||
// Opt-in real elapsed-time feedback; no extrapolation or gain-response model.
|
||||
bool time_aware = false;
|
||||
TimestampUs nominal_interval_us = 5'000'000;
|
||||
TimestampUs smoothing_tau_us = 2'000'000;
|
||||
TimestampUs confirmation_us = 1'000'000;
|
||||
double max_step_ratio_per_second = 0.05;
|
||||
};
|
||||
std::vector<std::string> validate(const ContinuousConfig& config);
|
||||
bool operator==(const ContinuousConfig& a, const ContinuousConfig& b);
|
||||
@ -249,6 +269,14 @@ struct ContinuousFrameInput {
|
||||
FeedbackSource source = FeedbackSource::Global;
|
||||
TimestampUs timestamp_us = 0;
|
||||
double actual_exposure_us = 0.0; // Device readback for this frame, not a previous suggestion.
|
||||
// Set before update when an external protection overrides the PI actuator.
|
||||
// Clears previous integral and prevents this accepted frame adding integral.
|
||||
bool suppress_integral = false;
|
||||
// Optional externally qualified forecast at this frame's actual exposure.
|
||||
// Used only for a held target measurement, never for global fallback or an
|
||||
// external protection/reset. It passes through the SAME controller state.
|
||||
std::optional<double> predicted_gray;
|
||||
double prediction_max_step_ratio = 0.10;
|
||||
};
|
||||
struct ContinuousFrameResult {
|
||||
bool accepted = false;
|
||||
@ -266,10 +294,18 @@ struct ContinuousFrameResult {
|
||||
MeteringResult metering;
|
||||
ControlTrace control;
|
||||
std::vector<std::string> errors;
|
||||
// Raw selected gray before optional exposure-normalized temporal filtering.
|
||||
std::optional<double> raw_control_gray;
|
||||
// The measured-only decision remains explicit; prediction never alters
|
||||
// metering or the temporal filter's measured-brightness history.
|
||||
std::optional<double> feedback_control_gray;
|
||||
std::optional<double> feedback_suggested_exposure_us;
|
||||
Reason feedback_reason = Reason::InvalidMetering;
|
||||
bool prediction_used = false; // Input selection, not a device write.
|
||||
};
|
||||
|
||||
// Serial calls per image stream. No target-presence state machine, prediction or
|
||||
// preCapture step: each accepted measurement supplies feedback for the next frame.
|
||||
// Serial calls per image stream. No target-presence state machine, trend fitting
|
||||
// or preCapture step. An optional caller forecast shares the feedback controller.
|
||||
// The caller applies suggestions and reads the actual exposure of each new frame.
|
||||
class ContinuousExposureControl {
|
||||
public:
|
||||
|
||||
@ -106,6 +106,11 @@ std::vector<std::string> validate(const ContinuousConfig& c) {
|
||||
if (!std::isfinite(c.max_exposure_us) || c.max_exposure_us < c.min_exposure_us)
|
||||
errors.emplace_back("max_exposure_us must be finite and >= min_exposure_us");
|
||||
if (c.idle_timeout_us <= 0) errors.emplace_back("idle_timeout_us must be positive");
|
||||
if (c.nominal_interval_us <= 0) errors.emplace_back("nominal_interval_us must be positive");
|
||||
if (c.smoothing_tau_us < 0) errors.emplace_back("smoothing_tau_us must be nonnegative");
|
||||
if (c.confirmation_us < 0) errors.emplace_back("confirmation_us must be nonnegative");
|
||||
if (!std::isfinite(c.max_step_ratio_per_second) || c.max_step_ratio_per_second <= 0)
|
||||
errors.emplace_back("max_step_ratio_per_second must be positive and finite");
|
||||
for (const auto& error : detail::validateController(c.region_controller))
|
||||
errors.emplace_back("region_controller." + error);
|
||||
for (const auto& error : detail::validateController(c.global_controller))
|
||||
@ -115,6 +120,10 @@ std::vector<std::string> validate(const ContinuousConfig& c) {
|
||||
bool operator==(const ContinuousConfig& a, const ContinuousConfig& b) {
|
||||
return a.min_exposure_us == b.min_exposure_us && a.max_exposure_us == b.max_exposure_us &&
|
||||
a.idle_timeout_us == b.idle_timeout_us &&
|
||||
a.preserve_confirmation_on_idle == b.preserve_confirmation_on_idle &&
|
||||
a.time_aware == b.time_aware && a.nominal_interval_us == b.nominal_interval_us &&
|
||||
a.smoothing_tau_us == b.smoothing_tau_us && a.confirmation_us == b.confirmation_us &&
|
||||
a.max_step_ratio_per_second == b.max_step_ratio_per_second &&
|
||||
detail::sameController(a.region_controller, b.region_controller) &&
|
||||
detail::sameController(a.global_controller, b.global_controller);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
#include "internal.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace target_exposure {
|
||||
@ -24,6 +25,12 @@ struct ContinuousExposureControl::Impl {
|
||||
std::optional<FeedbackSource> source;
|
||||
std::optional<TimestampUs> watermark;
|
||||
std::optional<FeedbackResetReason> pending_reset = FeedbackResetReason::Startup;
|
||||
std::optional<double> normalized_gray;
|
||||
std::optional<TimestampUs> direction_since;
|
||||
int timed_direction = 0;
|
||||
void resetTemporal() {
|
||||
normalized_gray.reset(); direction_since.reset(); timed_direction = 0;
|
||||
}
|
||||
};
|
||||
|
||||
ContinuousExposureControl::ContinuousExposureControl() : impl_(std::make_unique<Impl>()) {}
|
||||
@ -31,6 +38,7 @@ ContinuousExposureControl::~ContinuousExposureControl() = default;
|
||||
|
||||
void ContinuousExposureControl::reset(FeedbackResetReason reason) {
|
||||
impl_->controller.reset();
|
||||
impl_->resetTemporal();
|
||||
impl_->source.reset();
|
||||
impl_->pending_reset = reason;
|
||||
if (reason == FeedbackResetReason::ClockChanged) impl_->watermark.reset();
|
||||
@ -47,6 +55,11 @@ ContinuousFrameResult ContinuousExposureControl::update(const ContinuousFrameInp
|
||||
out.control.same_direction_count = s.controller.count;
|
||||
out.errors = validate(config);
|
||||
if (!validSource(input.source)) out.errors.emplace_back("invalid measurement source");
|
||||
if (input.predicted_gray && (!std::isfinite(*input.predicted_gray) ||
|
||||
*input.predicted_gray < 1 || *input.predicted_gray > 254 ||
|
||||
!std::isfinite(input.prediction_max_step_ratio) ||
|
||||
input.prediction_max_step_ratio <= 0 || input.prediction_max_step_ratio >= 1))
|
||||
out.errors.emplace_back("invalid prediction gray or step limit");
|
||||
if (!out.errors.empty()) { out.reason = Reason::InvalidConfiguration; return out; }
|
||||
if (input.timestamp_us < 0) { out.reason = Reason::InvalidTimestamp; return out; }
|
||||
if (s.watermark && input.timestamp_us <= *s.watermark) {
|
||||
@ -79,12 +92,17 @@ ContinuousFrameResult ContinuousExposureControl::update(const ContinuousFrameInp
|
||||
const auto& controller = selected == FeedbackSource::Global ? config.global_controller : config.region_controller;
|
||||
out.target_gray = controller.target_gray;
|
||||
out.control_gray = measurement->gray;
|
||||
out.raw_control_gray = measurement->gray;
|
||||
if (s.pending_reset) out.reset_reason = s.pending_reset;
|
||||
else if (!s.config || config != *s.config) out.reset_reason = FeedbackResetReason::ConfigurationChanged;
|
||||
else if (!s.source || selected != *s.source) out.reset_reason = FeedbackResetReason::SourceChanged;
|
||||
else if (s.watermark && input.timestamp_us - *s.watermark > config.idle_timeout_us)
|
||||
out.reset_reason = FeedbackResetReason::IdleTimeout;
|
||||
if (out.reset_reason) s.controller.reset();
|
||||
const bool preserveIdleConfirmation = !s.pending_reset &&
|
||||
out.reset_reason == FeedbackResetReason::IdleTimeout && config.preserve_confirmation_on_idle;
|
||||
if (out.reset_reason && !preserveIdleConfirmation) {
|
||||
s.controller.reset(); s.resetTemporal();
|
||||
}
|
||||
|
||||
Config controlConfig;
|
||||
controlConfig.min_exposure_us = config.min_exposure_us;
|
||||
@ -92,8 +110,82 @@ ContinuousFrameResult ContinuousExposureControl::update(const ContinuousFrameInp
|
||||
controlConfig.controller = controller;
|
||||
FrameResult feedback;
|
||||
feedback.suggested_exposure_us = input.actual_exposure_us;
|
||||
feedback.control.current_error_ratio = (controller.target_gray - *measurement->gray) / controller.target_gray;
|
||||
s.controller.update(*measurement->gray, input.actual_exposure_us, controlConfig, feedback);
|
||||
const double rawGray = *measurement->gray;
|
||||
const double rawError = (controller.target_gray - rawGray) / controller.target_gray;
|
||||
feedback.control.current_error_ratio = rawError;
|
||||
const bool rawDeadband = std::abs(rawError) < controller.deadband_ratio || rawError == 0;
|
||||
const bool rawHolding = rawDeadband || (s.controller.deadband_latched &&
|
||||
std::abs(rawError) + 1e-12 < controller.deadband_ratio + controller.hysteresis_ratio);
|
||||
const bool suppressIntegral = input.suppress_integral || preserveIdleConfirmation;
|
||||
double integralScale = 1.0;
|
||||
bool confirmationReady = true;
|
||||
if (config.time_aware) {
|
||||
auto& trace = feedback.control;
|
||||
trace.time_aware = true;
|
||||
trace.elapsed_us = s.watermark ? input.timestamp_us - *s.watermark : config.nominal_interval_us;
|
||||
const double dt = static_cast<double>(trace.elapsed_us);
|
||||
integralScale = suppressIntegral ? 0.0 : std::min(1.0, dt / config.nominal_interval_us);
|
||||
trace.integral_time_scale = integralScale;
|
||||
trace.effective_max_step_ratio = std::min(controller.max_step_ratio,
|
||||
config.max_step_ratio_per_second * (dt / 1'000'000.0));
|
||||
controlConfig.controller.max_step_ratio = trace.effective_max_step_ratio;
|
||||
controlConfig.controller.same_direction_frames = std::max(2, controller.same_direction_frames);
|
||||
|
||||
// Filter scene response per exposure, then project to THIS frame's exposure.
|
||||
// Gain changes require caller reset: gain units and response are not assumed.
|
||||
const double normalized = rawGray / input.actual_exposure_us;
|
||||
const bool resetFilter = !s.normalized_gray || !std::isfinite(normalized) || suppressIntegral || out.reset_reason ||
|
||||
config.smoothing_tau_us == 0 || std::abs(rawError) >= controller.large_error_ratio || rawHolding;
|
||||
trace.filter_alpha = resetFilter ? 1.0 : -std::expm1(-dt / config.smoothing_tau_us);
|
||||
double filtered = resetFilter ? normalized :
|
||||
*s.normalized_gray + trace.filter_alpha * (normalized - *s.normalized_gray);
|
||||
double gray = resetFilter ? rawGray : std::clamp(filtered * input.actual_exposure_us, 0.0, 255.0);
|
||||
// Past dark frames must never command brightening an already-bright frame,
|
||||
// nor hide a return into the raw deadband behind a delayed filtered error.
|
||||
if (!std::isfinite(gray) || (controller.target_gray - gray) * rawError <= 0) {
|
||||
gray = rawGray; filtered = normalized; trace.filter_alpha = 1.0;
|
||||
}
|
||||
if (std::isfinite(filtered)) s.normalized_gray = filtered;
|
||||
else s.normalized_gray.reset();
|
||||
out.control_gray = gray;
|
||||
trace.filtered_gray = gray;
|
||||
}
|
||||
const double timeStepRatio = controlConfig.controller.max_step_ratio;
|
||||
out.feedback_control_gray = out.control_gray;
|
||||
if (input.predicted_gray && selected != FeedbackSource::Global && rawHolding &&
|
||||
!suppressIntegral && !out.reset_reason) {
|
||||
out.prediction_used = true;
|
||||
out.control_gray = input.predicted_gray;
|
||||
out.feedback_reason = rawDeadband ? Reason::Deadband : Reason::Hysteresis;
|
||||
out.feedback_suggested_exposure_us = input.actual_exposure_us;
|
||||
controlConfig.controller.max_step_ratio = std::min(timeStepRatio, input.prediction_max_step_ratio);
|
||||
feedback.control.effective_max_step_ratio = controlConfig.controller.max_step_ratio;
|
||||
}
|
||||
if (config.time_aware) {
|
||||
auto& trace = feedback.control;
|
||||
const double error = (controller.target_gray - *out.control_gray) / controller.target_gray;
|
||||
const bool holding = std::abs(error) < controller.deadband_ratio || error == 0 ||
|
||||
(s.controller.deadband_latched &&
|
||||
std::abs(error) + 1e-12 < controller.deadband_ratio + controller.hysteresis_ratio);
|
||||
const int sign = holding ? 0 : (error > 0 ? 1 : -1);
|
||||
if (sign == 0) { s.direction_since.reset(); s.timed_direction = 0; }
|
||||
else if (sign != s.timed_direction || !s.direction_since) {
|
||||
s.direction_since = input.timestamp_us; s.timed_direction = sign;
|
||||
}
|
||||
trace.same_direction_duration_us = s.direction_since ? input.timestamp_us - *s.direction_since : 0;
|
||||
confirmationReady = trace.same_direction_duration_us >= config.confirmation_us;
|
||||
trace.confirmation_time_satisfied = confirmationReady;
|
||||
}
|
||||
s.controller.update(*out.control_gray, input.actual_exposure_us, controlConfig, feedback,
|
||||
suppressIntegral, integralScale, confirmationReady);
|
||||
if (config.time_aware) feedback.control.time_rate_limited = feedback.control.step_limited &&
|
||||
timeStepRatio < controller.max_step_ratio &&
|
||||
feedback.control.effective_max_step_ratio == timeStepRatio;
|
||||
if (!out.prediction_used) {
|
||||
out.feedback_control_gray = out.control_gray;
|
||||
out.feedback_reason = feedback.reason;
|
||||
out.feedback_suggested_exposure_us = feedback.suggested_exposure_us;
|
||||
}
|
||||
out.accepted = true;
|
||||
out.reason = feedback.reason;
|
||||
out.suggested_exposure_us = feedback.suggested_exposure_us;
|
||||
|
||||
@ -7,26 +7,29 @@ namespace target_exposure::detail {
|
||||
void Controller::reset() { integral = 0; direction = 0; count = 0; deadband_latched = true; }
|
||||
void Controller::invalidFeedback() { direction = 0; count = 0; }
|
||||
|
||||
void Controller::update(double gray, double actual, const Config& c, FrameResult& out) {
|
||||
void Controller::update(double gray, double actual, const Config& c, FrameResult& out,
|
||||
bool suppress_integral, double integral_time_scale, bool confirmation_ready) {
|
||||
const auto& k = c.controller;
|
||||
const double error = (k.target_gray - gray) / k.target_gray;
|
||||
auto& trace = out.control;
|
||||
trace.integral_suppressed = suppress_integral;
|
||||
if (suppress_integral) integral = 0;
|
||||
trace.control_error_ratio = error;
|
||||
const auto finish = [&] { trace.integral = integral; trace.same_direction_count = count; };
|
||||
if (std::abs(error) < k.deadband_ratio || error == 0) {
|
||||
deadband_latched = true; count = 0; direction = 0;
|
||||
integral = 0; deadband_latched = true; count = 0; direction = 0;
|
||||
out.reason = Reason::Deadband; finish(); return;
|
||||
}
|
||||
if (deadband_latched && std::abs(error) + 1e-12 < k.deadband_ratio + k.hysteresis_ratio) {
|
||||
count = 0; direction = 0;
|
||||
integral = 0; count = 0; direction = 0;
|
||||
out.reason = Reason::Hysteresis; finish(); return;
|
||||
}
|
||||
deadband_latched = false;
|
||||
const int sign = error > 0 ? 1 : -1;
|
||||
if (direction != sign) { direction = sign; count = 0; }
|
||||
if (direction != sign) { integral = 0; direction = sign; count = 0; }
|
||||
if (count < k.same_direction_frames) ++count;
|
||||
const bool large = std::abs(error) >= k.large_error_ratio;
|
||||
if (!large && count < k.same_direction_frames) {
|
||||
if (!large && (count < k.same_direction_frames || !confirmation_ready)) {
|
||||
out.reason = Reason::ConfirmingError; finish(); return;
|
||||
}
|
||||
const auto finiteDelta = [](double value) {
|
||||
@ -41,7 +44,8 @@ void Controller::update(double gray, double actual, const Config& c, FrameResult
|
||||
integral = 0;
|
||||
raw = finiteDelta(k.proportional_gain * error * actual);
|
||||
} else {
|
||||
const double candidate_i = k.ki == 0 ? 0 : std::clamp(integral + error, -k.integral_abs_limit, k.integral_abs_limit);
|
||||
const double candidate_i = suppress_integral || k.ki == 0 ? 0 :
|
||||
std::clamp(integral + error * integral_time_scale, -k.integral_abs_limit, k.integral_abs_limit);
|
||||
const double candidate_raw = finiteDelta((k.kp * error + k.ki * candidate_i) * actual);
|
||||
// Conditional anti-windup also considers the per-frame step limit.
|
||||
if (error * (candidate_raw - (limit(candidate_raw) - actual)) > 1e-9) {
|
||||
|
||||
@ -24,6 +24,7 @@ struct Controller {
|
||||
void reset();
|
||||
void invalidFeedback();
|
||||
void update(double gray, double actual_exposure_us, const Config& config,
|
||||
FrameResult& output);
|
||||
FrameResult& output, bool suppress_integral = false,
|
||||
double integral_time_scale = 1.0, bool confirmation_ready = true);
|
||||
};
|
||||
} // namespace target_exposure::detail
|
||||
|
||||
@ -47,5 +47,51 @@ int main() {
|
||||
decision.fallback_reason != FeedbackFallbackReason::RegionUnavailable ||
|
||||
!decision.adjustment_suggested || *decision.suggested_exposure_us != 1250) return 6;
|
||||
if (std::string(toString(decision.state)) != "Adjusting") return 7;
|
||||
std::cout << "Installed package consumer passed; stateless Gray8/Gray16 with valid mask, original and continuous control; exposure unit us.\n";
|
||||
// Exercise the installed additive API, including sparse captures and override.
|
||||
continuousConfig.preserve_confirmation_on_idle = true;
|
||||
continuousConfig.global_controller.target_gray = 100;
|
||||
input.metering = measureImage(cv::Mat(8, 8, CV_8UC1, cv::Scalar(80)), {}, false);
|
||||
input.timestamp_us = 1;
|
||||
if (continuous.update(input, continuousConfig).reason != Reason::ConfirmingError) return 10;
|
||||
input.timestamp_us += continuousConfig.idle_timeout_us + 1;
|
||||
const auto sparse = continuous.update(input, continuousConfig);
|
||||
if (!sparse.accepted || !sparse.adjustment_suggested || !sparse.control.integral_suppressed ||
|
||||
sparse.control.integral != 0 || sparse.control.same_direction_count != 2) return 11;
|
||||
++input.timestamp_us;
|
||||
input.suppress_integral = true;
|
||||
const auto overridden = continuous.update(input, continuousConfig);
|
||||
if (!overridden.accepted || !overridden.control.integral_suppressed || overridden.control.integral != 0 ||
|
||||
overridden.control.same_direction_count != 2) return 12;
|
||||
continuousConfig.time_aware = true;
|
||||
continuous.reset(FeedbackResetReason::ClockChanged);
|
||||
input.suppress_integral = false;
|
||||
input.timestamp_us = 0;
|
||||
const auto initial = continuous.update(input, continuousConfig);
|
||||
if (initial.reason != Reason::ConfirmingError || !initial.control.time_aware ||
|
||||
initial.raw_control_gray != 80.0 || initial.control.filtered_gray != 80.0) return 13;
|
||||
input.timestamp_us = 100'000;
|
||||
const auto early = continuous.update(input, continuousConfig);
|
||||
if (early.reason != Reason::ConfirmingError || early.control.confirmation_time_satisfied ||
|
||||
early.control.elapsed_us != 100'000 || std::abs(early.control.effective_max_step_ratio - .005) > 1e-12) return 14;
|
||||
input.timestamp_us = 1'000'000;
|
||||
const auto ready = continuous.update(input, continuousConfig);
|
||||
if (!ready.adjustment_suggested || !ready.control.confirmation_time_satisfied ||
|
||||
ready.control.same_direction_duration_us != 1'000'000) return 15;
|
||||
continuous.reset(FeedbackResetReason::ClockChanged);
|
||||
input = {};
|
||||
input.actual_exposure_us = 10000;
|
||||
input.source = FeedbackSource::ManualRoi;
|
||||
input.metering = measureImage(cv::Mat(8, 8, CV_8UC1, cv::Scalar(110)), offlineRoi, true);
|
||||
input.predicted_gray = 102;
|
||||
continuous.update(input, continuousConfig); // Startup observes the real image.
|
||||
input.timestamp_us = 5'000'000;
|
||||
const auto confirmingPrediction = continuous.update(input, continuousConfig);
|
||||
if (!confirmingPrediction.prediction_used || confirmingPrediction.reason != Reason::ConfirmingError ||
|
||||
confirmingPrediction.adjustment_suggested) return 16;
|
||||
input.timestamp_us = 10'000'000;
|
||||
const auto sharedPrediction = continuous.update(input, continuousConfig);
|
||||
if (!sharedPrediction.prediction_used || !sharedPrediction.adjustment_suggested ||
|
||||
sharedPrediction.feedback_reason != Reason::Deadband || sharedPrediction.feedback_control_gray != 110 ||
|
||||
sharedPrediction.feedback_suggested_exposure_us != 10000 || sharedPrediction.metering.target.gray != 110) return 17;
|
||||
std::cout << "Installed package consumer passed; Gray8/Gray16, original and continuous control, sparse suppression, elapsed-time filtering/confirmation/rate trace; exposure unit us.\n";
|
||||
}
|
||||
|
||||
344
tests/tests.cpp
344
tests/tests.cpp
@ -492,7 +492,7 @@ TEST(controller, small_error_confirmation_pi_and_integral_limit) {
|
||||
r=x.next(80); NEAR(r.control.integral,.2,1e-12); NEAR(*r.suggested_exposure_us,1100,1e-9);
|
||||
r=x.next(80); NEAR(r.control.integral,.3,1e-12); NEAR(*r.suggested_exposure_us,1110,1e-9);
|
||||
r=x.next(80); NEAR(r.control.integral,.3,1e-12);
|
||||
r=x.next(100); CHECK(r.reason==Reason::Deadband); NEAR(r.control.integral,.3,1e-12);
|
||||
r=x.next(100); CHECK(r.reason==Reason::Deadband); NEAR(r.control.integral,0,0);
|
||||
r=x.next(100,false); NEAR(r.control.integral,0,0);
|
||||
}
|
||||
TEST(controller, hysteresis_boundary_and_active_band) {
|
||||
@ -907,6 +907,348 @@ TEST(continuous, anti_windup_and_independent_instances_are_preserved) {
|
||||
CHECK(independent.reset_reason == FeedbackResetReason::Startup);
|
||||
CHECK(independent.reason == Reason::ConfirmingError && independent.control.same_direction_count == 1);
|
||||
}
|
||||
TEST(continuous, sparse_frames_preserve_confirmation_without_accumulating_stale_integral) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig();
|
||||
c.preserve_confirmation_on_idle = true;
|
||||
const TimestampUs interval = c.idle_timeout_us + 1;
|
||||
auto result = control.update(feedbackFrame(0), c);
|
||||
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
||||
for (int frame = 1; frame <= 4; ++frame) {
|
||||
result = control.update(feedbackFrame(frame * interval), c);
|
||||
CHECK(result.accepted && result.reset_reason == FeedbackResetReason::IdleTimeout);
|
||||
CHECK(result.reason == Reason::PiAdjustment && result.control.same_direction_count == 2);
|
||||
CHECK(result.control.integral == 0 && result.control.integral_suppressed);
|
||||
NEAR(*result.suggested_exposure_us, 1080, 1e-9);
|
||||
}
|
||||
// A subsequent close frame may integrate normally; the sparse frames do not.
|
||||
result = control.update(feedbackFrame(4 * interval + 1), c);
|
||||
CHECK(!result.reset_reason && !result.control.integral_suppressed);
|
||||
NEAR(result.control.integral, .2, 1e-12);
|
||||
// An idle gap after accumulated integral clears that integral before computing.
|
||||
result = control.update(feedbackFrame(5 * interval + 1), c);
|
||||
CHECK(result.control.integral == 0 && result.control.integral_suppressed);
|
||||
NEAR(*result.suggested_exposure_us, 1080, 1e-9);
|
||||
}
|
||||
TEST(continuous, sparse_idle_preserves_hysteresis_and_reconfirms_after_direction_change) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig();
|
||||
c.preserve_confirmation_on_idle = true;
|
||||
const TimestampUs interval = c.idle_timeout_us + 1;
|
||||
control.update(feedbackFrame(0), c);
|
||||
// 11 percent error is outside the deadband but inside the re-entry hysteresis.
|
||||
auto result = control.update(feedbackFrame(interval, 89), c);
|
||||
CHECK(result.adjustment_suggested && result.control.same_direction_count == 2);
|
||||
result = control.update(feedbackFrame(2 * interval, 120), c);
|
||||
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
||||
result = control.update(feedbackFrame(3 * interval, 120), c);
|
||||
CHECK(result.adjustment_suggested && *result.suggested_exposure_us < 1000);
|
||||
result = control.update(feedbackFrame(4 * interval, 100), c);
|
||||
CHECK(result.reason == Reason::Deadband && result.control.same_direction_count == 0);
|
||||
result = control.update(feedbackFrame(5 * interval, 89), c);
|
||||
CHECK(result.reason == Reason::Hysteresis && result.control.same_direction_count == 0);
|
||||
}
|
||||
TEST(continuous, idle_option_preserves_default_and_never_weakens_explicit_or_context_resets) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig();
|
||||
const TimestampUs interval = c.idle_timeout_us + 1;
|
||||
control.update(feedbackFrame(0), c);
|
||||
for (int frame = 1; frame <= 3; ++frame) {
|
||||
auto result = control.update(feedbackFrame(frame * interval), c);
|
||||
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
||||
CHECK(!result.control.integral_suppressed);
|
||||
}
|
||||
const auto oldConfig = c;
|
||||
c.preserve_confirmation_on_idle = true;
|
||||
CHECK(c != oldConfig);
|
||||
auto result = control.update(feedbackFrame(4 * interval), c);
|
||||
CHECK(result.reset_reason == FeedbackResetReason::ConfigurationChanged);
|
||||
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
||||
result = control.update(feedbackFrame(5 * interval, 80, FeedbackSource::ManualRoi, 80), c);
|
||||
CHECK(result.reset_reason == FeedbackResetReason::SourceChanged);
|
||||
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
||||
for (const auto reason : {FeedbackResetReason::Manual, FeedbackResetReason::IdleTimeout,
|
||||
FeedbackResetReason::ClockChanged}) {
|
||||
control.reset(reason);
|
||||
result = control.update(feedbackFrame(6 * interval, 80, FeedbackSource::ManualRoi, 80), c);
|
||||
CHECK(result.reset_reason == reason && result.reason == Reason::ConfirmingError);
|
||||
CHECK(result.control.same_direction_count == 1 && !result.control.integral_suppressed);
|
||||
result = control.update(feedbackFrame(7 * interval, 80, FeedbackSource::ManualRoi, 80), c);
|
||||
CHECK(result.adjustment_suggested);
|
||||
// Keep the next explicit reset's accepted timestamp strictly newer.
|
||||
control.reset(FeedbackResetReason::ClockChanged);
|
||||
}
|
||||
}
|
||||
TEST(continuous, external_override_clears_integral_without_restarting_confirmation) {
|
||||
ContinuousExposureControl control; const auto c = feedbackConfig();
|
||||
control.update(feedbackFrame(0), c);
|
||||
auto result = control.update(feedbackFrame(1), c);
|
||||
NEAR(result.control.integral, .2, 1e-12);
|
||||
for (TimestampUs time = 2; time <= 4; ++time) {
|
||||
auto input = feedbackFrame(time); input.suppress_integral = true;
|
||||
result = control.update(input, c);
|
||||
CHECK(result.accepted && !result.reset_reason && result.adjustment_suggested);
|
||||
CHECK(result.control.integral_suppressed && result.control.integral == 0);
|
||||
CHECK(result.control.same_direction_count == 2);
|
||||
NEAR(*result.suggested_exposure_us, 1080, 1e-9);
|
||||
}
|
||||
result = control.update(feedbackFrame(5), c);
|
||||
CHECK(!result.control.integral_suppressed);
|
||||
NEAR(result.control.integral, .2, 1e-12);
|
||||
auto deadband = feedbackFrame(6, 100); deadband.suppress_integral = true;
|
||||
result = control.update(deadband, c);
|
||||
CHECK(result.reason == Reason::Deadband && result.control.integral == 0 && result.control.integral_suppressed);
|
||||
auto confirming = feedbackFrame(7); confirming.suppress_integral = true;
|
||||
result = control.update(confirming, c);
|
||||
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
||||
result = control.update(feedbackFrame(8), c);
|
||||
CHECK(result.adjustment_suggested && result.control.same_direction_count == 2);
|
||||
}
|
||||
TEST(continuous, rejected_override_inputs_do_not_clear_integral_or_consume_time_and_configuration) {
|
||||
ContinuousExposureControl control; const auto c = feedbackConfig();
|
||||
control.update(feedbackFrame(0), c);
|
||||
NEAR(control.update(feedbackFrame(1), c).control.integral, .2, 1e-12);
|
||||
const auto rejected = [&](ContinuousFrameInput bad, const ContinuousConfig& config) {
|
||||
bad.suppress_integral = true;
|
||||
const auto result = control.update(bad, config);
|
||||
CHECK(!result.accepted && !result.control.integral_suppressed);
|
||||
CHECK(result.control.same_direction_count == 2);
|
||||
NEAR(result.control.integral, .2, 1e-12);
|
||||
};
|
||||
rejected(feedbackFrame(1), c);
|
||||
rejected(feedbackFrame(0), c);
|
||||
rejected(feedbackFrame(-1), c);
|
||||
rejected(feedbackFrame(2, 80, FeedbackSource::Global, {}, 0), c);
|
||||
auto bad = feedbackFrame(2); bad.metering.global.gray = std::numeric_limits<double>::quiet_NaN();
|
||||
auto changed = c; changed.preserve_confirmation_on_idle = true;
|
||||
rejected(bad, changed);
|
||||
bad = feedbackFrame(2); bad.source = static_cast<FeedbackSource>(999);
|
||||
rejected(bad, c);
|
||||
auto invalid = changed; invalid.global_controller.target_gray = 0;
|
||||
rejected(feedbackFrame(2), invalid);
|
||||
const auto result = control.update(feedbackFrame(2), c);
|
||||
CHECK(result.accepted && !result.reset_reason && result.control.same_direction_count == 2);
|
||||
NEAR(result.control.integral, .4, 1e-12);
|
||||
}
|
||||
TEST(continuous, reached_target_and_error_reversal_clear_obsolete_integral) {
|
||||
for (const bool timeAware : {false, true}) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = timeAware;
|
||||
c.global_controller.target_gray = 118;
|
||||
double actual = 5000;
|
||||
TimestampUs time = 0;
|
||||
for (int i = 0; i < 7; ++i, time += 5'000'000) {
|
||||
const auto r = control.update(feedbackFrame(time, 100, FeedbackSource::Global, {}, actual), c);
|
||||
actual = *r.suggested_exposure_us;
|
||||
}
|
||||
for (int i = 0; i < 2; ++i, time += 5'000'000) {
|
||||
const auto r = control.update(feedbackFrame(time, 118, FeedbackSource::Global, {}, actual), c);
|
||||
CHECK(r.control.integral == 0 && !r.adjustment_suggested);
|
||||
}
|
||||
for (int i = 0; i < 3; ++i, time += 5'000'000) {
|
||||
const auto r = control.update(feedbackFrame(time, 134, FeedbackSource::Global, {}, actual), c);
|
||||
CHECK(*r.suggested_exposure_us <= actual && r.control.integral <= 0);
|
||||
actual = *r.suggested_exposure_us;
|
||||
}
|
||||
control.reset(FeedbackResetReason::ClockChanged);
|
||||
control.update(feedbackFrame(0, 100), c);
|
||||
const auto dark = control.update(feedbackFrame(5'000'000, 100), c);
|
||||
CHECK(dark.control.integral > 0);
|
||||
const auto reversed = control.update(feedbackFrame(10'000'000, 134), c);
|
||||
CHECK(reversed.control.integral == 0 && *reversed.suggested_exposure_us <= 1000);
|
||||
}
|
||||
}
|
||||
TEST(controller, original_entrypoint_clears_integral_on_return_and_reversal) {
|
||||
Fixture x; x.c.controller.target_gray = 118; x.track(118);
|
||||
for (int i = 0; i < 7; ++i) x.next(100);
|
||||
auto r = x.next(118); CHECK(r.control.integral == 0);
|
||||
x.next(118);
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
r = x.next(134); CHECK(*r.suggested_exposure_us <= 1000 && r.control.integral <= 0);
|
||||
}
|
||||
x.next(100); x.next(100);
|
||||
r = x.next(134); CHECK(r.control.integral == 0 && !r.adjustment_suggested);
|
||||
}
|
||||
TEST(continuous, elapsed_time_limits_rate_and_integral_at_multiple_intervals) {
|
||||
for (const TimestampUs interval : {TimestampUs{100'000}, TimestampUs{1'000'000},
|
||||
TimestampUs{5'000'000}, TimestampUs{60'000'000}}) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true;
|
||||
const auto first = control.update(feedbackFrame(0, 20), c);
|
||||
CHECK(first.control.elapsed_us == c.nominal_interval_us);
|
||||
const auto r = control.update(feedbackFrame(interval, 20), c);
|
||||
const double expectedStep = std::min(.25, .05 * interval / 1'000'000.0);
|
||||
NEAR(*r.suggested_exposure_us, 1000 * (1 + expectedStep), 1e-8);
|
||||
NEAR(r.control.effective_max_step_ratio, expectedStep, 1e-12);
|
||||
CHECK(r.control.time_rate_limited == (interval < 5'000'000));
|
||||
CHECK(r.control.elapsed_us == interval && r.control.filter_alpha == 1);
|
||||
control.reset(FeedbackResetReason::ClockChanged);
|
||||
c.confirmation_us = 0;
|
||||
CHECK(control.update(feedbackFrame(0), c).reason == Reason::ConfirmingError);
|
||||
const auto small = control.update(feedbackFrame(interval), c);
|
||||
CHECK(small.control.same_direction_count == 2);
|
||||
NEAR(small.control.integral_time_scale, std::min(1.0, interval / 5'000'000.0), 1e-12);
|
||||
if (interval >= 5'000'000) NEAR(small.control.integral, .2, 1e-12);
|
||||
}
|
||||
}
|
||||
TEST(continuous, time_confirmation_requires_duration_and_two_samples) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true;
|
||||
c.global_controller.same_direction_frames = 1;
|
||||
CHECK(control.update(feedbackFrame(0), c).reason == Reason::ConfirmingError);
|
||||
for (int i = 1; i < 10; ++i) {
|
||||
const auto r = control.update(feedbackFrame(i * 100'000), c);
|
||||
CHECK(r.reason == Reason::ConfirmingError && !r.control.confirmation_time_satisfied);
|
||||
CHECK(r.control.integral == 0);
|
||||
}
|
||||
const auto r = control.update(feedbackFrame(1'000'000), c);
|
||||
CHECK(r.adjustment_suggested && r.control.confirmation_time_satisfied);
|
||||
CHECK(r.control.same_direction_duration_us == 1'000'000);
|
||||
NEAR(r.control.integral_time_scale, .02, 1e-12);
|
||||
}
|
||||
TEST(continuous, smoothing_normalizes_exposure_and_never_reverses_raw_error) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true;
|
||||
control.update(feedbackFrame(0, 80), c);
|
||||
// Same scene response at changed exposure must not be mistaken for light drift.
|
||||
auto r = control.update(feedbackFrame(100'000, 88, FeedbackSource::Global, {}, 1100), c);
|
||||
NEAR(*r.control_gray, 88, 1e-10);
|
||||
NEAR(*r.raw_control_gray, 88, 0);
|
||||
CHECK(r.control.filter_alpha > 0 && r.control.filter_alpha < 1);
|
||||
r = control.update(feedbackFrame(200'000, 120, FeedbackSource::Global, {}, 1100), c);
|
||||
CHECK(*r.control_gray == 120 && r.control.filter_alpha == 1);
|
||||
CHECK(*r.suggested_exposure_us <= 1100 && r.control.integral <= 0);
|
||||
r = control.update(feedbackFrame(300'000, 100, FeedbackSource::Global, {}, 1100), c);
|
||||
CHECK(r.reason == Reason::Deadband && !r.adjustment_suggested && r.control.integral == 0);
|
||||
auto overrideFrame = feedbackFrame(400'000, 90); overrideFrame.suppress_integral = true;
|
||||
r = control.update(overrideFrame, c);
|
||||
CHECK(*r.control_gray == 90 && r.control.filter_alpha == 1 && r.control.integral == 0);
|
||||
}
|
||||
TEST(continuous, timed_resets_and_long_gaps_clear_filter_without_stale_integral) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig();
|
||||
c.time_aware = true; c.preserve_confirmation_on_idle = true;
|
||||
control.update(feedbackFrame(0), c);
|
||||
auto r = control.update(feedbackFrame(3'600'000'000), c);
|
||||
CHECK(r.adjustment_suggested && r.control.integral == 0 && r.control.integral_time_scale == 0);
|
||||
CHECK(r.control.filter_alpha == 1 && r.control.same_direction_duration_us == 3'600'000'000);
|
||||
for (const auto reason : {FeedbackResetReason::Manual, FeedbackResetReason::ClockChanged}) {
|
||||
control.reset(reason);
|
||||
r = control.update(feedbackFrame(3'601'000'000, 85), c);
|
||||
CHECK(r.reset_reason == reason && r.control.filter_alpha == 1 && *r.control_gray == 85);
|
||||
CHECK(r.reason == Reason::ConfirmingError && r.control.same_direction_duration_us == 0);
|
||||
}
|
||||
r = control.update(feedbackFrame(3'602'000'000, 85, FeedbackSource::ManualRoi, 90), c);
|
||||
CHECK(r.reset_reason == FeedbackResetReason::SourceChanged && r.control.filter_alpha == 1);
|
||||
++c.confirmation_us;
|
||||
r = control.update(feedbackFrame(3'603'000'000, 85, FeedbackSource::ManualRoi, 90), c);
|
||||
CHECK(r.reset_reason == FeedbackResetReason::ConfigurationChanged && r.control.filter_alpha == 1);
|
||||
}
|
||||
TEST(continuous, rejected_frames_do_not_consume_temporal_filter_or_timestamps) {
|
||||
ContinuousExposureControl a, b; auto c = feedbackConfig(); c.time_aware = true;
|
||||
a.update(feedbackFrame(0), c); b.update(feedbackFrame(0), c);
|
||||
auto bad = feedbackFrame(500'000, 255); bad.actual_exposure_us = 0;
|
||||
CHECK(!a.update(bad, c).accepted);
|
||||
bad = feedbackFrame(500'000, 255); bad.metering.global.gray = -1;
|
||||
CHECK(!a.update(bad, c).accepted);
|
||||
for (int field = 0; field < 4; ++field) {
|
||||
auto invalid = c;
|
||||
if (field == 0) invalid.nominal_interval_us = 0;
|
||||
if (field == 1) invalid.smoothing_tau_us = -1;
|
||||
if (field == 2) invalid.confirmation_us = -1;
|
||||
if (field == 3) invalid.max_step_ratio_per_second = 0;
|
||||
CHECK(!a.update(feedbackFrame(500'000, 255), invalid).accepted);
|
||||
}
|
||||
const auto x = a.update(feedbackFrame(1'000'000, 85), c);
|
||||
const auto y = b.update(feedbackFrame(1'000'000, 85), c);
|
||||
CHECK(x.reason == y.reason && x.control_gray == y.control_gray && x.suggested_exposure_us == y.suggested_exposure_us);
|
||||
CHECK(x.control.elapsed_us == y.control.elapsed_us && x.control.integral == y.control.integral);
|
||||
CHECK(x.control.same_direction_duration_us == y.control.same_direction_duration_us);
|
||||
}
|
||||
TEST(continuous, tiny_finite_exposure_cannot_poison_temporal_filter) {
|
||||
ContinuousExposureControl control; auto c = feedbackConfig();
|
||||
c.time_aware = true; c.min_exposure_us = 1e-320;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const auto r = control.update(feedbackFrame(i * 1'000'000, 80, FeedbackSource::Global, {}, 1e-319), c);
|
||||
CHECK(r.accepted && r.control_gray == 80 && std::isfinite(*r.suggested_exposure_us));
|
||||
CHECK(std::isfinite(r.control.integral));
|
||||
}
|
||||
}
|
||||
ContinuousConfig predictionConfig() {
|
||||
auto c = feedbackConfig(); c.time_aware = true; c.max_exposure_us = 30000;
|
||||
c.region_controller.target_gray = c.global_controller.target_gray = 118;
|
||||
return c;
|
||||
}
|
||||
ContinuousFrameInput predictionFrame(TimestampUs time, int gray, double predicted) {
|
||||
auto input = feedbackFrame(time, gray, FeedbackSource::ManualRoi, gray, 10000);
|
||||
input.predicted_gray = predicted;
|
||||
return input;
|
||||
}
|
||||
TEST(continuous, prediction_uses_shared_deadband_hysteresis_and_confirmation) {
|
||||
ContinuousExposureControl control; const auto c = predictionConfig();
|
||||
auto r = control.update(predictionFrame(0, 110, 110), c);
|
||||
CHECK(r.accepted && !r.prediction_used && r.reset_reason == FeedbackResetReason::Startup);
|
||||
r = control.update(predictionFrame(5'000'000, 110, 110), c);
|
||||
CHECK(r.prediction_used && r.reason == Reason::Deadband && *r.suggested_exposure_us == 10000);
|
||||
r = control.update(predictionFrame(10'000'000, 105, 105), c);
|
||||
CHECK(r.prediction_used && r.reason == Reason::Hysteresis && *r.suggested_exposure_us == 10000);
|
||||
r = control.update(predictionFrame(15'000'000, 110, 102), c);
|
||||
CHECK(r.prediction_used && r.reason == Reason::ConfirmingError && !r.adjustment_suggested);
|
||||
CHECK(r.control.same_direction_count == 1 && r.control.integral == 0);
|
||||
r = control.update(predictionFrame(20'000'000, 110, 101), c);
|
||||
CHECK(r.prediction_used && r.reason == Reason::PiAdjustment && r.adjustment_suggested);
|
||||
CHECK(r.control.same_direction_count == 2 && r.control.confirmation_time_satisfied);
|
||||
CHECK(*r.suggested_exposure_us > 10000 && *r.suggested_exposure_us <= 11000);
|
||||
CHECK(r.feedback_reason == Reason::Deadband && *r.feedback_suggested_exposure_us == 10000);
|
||||
CHECK(r.metering.target.gray == 110 && r.raw_control_gray == 110 && r.feedback_control_gray == 110);
|
||||
CHECK(r.control.filtered_gray == 110 && r.control_gray == 101);
|
||||
NEAR(*r.control.current_error_ratio, 8.0 / 118, 1e-12);
|
||||
NEAR(*r.control.control_error_ratio, 17.0 / 118, 1e-12);
|
||||
}
|
||||
TEST(continuous, prediction_keeps_time_confirmation_and_both_step_limits) {
|
||||
ContinuousExposureControl control; auto c = predictionConfig();
|
||||
control.update(predictionFrame(0, 110, 110), c);
|
||||
auto r = control.update(predictionFrame(100'000, 110, 80), c);
|
||||
CHECK(r.reason == Reason::ConfirmingError);
|
||||
r = control.update(predictionFrame(200'000, 110, 80), c);
|
||||
CHECK(r.reason == Reason::ConfirmingError && !r.control.confirmation_time_satisfied);
|
||||
r = control.update(predictionFrame(1'100'000, 110, 80), c);
|
||||
CHECK(r.adjustment_suggested && r.control.confirmation_time_satisfied && r.control.time_rate_limited);
|
||||
NEAR(*r.suggested_exposure_us, 10450, 1e-8);
|
||||
r = control.update(predictionFrame(6'100'000, 110, 60), c);
|
||||
CHECK(r.adjustment_suggested && r.control.step_limited && !r.control.time_rate_limited);
|
||||
NEAR(*r.suggested_exposure_us, 11000, 1e-8);
|
||||
control.reset();
|
||||
c.time_aware = false;
|
||||
control.update(predictionFrame(7'000'000, 110, 110), c);
|
||||
r = control.update(predictionFrame(8'000'000, 110, 80), c);
|
||||
CHECK(r.reason == Reason::ConfirmingError);
|
||||
r = control.update(predictionFrame(9'000'000, 110, 80), c);
|
||||
CHECK(r.adjustment_suggested && *r.suggested_exposure_us <= 11000);
|
||||
}
|
||||
TEST(continuous, prediction_cannot_override_active_feedback_or_protection_resets) {
|
||||
const auto c = predictionConfig();
|
||||
for (int scenario = 0; scenario < 5; ++scenario) {
|
||||
ContinuousExposureControl control;
|
||||
control.update(predictionFrame(0, 110, 110), c);
|
||||
auto input = predictionFrame(5'000'000, 110, 60);
|
||||
if (scenario == 0) input = predictionFrame(5'000'000, 60, 200);
|
||||
if (scenario == 1) input.source = FeedbackSource::Global;
|
||||
if (scenario == 2) input.suppress_integral = true;
|
||||
if (scenario == 3) control.reset(FeedbackResetReason::ClockChanged);
|
||||
if (scenario == 4) input.timestamp_us = c.idle_timeout_us + 1;
|
||||
const auto r = control.update(input, c);
|
||||
CHECK(r.accepted && !r.prediction_used && r.feedback_reason == r.reason);
|
||||
CHECK(r.feedback_suggested_exposure_us == r.suggested_exposure_us);
|
||||
if (scenario == 0) CHECK(r.adjustment_suggested && *r.suggested_exposure_us > 10000);
|
||||
else CHECK(!r.adjustment_suggested);
|
||||
}
|
||||
}
|
||||
TEST(continuous, rejected_prediction_does_not_mutate_feedback_state) {
|
||||
const auto c = predictionConfig(); ContinuousExposureControl a, b;
|
||||
a.update(predictionFrame(0, 110, 110), c); b.update(predictionFrame(0, 110, 110), c);
|
||||
a.update(predictionFrame(5'000'000, 110, 102), c); b.update(predictionFrame(5'000'000, 110, 102), c);
|
||||
auto bad = predictionFrame(6'000'000, 110, std::numeric_limits<double>::quiet_NaN());
|
||||
CHECK(!a.update(bad, c).accepted);
|
||||
bad.predicted_gray = 101; bad.prediction_max_step_ratio = 0;
|
||||
CHECK(!a.update(bad, c).accepted);
|
||||
const auto x = a.update(predictionFrame(10'000'000, 110, 101), c);
|
||||
const auto y = b.update(predictionFrame(10'000'000, 110, 101), c);
|
||||
CHECK(x.adjustment_suggested && x.reason == y.reason && x.suggested_exposure_us == y.suggested_exposure_us);
|
||||
CHECK(x.control.same_direction_count == y.control.same_direction_count && x.control.integral == y.control.integral);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc,char** argv) {
|
||||
|
||||
12
实施记录.md
12
实施记录.md
@ -1,5 +1,17 @@
|
||||
# 目标测光曝光控制模块实施记录
|
||||
|
||||
## 2026-09-23 连续控制共享预测稳定规则
|
||||
|
||||
批次 `prediction-stability-fix-20260923` 为 `ContinuousFrameInput` 追加可选外部预测输入。只在真实区域反馈保持、没有重置或保护抑制时选用预测;预测与实测反馈使用同一个状态控制器,保留死区、迟滞、连续确认、时间确认、PI 和限幅。默认不传预测的调用保持原行为。`feedback_*` 记录真实反馈依据,`metering` 和滤波历史仍由实测更新;`prediction_used` 不表示设备写入。静态库和应用须一起重新编译。
|
||||
|
||||
新增回归覆盖保持区/迟滞/确认、时间与预测限幅、保护及重置优先级、非法预测不污染状态;独立安装消费工程实际调用新增接口。完整基线由以下命令执行,结果与源码指纹以 `.codex/delivery-integrity.json` 为准:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File tools/verify.ps1 -BuildRoot E:/05_Temp/agent/ci/tmp/MatchVerifyFix-01a0cd3e/actual-library-build -OpenCVDir E:/06_EnvConfig/opencv/build/x64/vc16/lib -OpenCVRuntimeDir E:/06_EnvConfig/opencv/build/x64/vc16/bin
|
||||
```
|
||||
|
||||
所有输入为内存图像和模拟时间,未连接真实相机、部署或提交推送。应用内的时序拟合与等待 60 秒加处理耗时边界由 MatchVerify 集成回归覆盖。
|
||||
|
||||
## 2026-09-22 现场光照分析测光扩展(1.3.0)
|
||||
|
||||
批次 `daylight-metering-20260922` 为独立测光接入 Gray16 和有效图像掩膜,保留已有连续控制与原控制的策略行为。公开 `measureImage` 追加可选第五参数 `validImageMask`;原三/四参数源码调用继续可用。`Brightness` 追加原始普通均值、源位深和满量程,既有普通/截尾均值仍采用统一 0~255 标度。公开 `kVersion` 供分析输出记录库版本。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user