From 11236718ea2a5d0987f07449db57b54301e98e2a Mon Sep 17 00:00:00 2001 From: Ben <987807597@qq.com> Date: Tue, 20 Oct 2026 23:59:35 +0800 Subject: [PATCH] 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. --- README.md | 6 +- docs/控制与状态说明.md | 21 +- .../exposure_control.hpp | 40 +- src/config.cpp | 9 + src/continuous_control.cpp | 98 ++++- src/controller.cpp | 16 +- src/internal.hpp | 3 +- tests/installed_consumer/main.cpp | 48 ++- tests/tests.cpp | 344 +++++++++++++++++- 实施记录.md | 12 + 10 files changed, 581 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 227f760..da24e38 100644 --- a/README.md +++ b/README.md @@ -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 调用) diff --git a/docs/控制与状态说明.md b/docs/控制与状态说明.md index b2a0510..0103f13 100644 --- a/docs/控制与状态说明.md +++ b/docs/控制与状态说明.md @@ -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 状态机。 + ## 单次无目标恢复 从首次未检出开始,到连续检出达到配置数量结束,视为同一丢失过程。恢复机会独立于三态及初始化记录。 diff --git a/include/target_exposure_control/exposure_control.hpp b/include/target_exposure_control/exposure_control.hpp index 0804ed7..cfd7909 100644 --- a/include/target_exposure_control/exposure_control.hpp +++ b/include/target_exposure_control/exposure_control.hpp @@ -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 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 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 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 errors; + // Raw selected gray before optional exposure-normalized temporal filtering. + std::optional raw_control_gray; + // The measured-only decision remains explicit; prediction never alters + // metering or the temporal filter's measured-brightness history. + std::optional feedback_control_gray; + std::optional 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: diff --git a/src/config.cpp b/src/config.cpp index 55f1c04..2857db2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -106,6 +106,11 @@ std::vector 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 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); } diff --git a/src/continuous_control.cpp b/src/continuous_control.cpp index 2546f92..eda7e2b 100644 --- a/src/continuous_control.cpp +++ b/src/continuous_control.cpp @@ -1,4 +1,5 @@ #include "internal.hpp" +#include #include namespace target_exposure { @@ -24,6 +25,12 @@ struct ContinuousExposureControl::Impl { std::optional source; std::optional watermark; std::optional pending_reset = FeedbackResetReason::Startup; + std::optional normalized_gray; + std::optional direction_since; + int timed_direction = 0; + void resetTemporal() { + normalized_gray.reset(); direction_since.reset(); timed_direction = 0; + } }; ContinuousExposureControl::ContinuousExposureControl() : impl_(std::make_unique()) {} @@ -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(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; diff --git a/src/controller.cpp b/src/controller.cpp index 2467736..8bb1931 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -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) { diff --git a/src/internal.hpp b/src/internal.hpp index 970a046..98fdb08 100644 --- a/src/internal.hpp +++ b/src/internal.hpp @@ -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 diff --git a/tests/installed_consumer/main.cpp b/tests/installed_consumer/main.cpp index cfcf29d..149162f 100644 --- a/tests/installed_consumer/main.cpp +++ b/tests/installed_consumer/main.cpp @@ -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"; } diff --git a/tests/tests.cpp b/tests/tests.cpp index 633416e..411f9d0 100644 --- a/tests/tests.cpp +++ b/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::quiet_NaN(); + auto changed = c; changed.preserve_confirmation_on_idle = true; + rejected(bad, changed); + bad = feedbackFrame(2); bad.source = static_cast(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::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) { diff --git a/实施记录.md b/实施记录.md index 098bc8f..fad2cda 100644 --- a/实施记录.md +++ b/实施记录.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` 供分析输出记录库版本。