diff --git a/CMakeLists.txt b/CMakeLists.txt index 12da6cb..c0f1cd5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(TargetExposureControl VERSION 1.1.0 LANGUAGES CXX) +project(TargetExposureControl VERSION 1.3.0 LANGUAGES CXX) include(GNUInstallDirs) include(CMakePackageConfigHelpers) @@ -7,7 +7,7 @@ find_package(OpenCV 4 REQUIRED COMPONENTS core imgproc) add_library(target_exposure_control STATIC src/config.cpp src/metering.cpp src/history.cpp src/predictor.cpp - src/controller.cpp src/exposure_control.cpp) + src/controller.cpp src/exposure_control.cpp src/continuous_control.cpp) add_library(TargetExposureControl::Control ALIAS target_exposure_control) set_target_properties(target_exposure_control PROPERTIES EXPORT_NAME Control POSITION_INDEPENDENT_CODE ON CXX_EXTENSIONS OFF) @@ -33,7 +33,7 @@ if(TEC_BUILD_TESTS) add_executable(target_exposure_tests tests/tests.cpp) target_link_libraries(target_exposure_tests PRIVATE TargetExposureControl::Control) tec_warnings(target_exposure_tests) - foreach(group IN ITEMS metering stateless history prediction controller states recovery lifecycle feedback config) + foreach(group IN ITEMS metering stateless history prediction controller states recovery lifecycle feedback config continuous) add_test(NAME tec_${group} COMMAND target_exposure_tests ${group}) set_tests_properties(tec_${group} PROPERTIES TIMEOUT 30) endforeach() diff --git a/README.md b/README.md index 61f7c02..227f760 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,39 @@ target_link_libraries(your_app PRIVATE TargetExposureControl::Control) 每路相机/图像流使用一个 `target_exposure::ExposureControl` 实例。不同实例状态隔离,同一实例由调用方串行调用。所有曝光单位为 **微秒 us**,所有时间戳也使用同一时钟域下的非负微秒;两者语义不同,不可相互替代。 -## 独立离线测光(1.1.0) +## 连续在线反馈(1.2.0) + +需要每一帧都依据成像亮度调整曝光时,使用独立的 `ContinuousExposureControl`。它复用原比例/PI、死区、迟滞、单步限幅和积分保护,但没有“目标确认 → 丢失冻结 → 单次恢复预算”,也不调用预测。原 `ExposureControl` 的状态和默认行为保持不变。 + +```cpp +target_exposure::ContinuousExposureControl feedback; // 每路相机一个,跨帧保留 +target_exposure::ContinuousConfig config; +config.min_exposure_us = camera_min_exposure_us; +config.max_exposure_us = permitted_max_exposure_us; +config.region_controller.target_gray = desired_region_gray; +config.global_controller.target_gray = desired_global_gray; + +target_exposure::ContinuousFrameInput input; +input.metering = target_exposure::measureImage(original_image, final_roi, region_is_usable); +input.source = target_exposure::FeedbackSource::YoloRegions; // 或 ManualRoi、Global +input.timestamp_us = capture_monotonic_us; +input.actual_exposure_us = exposure_read_back_for_this_frame; +const auto result = feedback.update(input, config); +// accepted 且 adjustment_suggested 时,由业务在下一帧前写入 suggested_exposure_us。 +// 写入失败、读回缺失或读回与请求不一致由业务记录并处理,不伪造实际曝光。 +``` + +`Global` 只使用全局均值;`ManualRoi` 和 `YoloRegions` 优先使用有效区域的 `gray`(截尾均值),没有可用区域或区域统计无效时显式回退全局。全局和区域各有独立的 `ControllerConfig`,参考灰度默认均为 118,但可以分别配置;全局和 ROI 的灰度目标无需相同。ROI 有效只是像素区域有效,不确认存在工件。无目标但全局正常时保持曝光,全局偏暗/偏亮时按误差方向连续调整。 + +`accepted=true` 时总是返回 `suggested_exposure_us`;保持时它等于当前实际曝光。每帧曝光值必须来自这一帧的设备读回,不能用上一条建议替代。输入时间为同一单调时钟的非负微秒,严格递增。非法配置、来源、时间、实际曝光或最终测光不会产生可应用建议,也不推进已接受帧时间水位或 PI 状态。 + +实际测光来源切换、配置变化或超过 `idle_timeout_us`(默认 300 秒)会清空积分与同向计数。`reset()` 也清空反馈状态;仅显式 `reset(FeedbackResetReason::ClockChanged)` 解除时间水位。调用方更换 ROI 几何或模型但来源枚举未变化时,应显式 `reset()`,避免跨工况沿用积分。正常流不需要 `preCapture()` 或经验曝光初始化;从相机当前读回曝光开始。 + +日志可直接记录返回的 `requested_source`、`effective_source`、`fallback_reason`、`reset_reason`、`state`、`reason`、实际/建议曝光、参考/反馈灰度、完整 `metering` 以及 `control`。`control` 包含当前/控制误差、积分、同向计数、原始/最终增量、步长限制、曝光限制和积分抗饱和标志。近黑率与饱和率作为测光事实保留,不额外引入新阈值控制策略。库不写日志文件,不接触相机,也不承诺现场识别率。 + +连续控制的 `state` 是 `Holding`、`Adjusting`、`Limited` 或拒绝输入时的 `Invalid`,不表示检测到/丢失工件。新增独立类型保持旧源码调用兼容,静态库与应用应一起重新编译。 + +## 独立离线测光(1.3.0,兼容原 1.1.0 调用) 只计算保存图像时,直接调用 `measureImage`,不创建控制实例、不设置曝光或时间,也不调用 `preCapture`。以下变量由调用方提供: @@ -35,10 +67,21 @@ const auto measurement = target_exposure::measureImage(original_image, roi, roi_ // measurement.global.arithmetic_gray:全局普通平均灰度。 // measurement.target.arithmetic_gray:ROI 普通平均灰度。 // measurement.target.gray:ROI 截尾均值,保留既有控制算法的统计口径。 +// measurement.global.source_arithmetic_gray:图像原始标度的普通均值。 +// target_exposure::kVersion:当前测光库版本,供分析文件记录。 ``` 第四个参数为可选的 `MeteringConfig`,默认整图测光、目标区域两端各截尾 5%、至少 16 个原始像素和 8 个保留像素。只计算全局时传空 `Roi` 和 `false`。`roi_is_usable` 只表示存在可测光的区域;手绘 ROI 有效不代表工件已到位或已识别。 +第五个参数 `validImageMask` 为可选的有效图像区域掩膜。它必须是与图像等尺寸的 `CV_8UC1`,非零像素表示有效;全局区域和目标区域并集都与它取交集。省略或传空 `cv::Mat` 时整图有效,不会根据像素颜色自动排除黑色工件或黑边。全部为零是有效但没有可测像素的掩膜,对应 `EmptyRoi`;格式错误对应 `InvalidRoi`,不静默退回整图。无目标时 `target` 仍为 `NoTarget`。`global_center_fraction` 如非 1,则只限制全局区域,不改变目标区域。 + +```cpp +const auto measurement = target_exposure::measureImage( + original_image, saved_regions, regions_are_usable, metering_config, valid_image_mask); +``` + +`CV_16UC1` 使用 65536 桶的源像素直方图;普通均值与截尾均值都先在 0~65535 标度精确统计,再线性映射到 0~255,不先转成 8 位图。`source_arithmetic_gray` 保留原始普通均值,`source_bit_depth/source_max_gray` 分别为 16/65535;8 位输入则为 8/255,原始与统一均值相同。`arithmetic_gray` 和 `gray` 始终采用 0~255 标度,近黑/饱和阈值及控制配置也维持此标度。图中 12 位或 10 位数据如果装在 16 位容器内,本库仍按 65535 解释;调用方应明确源数据标度,不能误称为满量程 16 位。16 位三通道、Bayer 和浮点图像仍不支持。 + 每个区域使用一次像素直方图,同时输出普通均值、截尾均值、原始/保留像素数和截尾前的近黑/饱和比例。目标样本不足时两个均值均为空,不能用零替代;全局不受目标区域无效影响。配置非法时两个区域均返回 `MeterStatus::InvalidConfiguration`;图像、ROI 错误由对应状态报告。无有效区域时不检查闲置 ROI 内容,全局照常计算。 这是无状态函数,同一输入可重复计算,结果不受曝光控制实例的历史影响;调用期间原图和 mask 均不被修改或保留。允许在输入数据不被并发修改的前提下并发测光。采集时间、处理耗时、模型来源及 CSV/JSON 存档由应用程序关联,不由测光函数生成。 @@ -80,12 +123,12 @@ auto result = exposure.processFrame(input); ## 输入和测光契约 -- 支持 Gray8 和 BGR8。BGR 使用 OpenCV 固定 `COLOR_BGR2GRAY` 转换;不做归一化、gamma 调整或自动增强。不接受画框图、增强图和未经解释的 Bayer/12/16 bit 数据。 +- 支持 Gray8、BGR8 和 Gray16。BGR 使用 OpenCV 固定 `COLOR_BGR2GRAY` 转换;Gray16 先统计原始像素再按 65535 线性映射到统一 0~255 标度。不做逐图 min-max 归一化、gamma 调整或自动增强,不接受画框图、增强图及未经解释的 Bayer 数据。 - `Roi` 中矩形、旋转矩形及 mask 可以混用,按像素取并集,重叠只统计一次。mask 必须为与图像同尺寸的 `CV_8UC1`,非零即选中。 - 普通矩形使用 OpenCV 的左闭右开范围,先裁剪到图像;旋转矩形按像素中心是否位于旋转后的半开矩形内判断,不使用其包围框直接测光。 - 输入 ROI 被视为最终结果,不隐式内缩。`inset(rect, fraction_per_side)` 是独立、显式辅助函数;比例指每一侧内缩量,整数矩形向内取整。 - `arithmetic_gray` 为截尾前的普通平均;全局 `gray` 与其相同,`global_center_fraction<1` 时统计固定比例的居中矩形。目标 `gray` 在并集内上下各删除 `floor(N × trim_fraction_each_side)` 个样本后求平均。 -- 无目标、ROI 空/格式错误、像素不足均有明确状态,`gray`、`arithmetic_gray` 为 `std::nullopt`。真实黑图的灰度 0 可以是有效测量。 +- 无目标、ROI 空/格式错误、像素不足均有明确状态,`gray`、`arithmetic_gray`、`source_arithmetic_gray` 为 `std::nullopt`。真实黑图的灰度 0 可以是有效测量。 - 非有限、非正或超出当前配置范围的实际曝光属于输入契约错误,返回 `InvalidActualExposure`,保留错误实参用于诊断,不伪造实际曝光或产生调整建议。调用方应修正配置/曝光状态后重走拍照前流程。 - 同一实例内帧时间必须严格递增。重复、乱序及缺少拍照前检查的帧不更新控制状态。相机时间重置时显式 `reset(ResetReason::ClockChanged)`。 - 图像只在调用期间读取,不改原图、不保存图像副本;调用期间调用方须保证数据有效且不被其他线程修改。 diff --git a/docs/控制与状态说明.md b/docs/控制与状态说明.md index 6f5a038..b2a0510 100644 --- a/docs/控制与状态说明.md +++ b/docs/控制与状态说明.md @@ -57,6 +57,16 @@ I 是逐次有效 PI 调节累计的相对误差,不按秒积分。仅在新 检出/丢失计数以调用方明确的 `has_usable_target` 为准。LOST 冻结计数包含间歇检出的新采集帧,重复帧和拍照前查询不计数。 +## 连续反馈与原目标状态机的选择 + +本文件其余 CAPTURE/TRACK/LOST、预测与单次恢复说明适用于原 `ExposureControl`。版本 1.2.0 新增的 `ContinuousExposureControl` 是另一种明确调用入口,持续按成像亮度闭环,不把无目标当作曝光错误,也不需要外部“工件存在”信号。 + +连续控制只接收已计算的全局/区域测光结果。指定全局时使用全局普通均值;指定手动 ROI 或 YOLO 区域时,区域有效优先,失效后明确回退全局。手动 ROI 不参与任何真实目标存在计数。源切换后清积分和同向计数,避免 ROI 与全局不同亮度基准相互污染。 + +每次 `update` 先校验配置、严格递增时间、设备实际曝光及最终选用测光;有效后才调用同一个比例/PI 控制实现。黑图是有效的零灰度,会向增曝光方向调整;饱和图会向减曝光方向调整;正常死区保持。持续无目标不消耗任何恢复预算,每张新图都可继续反馈。饱和/近黑比例保留在诊断中,本批没有另创比例阈值保护策略。 + +当前参考灰度由实际来源选择 `global_controller` 或 `region_controller`;配置、来源或闲置超时变化清反馈状态,拒绝帧不消费时间水位。曝光建议仍是库输出,调用方必须落实“成功写入 → 读回 → 下一帧用实际值反馈”,才能构成设备闭环;库内验证不代表相机已生效。 + ## 单次无目标恢复 从首次未检出开始,到连续检出达到配置数量结束,视为同一丢失过程。恢复机会独立于三态及初始化记录。 diff --git a/include/target_exposure_control/exposure_control.hpp b/include/target_exposure_control/exposure_control.hpp index 5aff687..0804ed7 100644 --- a/include/target_exposure_control/exposure_control.hpp +++ b/include/target_exposure_control/exposure_control.hpp @@ -10,6 +10,8 @@ namespace target_exposure { +inline constexpr const char* kVersion = "1.3.0"; + // All timestamps use caller-supplied, nonnegative microseconds in one clock domain. using TimestampUs = std::int64_t; enum class State { CAPTURE, TRACK, LOST }; @@ -115,18 +117,26 @@ struct Brightness { std::size_t retained_pixels = 0; double near_black_fraction = 0.0; double saturated_fraction = 0.0; - // Ordinary mean before trimming; absent whenever the measurement is invalid. + // Ordinary mean before trimming, on the common 0..255 scale; absent when invalid. std::optional arithmetic_gray; + // Ordinary mean in the source scale, computed before any normalization/quantization. + std::optional source_arithmetic_gray; + int source_bit_depth = 8; + double source_max_gray = 255.0; bool valid() const noexcept { return status == MeterStatus::Valid && gray.has_value(); } }; struct MeteringResult { Brightness target; Brightness global; }; -// Stateless Gray8/BGR8 measurement. No exposure, timestamp or control instance needed. +// Stateless Gray8/BGR8/Gray16 measurement. All gray/arithmetic_gray values and +// metering thresholds use the common 0..255 scale (Gray16 source maximum = 65535). +// No exposure, timestamp or control instance needed. // hasUsableRegion describes the supplied ROI, not confirmed physical target presence. +// Optional validImageMask must be full-size CV_8UC1; its nonzero pixels intersect +// both global and target regions. Empty means the entire image is valid. // Reads image/masks synchronously without retaining or changing them. Calls may run // concurrently when the caller keeps the input image/mask data unchanged. MeteringResult measureImage(const cv::Mat& image, const Roi& roi, bool hasUsableRegion, - const MeteringConfig& config = {}); + const MeteringConfig& config = {}, const cv::Mat& validImageMask = {}); struct HistoryRecord { TimestampUs timestamp_us = 0; @@ -158,7 +168,7 @@ struct ControlTrace { bool exposure_limited = false; }; struct FrameInput { - cv::Mat image; // Gray8 or BGR8, read synchronously; no image retained after the call + cv::Mat image; // Gray8, BGR8 or Gray16, read synchronously; no image retained Roi roi; bool has_usable_target = false; TimestampUs timestamp_us = 0; @@ -212,4 +222,67 @@ private: std::unique_ptr impl_; }; +// A measurement source describes pixels, never physical workpiece presence. +enum class FeedbackSource { Global, ManualRoi, YoloRegions }; +enum class FeedbackFallbackReason { None, RegionUnavailable, InvalidRegionMeasurement }; +enum class FeedbackResetReason { Startup, Manual, ConfigurationChanged, SourceChanged, + IdleTimeout, ClockChanged }; +enum class FeedbackState { Idle, Holding, Adjusting, Limited, Invalid }; +const char* toString(FeedbackSource value) noexcept; +const char* toString(FeedbackFallbackReason value) noexcept; +const char* toString(FeedbackResetReason value) noexcept; +const char* toString(FeedbackState value) noexcept; + +struct ContinuousConfig { + double min_exposure_us = 0.0; + double max_exposure_us = 0.0; + TimestampUs idle_timeout_us = 300'000'000; + ControllerConfig region_controller; + ControllerConfig global_controller; +}; +std::vector validate(const ContinuousConfig& config); +bool operator==(const ContinuousConfig& a, const ContinuousConfig& b); +inline bool operator!=(const ContinuousConfig& a, const ContinuousConfig& b) { return !(a == b); } + +struct ContinuousFrameInput { + MeteringResult metering; + FeedbackSource source = FeedbackSource::Global; + TimestampUs timestamp_us = 0; + double actual_exposure_us = 0.0; // Device readback for this frame, not a previous suggestion. +}; +struct ContinuousFrameResult { + bool accepted = false; + FeedbackState state = FeedbackState::Invalid; + Reason reason = Reason::InvalidMetering; + FeedbackSource requested_source = FeedbackSource::Global; + std::optional effective_source; + FeedbackFallbackReason fallback_reason = FeedbackFallbackReason::None; + std::optional reset_reason; + double actual_exposure_us = 0.0; + std::optional suggested_exposure_us; + bool adjustment_suggested = false; + double target_gray = 0.0; + std::optional control_gray; + MeteringResult metering; + ControlTrace control; + std::vector errors; +}; + +// Serial calls per image stream. No target-presence state machine, prediction or +// preCapture step: each accepted measurement supplies feedback for the next frame. +// The caller applies suggestions and reads the actual exposure of each new frame. +class ContinuousExposureControl { +public: + ContinuousExposureControl(); + ~ContinuousExposureControl(); + ContinuousExposureControl(const ContinuousExposureControl&) = delete; + ContinuousExposureControl& operator=(const ContinuousExposureControl&) = delete; + ContinuousFrameResult update(const ContinuousFrameInput& input, const ContinuousConfig& config); + // Only ClockChanged removes the timestamp watermark. All resets clear PI state. + void reset(FeedbackResetReason reason = FeedbackResetReason::Manual); +private: + struct Impl; + std::unique_ptr impl_; +}; + } // namespace target_exposure diff --git a/src/config.cpp b/src/config.cpp index cd208a2..55f1c04 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -3,6 +3,30 @@ #include namespace target_exposure { +std::vector detail::validateController(const ControllerConfig& k) { + std::vector errors; + const auto require = [&](bool ok, const char* name) { if (!ok) errors.emplace_back(name); }; + const auto range = [](double v, double lo, double hi) { return std::isfinite(v) && v >= lo && v <= hi; }; + require(range(k.target_gray, 0, 255) && k.target_gray > 0, "target_gray must be in (0, 255]"); + require(range(k.deadband_ratio, 0, 1) && range(k.hysteresis_ratio, 0, 1) && + std::isfinite(k.large_error_ratio) && k.large_error_ratio > k.deadband_ratio + k.hysteresis_ratio, + "large_error_ratio must exceed deadband plus hysteresis"); + require(range(k.proportional_gain, 0, 1), "proportional_gain must be in [0, 1]"); + require(std::isfinite(k.kp) && k.kp >= 0 && std::isfinite(k.ki) && k.ki >= 0, + "PI gains must be nonnegative and finite"); + require(std::isfinite(k.integral_abs_limit) && k.integral_abs_limit >= 0, + "integral_abs_limit must be nonnegative and finite"); + require(range(k.max_step_ratio, 0, 1) && k.max_step_ratio > 0 && k.max_step_ratio < 1, + "max_step_ratio must be in (0, 1)"); + require(k.same_direction_frames > 0, "same_direction_frames must be positive"); + return errors; +} +bool detail::sameController(const ControllerConfig& a, const ControllerConfig& b) { + const auto fields = [](const ControllerConfig& c) { return std::tie(c.target_gray, + c.deadband_ratio, c.hysteresis_ratio, c.large_error_ratio, c.proportional_gain, + c.kp, c.ki, c.integral_abs_limit, c.max_step_ratio, c.same_direction_frames); }; + return fields(a) == fields(b); +} std::vector detail::validateMetering(const MeteringConfig& m) { std::vector errors; const auto require = [&](bool ok, const char* name) { if (!ok) errors.emplace_back(name); }; @@ -47,18 +71,8 @@ std::vector validate(const Config& c) { p.min_predicted_gray < p.max_predicted_gray && p.max_predicted_gray > p.black_level, "invalid predicted gray range"); const auto& k = c.controller; - require(range(k.target_gray, 0, 255) && k.target_gray > 0, "target_gray must be in (0, 255]"); - require(range(k.deadband_ratio, 0, 1) && range(k.hysteresis_ratio, 0, 1) && - std::isfinite(k.large_error_ratio) && k.large_error_ratio > k.deadband_ratio + k.hysteresis_ratio, - "large_error_ratio must exceed deadband plus hysteresis"); - require(range(k.proportional_gain, 0, 1), "proportional_gain must be in [0, 1]"); - require(std::isfinite(k.kp) && k.kp >= 0 && std::isfinite(k.ki) && k.ki >= 0, - "PI gains must be nonnegative and finite"); - require(std::isfinite(k.integral_abs_limit) && k.integral_abs_limit >= 0, - "integral_abs_limit must be nonnegative and finite"); - require(range(k.max_step_ratio, 0, 1) && k.max_step_ratio > 0 && k.max_step_ratio < 1, - "max_step_ratio must be in (0, 1)"); - require(k.same_direction_frames > 0, "same_direction_frames must be positive"); + const auto controllerErrors = detail::validateController(k); + errors.insert(errors.end(), controllerErrors.begin(), controllerErrors.end()); const auto& r = c.recovery; require(!r.global_reference_gray || (range(*r.global_reference_gray, 0, 255) && *r.global_reference_gray > 0), "global_reference_gray must be in (0, 255]"); @@ -78,17 +92,52 @@ bool operator==(const Config& a, const Config& b) { c.photometric_model_confirmed, c.points, c.black_level, c.nominal_interval_us, c.max_horizon_us, c.max_relative_fit_rmse, c.max_near_black_fraction, c.max_saturated_fraction, c.min_predicted_gray, c.max_predicted_gray); }; - const auto ctrl = [](const ControllerConfig& c) { return std::tie(c.target_gray, - c.deadband_ratio, c.hysteresis_ratio, c.large_error_ratio, c.proportional_gain, - c.kp, c.ki, c.integral_abs_limit, c.max_step_ratio, c.same_direction_frames); }; const auto rec = [](const RecoveryConfig& c) { return std::tie(c.global_reference_gray, c.deadband_ratio, c.gain, c.max_step_ratio); }; return base(a) == base(b) && meter(a.metering) == meter(b.metering) && - pred(a.prediction) == pred(b.prediction) && ctrl(a.controller) == ctrl(b.controller) && + pred(a.prediction) == pred(b.prediction) && detail::sameController(a.controller, b.controller) && rec(a.recovery) == rec(b.recovery); } +std::vector validate(const ContinuousConfig& c) { + std::vector errors; + if (!std::isfinite(c.min_exposure_us) || c.min_exposure_us <= 0) + errors.emplace_back("min_exposure_us must be positive and finite"); + 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"); + for (const auto& error : detail::validateController(c.region_controller)) + errors.emplace_back("region_controller." + error); + for (const auto& error : detail::validateController(c.global_controller)) + errors.emplace_back("global_controller." + error); + return errors; +} +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 && + detail::sameController(a.region_controller, b.region_controller) && + detail::sameController(a.global_controller, b.global_controller); +} + #define TEC_ENUM_CASE(type, name) case type::name: return #name +const char* toString(FeedbackSource v) noexcept { switch (v) { + TEC_ENUM_CASE(FeedbackSource, Global); TEC_ENUM_CASE(FeedbackSource, ManualRoi); + TEC_ENUM_CASE(FeedbackSource, YoloRegions); +} return "Unknown"; } +const char* toString(FeedbackFallbackReason v) noexcept { switch (v) { + TEC_ENUM_CASE(FeedbackFallbackReason, None); TEC_ENUM_CASE(FeedbackFallbackReason, RegionUnavailable); + TEC_ENUM_CASE(FeedbackFallbackReason, InvalidRegionMeasurement); +} return "Unknown"; } +const char* toString(FeedbackResetReason v) noexcept { switch (v) { + TEC_ENUM_CASE(FeedbackResetReason, Startup); TEC_ENUM_CASE(FeedbackResetReason, Manual); + TEC_ENUM_CASE(FeedbackResetReason, ConfigurationChanged); TEC_ENUM_CASE(FeedbackResetReason, SourceChanged); + TEC_ENUM_CASE(FeedbackResetReason, IdleTimeout); TEC_ENUM_CASE(FeedbackResetReason, ClockChanged); +} return "Unknown"; } +const char* toString(FeedbackState v) noexcept { switch (v) { + TEC_ENUM_CASE(FeedbackState, Idle); TEC_ENUM_CASE(FeedbackState, Holding); + TEC_ENUM_CASE(FeedbackState, Adjusting); TEC_ENUM_CASE(FeedbackState, Limited); + TEC_ENUM_CASE(FeedbackState, Invalid); +} return "Unknown"; } const char* toString(State v) noexcept { switch (v) { TEC_ENUM_CASE(State, CAPTURE); TEC_ENUM_CASE(State, TRACK); TEC_ENUM_CASE(State, LOST); } return "Unknown"; } diff --git a/src/continuous_control.cpp b/src/continuous_control.cpp new file mode 100644 index 0000000..2546f92 --- /dev/null +++ b/src/continuous_control.cpp @@ -0,0 +1,110 @@ +#include "internal.hpp" +#include + +namespace target_exposure { +namespace { +bool usable(const Brightness& value) { + const auto gray = [](double v) { return std::isfinite(v) && v >= 0 && v <= 255; }; + const auto fraction = [](double v) { return std::isfinite(v) && v >= 0 && v <= 1; }; + return value.valid() && gray(*value.gray) && + (!value.arithmetic_gray || gray(*value.arithmetic_gray)) && value.pixels > 0 && + value.retained_pixels > 0 && value.retained_pixels <= value.pixels && + fraction(value.near_black_fraction) && fraction(value.saturated_fraction) && + value.near_black_fraction + value.saturated_fraction <= 1.0 + 1e-12; +} +bool validSource(FeedbackSource source) { + return source == FeedbackSource::Global || source == FeedbackSource::ManualRoi || + source == FeedbackSource::YoloRegions; +} +} + +struct ContinuousExposureControl::Impl { + detail::Controller controller; + std::optional config; + std::optional source; + std::optional watermark; + std::optional pending_reset = FeedbackResetReason::Startup; +}; + +ContinuousExposureControl::ContinuousExposureControl() : impl_(std::make_unique()) {} +ContinuousExposureControl::~ContinuousExposureControl() = default; + +void ContinuousExposureControl::reset(FeedbackResetReason reason) { + impl_->controller.reset(); + impl_->source.reset(); + impl_->pending_reset = reason; + if (reason == FeedbackResetReason::ClockChanged) impl_->watermark.reset(); +} + +ContinuousFrameResult ContinuousExposureControl::update(const ContinuousFrameInput& input, + const ContinuousConfig& config) { + auto& s = *impl_; + ContinuousFrameResult out; + out.requested_source = input.source; + out.actual_exposure_us = input.actual_exposure_us; + out.metering = input.metering; + out.control.integral = s.controller.integral; + out.control.same_direction_count = s.controller.count; + out.errors = validate(config); + if (!validSource(input.source)) out.errors.emplace_back("invalid measurement source"); + 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) { + out.reason = input.timestamp_us == *s.watermark ? Reason::DuplicateFrame : Reason::OutOfOrderFrame; + return out; + } + if (!std::isfinite(input.actual_exposure_us) || input.actual_exposure_us < config.min_exposure_us || + input.actual_exposure_us > config.max_exposure_us) { + out.reason = Reason::InvalidActualExposure; return out; + } + + FeedbackSource selected = input.source; + const Brightness* measurement = &input.metering.global; + if (input.source != FeedbackSource::Global) { + if (usable(input.metering.target)) measurement = &input.metering.target; + else { + selected = FeedbackSource::Global; + const auto status = input.metering.target.status; + out.fallback_reason = status == MeterStatus::NoTarget || status == MeterStatus::EmptyRoi || + status == MeterStatus::InsufficientPixels ? FeedbackFallbackReason::RegionUnavailable : + FeedbackFallbackReason::InvalidRegionMeasurement; + } + } + if (!usable(*measurement)) { + out.reason = Reason::InvalidMetering; + out.errors.emplace_back("selected measurement has invalid status, gray, pixel counts or fractions"); + return out; + } + out.effective_source = selected; + const auto& controller = selected == FeedbackSource::Global ? config.global_controller : config.region_controller; + out.target_gray = controller.target_gray; + out.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(); + + Config controlConfig; + controlConfig.min_exposure_us = config.min_exposure_us; + controlConfig.max_exposure_us = config.max_exposure_us; + 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); + out.accepted = true; + out.reason = feedback.reason; + out.suggested_exposure_us = feedback.suggested_exposure_us; + out.adjustment_suggested = feedback.adjustment_suggested; + out.control = feedback.control; + out.state = feedback.reason == Reason::ExposureLimit ? FeedbackState::Limited : + feedback.adjustment_suggested ? FeedbackState::Adjusting : FeedbackState::Holding; + s.config = config; + s.source = selected; + s.watermark = input.timestamp_us; + s.pending_reset.reset(); + return out; +} +} // namespace target_exposure diff --git a/src/internal.hpp b/src/internal.hpp index 4e3899a..970a046 100644 --- a/src/internal.hpp +++ b/src/internal.hpp @@ -4,6 +4,8 @@ namespace target_exposure::detail { std::vector validateMetering(const MeteringConfig& config); +std::vector validateController(const ControllerConfig& config); +bool sameController(const ControllerConfig& a, const ControllerConfig& b); struct History { std::deque records; // Separate retention prevents many no-target frames evicting the newest valid targets. diff --git a/src/metering.cpp b/src/metering.cpp index 1345924..4920145 100644 --- a/src/metering.cpp +++ b/src/metering.cpp @@ -1,7 +1,6 @@ #include "internal.hpp" #include #include -#include #include #include #include @@ -38,12 +37,17 @@ cv::RotatedRect inset(const cv::RotatedRect& rectangle, double fraction) { } namespace { -Brightness measure(const cv::Mat& gray, const cv::Mat& mask, double trim, +template +Brightness measureTyped(const cv::Mat& gray, const cv::Mat& mask, double trim, std::size_t min_pixels, std::size_t min_retained, const MeteringConfig& c) { Brightness out; - std::array hist{}; + constexpr int sourceMax = std::numeric_limits::max(); + constexpr double scale = 255.0 / sourceMax; + out.source_bit_depth = static_cast(sizeof(Pixel) * 8); + out.source_max_gray = sourceMax; + std::vector hist(static_cast(sourceMax) + 1, 0); for (int y = 0; y < gray.rows; ++y) { - const auto* pixels = gray.ptr(y); + const auto* pixels = gray.ptr(y); const auto* selected = mask.ptr(y); for (int x = 0; x < gray.cols; ++x) if (selected[x]) { ++hist[pixels[x]]; @@ -53,10 +57,10 @@ Brightness measure(const cv::Mat& gray, const cv::Mat& mask, double trim, if (out.pixels == 0) { out.status = MeterStatus::EmptyRoi; return out; } std::size_t black = 0, saturated = 0; double untrimmedSum = 0.0; - for (int i = 0; i < 256; ++i) { + for (int i = 0; i <= sourceMax; ++i) { untrimmedSum += static_cast(i) * static_cast(hist[static_cast(i)]); - if (i <= c.near_black_level) black += hist[static_cast(i)]; - if (i >= c.saturation_level) saturated += hist[static_cast(i)]; + if (i * 255 <= c.near_black_level * sourceMax) black += hist[static_cast(i)]; + if (i * 255 >= c.saturation_level * sourceMax) saturated += hist[static_cast(i)]; } out.near_black_fraction = static_cast(black) / static_cast(out.pixels); out.saturated_fraction = static_cast(saturated) / static_cast(out.pixels); @@ -66,14 +70,15 @@ Brightness measure(const cv::Mat& gray, const cv::Mat& mask, double trim, out.status = MeterStatus::InsufficientPixels; return out; } - out.arithmetic_gray = untrimmedSum / static_cast(out.pixels); + out.source_arithmetic_gray = untrimmedSum / static_cast(out.pixels); + out.arithmetic_gray = *out.source_arithmetic_gray * scale; auto remaining = removed; for (std::size_t i = 0; i < hist.size() && remaining; ++i) { const auto take = std::min(remaining, hist[i]); hist[i] -= take; remaining -= take; } remaining = removed; - for (int i = 255; i >= 0 && remaining; --i) { + for (int i = sourceMax; i >= 0 && remaining; --i) { auto& bin = hist[static_cast(i)]; const auto take = std::min(remaining, bin); bin -= take; remaining -= take; @@ -81,11 +86,18 @@ Brightness measure(const cv::Mat& gray, const cv::Mat& mask, double trim, double sum = 0; for (std::size_t i = 0; i < hist.size(); ++i) sum += static_cast(i) * static_cast(hist[i]); - out.gray = sum / static_cast(out.retained_pixels); + out.gray = (sum / static_cast(out.retained_pixels)) * scale; out.status = MeterStatus::Valid; return out; } +Brightness measure(const cv::Mat& gray, const cv::Mat& mask, double trim, + std::size_t min_pixels, std::size_t min_retained, const MeteringConfig& c) { + if (gray.type() == CV_16UC1) + return measureTyped(gray, mask, trim, min_pixels, min_retained, c); + return measureTyped(gray, mask, trim, min_pixels, min_retained, c); +} + bool unionRoi(const Roi& roi, cv::Mat& mask) { for (const auto& r : roi.rectangles) { if (r.width < 0 || r.height < 0) return false; @@ -126,7 +138,7 @@ bool unionRoi(const Roi& roi, cv::Mat& mask) { } MeteringResult measureImage(const cv::Mat& image, const Roi& roi, bool hasUsableRegion, - const MeteringConfig& c) { + const MeteringConfig& c, const cv::Mat& validImageMask) { MeteringResult out; if (!detail::validateMetering(c).empty()) { out.target.status = MeterStatus::InvalidConfiguration; @@ -137,11 +149,21 @@ MeteringResult measureImage(const cv::Mat& image, const Roi& roi, bool hasUsable if (!hasUsableRegion) out.target.status = MeterStatus::NoTarget; return out; } - if (image.dims != 2 || (image.type() != CV_8UC1 && image.type() != CV_8UC3)) { + if (image.dims != 2 || (image.type() != CV_8UC1 && image.type() != CV_8UC3 && image.type() != CV_16UC1)) { out.target.status = hasUsableRegion ? MeterStatus::UnsupportedImage : MeterStatus::NoTarget; out.global.status = MeterStatus::UnsupportedImage; return out; } + if (image.type() == CV_16UC1) { + out.global.source_bit_depth = out.target.source_bit_depth = 16; + out.global.source_max_gray = out.target.source_max_gray = 65535.0; + } + if (!validImageMask.empty() && (validImageMask.dims != 2 || + validImageMask.type() != CV_8UC1 || validImageMask.size() != image.size())) { + out.global.status = MeterStatus::InvalidRoi; + out.target.status = hasUsableRegion ? MeterStatus::InvalidRoi : MeterStatus::NoTarget; + return out; + } cv::Mat gray; if (image.type() == CV_8UC3) cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY); else gray = image; @@ -149,10 +171,12 @@ MeteringResult measureImage(const cv::Mat& image, const Roi& roi, bool hasUsable const int width = std::max(1, static_cast(std::floor(gray.cols * c.global_center_fraction))); const int height = std::max(1, static_cast(std::floor(gray.rows * c.global_center_fraction))); global_mask(cv::Rect{(gray.cols - width) / 2, (gray.rows - height) / 2, width, height}).setTo(255); + if (!validImageMask.empty()) cv::bitwise_and(global_mask, validImageMask, global_mask); out.global = measure(gray, global_mask, 0.0, 1, 1, c); if (!hasUsableRegion) { out.target.status = MeterStatus::NoTarget; return out; } cv::Mat target_mask(gray.size(), CV_8UC1, cv::Scalar{0}); if (!unionRoi(roi, target_mask)) { out.target.status = MeterStatus::InvalidRoi; return out; } + if (!validImageMask.empty()) cv::bitwise_and(target_mask, validImageMask, target_mask); out.target = measure(gray, target_mask, c.trim_fraction_each_side, c.min_target_pixels, c.min_retained_pixels, c); return out; diff --git a/tests/installed_consumer/main.cpp b/tests/installed_consumer/main.cpp index 180dbe9..cfcf29d 100644 --- a/tests/installed_consumer/main.cpp +++ b/tests/installed_consumer/main.cpp @@ -1,14 +1,25 @@ #include +#include #include int main() { using namespace target_exposure; + if(std::string(kVersion)!="1.3.0") return 9; const cv::Mat offlineImage(8,8,CV_8UC1,cv::Scalar(123)); Roi offlineRoi; offlineRoi.rectangles.emplace_back(0,0,8,8); const auto offline=measureImage(offlineImage,offlineRoi,true); if(!offline.target.valid() || offline.target.arithmetic_gray!=123.0 || offline.global.arithmetic_gray!=123.0) return 4; const auto globalOnly=measureImage(offlineImage,{},false); if(globalOnly.target.status!=MeterStatus::NoTarget || !globalOnly.global.valid()) return 5; + MeteringConfig preciseConfig; preciseConfig.min_target_pixels=1; preciseConfig.min_retained_pixels=1; + const cv::Mat source16=(cv::Mat_(1,4)<<0,1000,1001,65535); + const cv::Mat validMask=(cv::Mat_(1,4)<<0,1,255,0); + Roi preciseRoi; preciseRoi.rectangles={{0,0,3,1},{1,0,3,1}}; + const auto precise=measureImage(source16,preciseRoi,true,preciseConfig,validMask); + if(!precise.target.valid() || precise.global.pixels!=2 || precise.target.pixels!=2 || + precise.global.source_arithmetic_gray!=1000.5 || precise.target.source_arithmetic_gray!=1000.5 || + precise.target.source_bit_depth!=16 || precise.target.source_max_gray!=65535.0 || + std::abs(*precise.target.arithmetic_gray-1000.5*255/65535)>1e-12) return 8; Config c; c.min_exposure_us = 100; c.max_exposure_us = 20'000; c.empirical_exposure_us = 1'000; c.detect_confirm_frames = 1; @@ -24,5 +35,17 @@ int main() { if (!result.accepted || result.state != State::TRACK) return 2; if (t == 2'000'000 && (!result.adjustment_suggested || *result.suggested_exposure_us != 1'250)) return 3; } - std::cout << "Installed package consumer passed; stateless metering and control; exposure unit us.\n"; + ContinuousConfig continuousConfig; + continuousConfig.min_exposure_us = 100; continuousConfig.max_exposure_us = 20000; + ContinuousExposureControl continuous; + ContinuousFrameInput input; + input.metering = measureImage(cv::Mat(8, 8, CV_8UC1, cv::Scalar(20)), {}, false); + input.source = FeedbackSource::YoloRegions; + input.actual_exposure_us = 1000; + const auto decision = continuous.update(input, continuousConfig); + if (!decision.accepted || decision.effective_source != FeedbackSource::Global || + 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"; } diff --git a/tests/tests.cpp b/tests/tests.cpp index 481cee2..633416e 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -146,7 +146,7 @@ TEST(metering, explicit_inset_and_unsupported_image) { const auto shrunk=inset(cv::Rect{0,0,10,10},.1); CHECK(shrunk==cv::Rect(1,1,8,8)); Fixture x; auto f=imageFrame(0); f.roi.rectangles={{0,0,10,10}}; CHECK(x.send(f).metering.target.pixels==100); - f.timestamp_us++; f.image=cv::Mat(10,10,CV_16UC1,cv::Scalar(100)); + f.timestamp_us++; f.image=cv::Mat(10,10,CV_16UC3,cv::Scalar(100,100,100)); auto r=x.send(f); CHECK(r.metering.target.status==MeterStatus::UnsupportedImage && !r.adjustment_suggested); f.timestamp_us++; f.image.release(); r=x.send(f); CHECK(r.metering.global.status==MeterStatus::EmptyImage); } @@ -219,7 +219,7 @@ TEST(stateless, invalid_roi_and_images_keep_explicit_missing_values) { CHECK(measureImage(image,roi,true).target.status==MeterStatus::InvalidRoi); r=measureImage({},roi,true); CHECK(r.global.status==MeterStatus::EmptyImage && !r.global.arithmetic_gray); - r=measureImage(cv::Mat(8,8,CV_16UC1),roi,true); + r=measureImage(cv::Mat(8,8,CV_16UC3),roi,true); CHECK(r.global.status==MeterStatus::UnsupportedImage && !r.global.arithmetic_gray); const int sizes[]={2,2,2}; r=measureImage(cv::Mat(3,sizes,CV_8UC1,cv::Scalar(20)),roi,true); @@ -283,6 +283,114 @@ TEST(stateless, repeats_are_deterministic_and_leave_controller_trajectory_unchan } } +TEST(stateless, gray16_preserves_source_precision_and_normalizes_after_statistics) { + MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=.25; + // 1000 and 1001 must not be rounded to the same 8-bit intensity before averaging. + const cv::Mat image=(cv::Mat_(1,4)<<0,1000,1001,65535); + Roi roi; roi.rectangles={{0,0,4,1}}; + const auto r=measureImage(image,roi,true,c); + CHECK(r.target.valid() && r.global.valid()); + CHECK(r.target.source_bit_depth==16 && r.global.source_max_gray==65535.0); + NEAR(*r.target.source_arithmetic_gray,16884.0,0); + NEAR(*r.global.source_arithmetic_gray,16884.0,0); + NEAR(*r.target.arithmetic_gray,16884.0*255/65535,1e-12); + NEAR(*r.target.gray,1000.5*255/65535,1e-12); + CHECK(r.target.retained_pixels==2 && r.target.pixels==4); + NEAR(r.target.near_black_fraction,.25,0); + NEAR(r.target.saturated_fraction,.25,0); + CHECK(image.at(0,2)==1001); +} +TEST(stateless, gray16_threshold_boundaries_black_saturation_and_stride) { + MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=0; + cv::Mat padded(4,8,CV_16UC1,cv::Scalar(9999)); + cv::Mat image=padded(cv::Rect{1,1,6,2}); + const unsigned short samples[]={0,514,515,65020,65021,65535}; + for(int y=0;y(y,x)=samples[x]; + Roi roi; roi.rectangles={{0,0,6,2}}; + const auto r=measureImage(image,roi,true,c); + NEAR(r.target.near_black_fraction,1.0/3,0); + NEAR(r.target.saturated_fraction,1.0/3,0); + NEAR(*r.target.source_arithmetic_gray,196605.0/6,0); + CHECK(padded.at(0,0)==9999 && !image.isContinuous()); + image.setTo(0); const auto black=measureImage(image,{},false); + CHECK(black.target.status==MeterStatus::NoTarget && black.target.source_bit_depth==16); + NEAR(*black.global.source_arithmetic_gray,0,0); NEAR(black.global.near_black_fraction,1,0); + image.setTo(65535); const auto bright=measureImage(image,roi,true,c); + NEAR(*bright.target.gray,255,0); NEAR(*bright.target.source_arithmetic_gray,65535,0); + NEAR(bright.target.saturated_fraction,1,0); +} +TEST(stateless, valid_image_mask_intersects_union_and_global_without_mutation) { + MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=0; + const cv::Mat image8=(cv::Mat_(2,4)<<0,10,20,30,40,50,60,255); + const cv::Mat valid=(cv::Mat_(2,4)<<0,1,128,0,2,255,0,0); + Roi roi; roi.rectangles={{0,0,2,2},{1,0,2,1}}; + roi.rotated_rectangles.emplace_back(cv::Point2f{2,1},cv::Size2f{2,2},0.0f); + cv::Mat part(2,4,CV_8UC1,cv::Scalar(0)); part.at(1,1)=1; roi.masks={part,part}; + for(int depth : {CV_8U,CV_16U}) { + cv::Mat image; image8.convertTo(image,depth,depth==CV_16U?257:1); + const auto r=measureImage(image,roi,true,c,valid); + CHECK(r.global.pixels==4 && r.target.pixels==4); + NEAR(*r.global.arithmetic_gray,30,1e-12); NEAR(*r.target.arithmetic_gray,30,1e-12); + NEAR(*r.target.source_arithmetic_gray,depth==CV_16U?7710:30,0); + CHECK(cv::countNonZero(valid)==4 && valid.at(0,2)==128); + CHECK(cv::countNonZero(part)==1 && part.at(1,1)==1); + CHECK(image8.at(1,3)==255); + const auto repeat=measureImage(image,roi,true,c,valid); + CHECK(repeat.target.source_arithmetic_gray==r.target.source_arithmetic_gray); + } +} +TEST(stateless, valid_image_mask_keeps_target_independent_of_global_center) { + MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.global_center_fraction=.5; + cv::Mat image(4,4,CV_8UC1,cv::Scalar(200)); + image.at(1,1)=20; image.at(0,0)=80; + cv::Mat valid(4,4,CV_8UC1,cv::Scalar(0)); valid.at(1,1)=1; valid.at(0,0)=1; + Roi roi; roi.rectangles={{0,0,1,1}}; + const auto r=measureImage(image,roi,true,c,valid); + CHECK(r.global.pixels==1 && r.target.pixels==1); + NEAR(*r.global.arithmetic_gray,20,0); NEAR(*r.target.arithmetic_gray,80,0); + CHECK(r.target.source_bit_depth==8 && r.target.source_max_gray==255.0); +} +TEST(stateless, invalid_valid_image_masks_and_empty_intersections_are_explicit) { + const cv::Mat image(8,8,CV_16UC1,cv::Scalar(1001)); Roi roi; roi.rectangles={{0,0,4,8}}; + const int sizes[]={2,4,8}; + const std::vector invalid={cv::Mat(7,8,CV_8UC1,cv::Scalar(1)), + cv::Mat(8,8,CV_16UC1,cv::Scalar(1)),cv::Mat(8,8,CV_8UC3,cv::Scalar(1,1,1)), + cv::Mat(3,sizes,CV_8UC1,cv::Scalar(1))}; + for(const auto& mask:invalid) { + const auto r=measureImage(image,roi,true,{},mask); + CHECK(r.target.status==MeterStatus::InvalidRoi && r.global.status==MeterStatus::InvalidRoi); + CHECK(!r.target.arithmetic_gray && !r.global.source_arithmetic_gray); + const auto noTarget=measureImage(image,{},false,{},mask); + CHECK(noTarget.target.status==MeterStatus::NoTarget && noTarget.global.status==MeterStatus::InvalidRoi); + } + cv::Mat valid(8,8,CV_8UC1,cv::Scalar(0)); + auto r=measureImage(image,roi,true,{},valid); + CHECK(r.global.status==MeterStatus::EmptyRoi && r.target.status==MeterStatus::EmptyRoi); + CHECK(!r.global.source_arithmetic_gray && r.global.pixels==0 && r.global.source_bit_depth==16); + valid(cv::Rect{4,0,4,8}).setTo(255); r=measureImage(image,roi,true,{},valid); + CHECK(r.global.valid() && r.target.status==MeterStatus::EmptyRoi); + NEAR(*r.global.source_arithmetic_gray,1001,0); + valid.setTo(0); valid.at(0,0)=1; r=measureImage(image,roi,true,{},valid); + CHECK(r.global.valid() && r.target.status==MeterStatus::InsufficientPixels); + CHECK(!r.target.source_arithmetic_gray && !r.target.arithmetic_gray); + const auto defaultMask=measureImage(image,roi,true); + const auto explicitEmptyMask=measureImage(image,roi,true,{},{}); + CHECK(defaultMask.global.pixels==64 && defaultMask.global.source_arithmetic_gray==explicitEmptyMask.global.source_arithmetic_gray); +} +TEST(stateless, gray16_and_gray8_equivalent_scale_preserves_control_trajectory) { + Fixture a,b; + for(int gray : {118,80,40,200,118}) { + auto frame8=imageFrame(a.time+1'000'000,gray); + auto frame16=frame8; + frame8.image.convertTo(frame16.image,CV_16U,257); + const auto r8=a.send(frame8), r16=b.send(frame16); + CHECK(r8.state==r16.state && r8.reason==r16.reason); + NEAR(*r8.metering.target.gray,*r16.metering.target.gray,1e-12); + CHECK(r8.adjustment_suggested==r16.adjustment_suggested); + if(r8.suggested_exposure_us) NEAR(*r8.suggested_exposure_us,*r16.suggested_exposure_us,1e-10); + } +} + TEST(history, window_inclusive_boundary_and_filtering) { Fixture x; x.c.history_window_us=3'000'000; x.c.idle_timeout_us=100'000'000; x.at(0); x.at(1'000'000); x.at(3'000'000); @@ -626,6 +734,179 @@ TEST(config, independent_instances_do_not_share_state) { CHECK(b.module.state()==State::CAPTURE && b.module.history().empty()); CHECK(b.module.preCapture(0,b.c).initialization_required); } + +ContinuousFrameInput feedbackFrame(TimestampUs time, int globalGray = 80, + FeedbackSource source = FeedbackSource::Global, + std::optional regionGray = {}, double actual = 1000) { + ContinuousFrameInput input; + input.timestamp_us = time; input.actual_exposure_us = actual; input.source = source; + input.metering = measureImage(cv::Mat(10, 10, CV_8UC1, cv::Scalar(globalGray)), {}, false); + if (regionGray) { + Roi roi; roi.rectangles.emplace_back(0, 0, 10, 10); + input.metering.target = measureImage(cv::Mat(10, 10, CV_8UC1, cv::Scalar(*regionGray)), roi, true).target; + } + return input; +} +ContinuousConfig feedbackConfig() { + ContinuousConfig config; + config.min_exposure_us = 100; config.max_exposure_us = 10000; + config.region_controller.target_gray = config.global_controller.target_gray = 100; + return config; +} +TEST(continuous, no_target_feedback_converges_without_a_recovery_budget) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + double actual = 1000; int adjustments = 0; + ContinuousFrameResult result; + for (int i = 0; i < 40; ++i) { + const int gray = std::clamp(static_cast(std::round(actual * .02)), 0, 255); + result = control.update(feedbackFrame(i, gray, FeedbackSource::YoloRegions, {}, actual), c); + CHECK(result.accepted && result.effective_source == FeedbackSource::Global); + CHECK(result.metering.target.status == MeterStatus::NoTarget); + CHECK(result.fallback_reason == FeedbackFallbackReason::RegionUnavailable); + CHECK(std::abs(result.control.limited_delta_exposure_us) <= actual * .25 + 1e-9); + if (result.adjustment_suggested) ++adjustments; + actual = *result.suggested_exposure_us; + } + CHECK(adjustments > 3); + CHECK(result.state == FeedbackState::Holding && !result.adjustment_suggested); + CHECK(*result.control_gray >= 88 && *result.control_gray <= 112); +} +TEST(continuous, manual_and_yolo_regions_take_priority_then_fallback_explicitly) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + auto result = control.update(feedbackFrame(0, 0, FeedbackSource::ManualRoi, 100), c); + CHECK(result.effective_source == FeedbackSource::ManualRoi && result.reason == Reason::Deadband); + CHECK(result.fallback_reason == FeedbackFallbackReason::None && *result.control_gray == 100); + result = control.update(feedbackFrame(1, 255, FeedbackSource::YoloRegions, 20), c); + CHECK(result.effective_source == FeedbackSource::YoloRegions && result.adjustment_suggested); + CHECK(*result.suggested_exposure_us == 1250 && result.reset_reason == FeedbackResetReason::SourceChanged); + result = control.update(feedbackFrame(2, 100, FeedbackSource::YoloRegions), c); + CHECK(result.effective_source == FeedbackSource::Global && result.reason == Reason::Deadband); + CHECK(result.fallback_reason == FeedbackFallbackReason::RegionUnavailable); + auto invalid = feedbackFrame(3, 20, FeedbackSource::ManualRoi); + invalid.metering.target.status = MeterStatus::InvalidRoi; + result = control.update(invalid, c); + CHECK(result.accepted && result.fallback_reason == FeedbackFallbackReason::InvalidRegionMeasurement); + CHECK(result.effective_source == FeedbackSource::Global && !result.reset_reason); +} +TEST(continuous, source_changes_clear_integral_and_direction_count) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + CHECK(control.update(feedbackFrame(0, 80, FeedbackSource::ManualRoi, 80), c).reason == Reason::ConfirmingError); + auto result = control.update(feedbackFrame(1, 80, FeedbackSource::ManualRoi, 80), c); + NEAR(result.control.integral, .2, 1e-12); + result = control.update(feedbackFrame(2), c); + CHECK(result.reset_reason == FeedbackResetReason::SourceChanged && result.reason == Reason::ConfirmingError); + CHECK(result.control.integral == 0 && result.control.same_direction_count == 1); + result = control.update(feedbackFrame(3), c); + NEAR(result.control.integral, .2, 1e-12); + result = control.update(feedbackFrame(4, 80, FeedbackSource::YoloRegions, 80), c); + CHECK(result.reset_reason == FeedbackResetReason::SourceChanged && result.control.integral == 0); +} +TEST(continuous, global_reference_is_independent_of_region_reference) { + ContinuousExposureControl control; auto c = feedbackConfig(); + c.region_controller.target_gray = 180; c.global_controller.target_gray = 100; + auto result = control.update(feedbackFrame(0, 100, FeedbackSource::YoloRegions), c); + CHECK(result.target_gray == 100 && result.reason == Reason::Deadband); + result = control.update(feedbackFrame(1, 100, FeedbackSource::YoloRegions, 100), c); + CHECK(result.target_gray == 180 && result.reason == Reason::ProportionalAdjustment); +} +TEST(continuous, black_saturation_step_limits_and_exposure_limits_are_traced) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + auto result = control.update(feedbackFrame(0, 0), c); + CHECK(result.state == FeedbackState::Adjusting && result.control.step_limited); + NEAR(result.control.raw_delta_exposure_us, 700, 1e-10); + NEAR(result.control.limited_delta_exposure_us, 250, 0); + CHECK(result.metering.global.near_black_fraction == 1); + result = control.update(feedbackFrame(1, 255), c); + CHECK(*result.suggested_exposure_us == 750 && result.metering.global.saturated_fraction == 1); + CHECK(result.control.current_error_ratio == result.control.control_error_ratio); + result = control.update(feedbackFrame(2, 0, FeedbackSource::Global, {}, 10000), c); + CHECK(result.state == FeedbackState::Limited && result.reason == Reason::ExposureLimit); + CHECK(!result.adjustment_suggested && result.control.exposure_limited); + result = control.update(feedbackFrame(3, 255, FeedbackSource::Global, {}, 100), c); + CHECK(result.state == FeedbackState::Limited && *result.suggested_exposure_us == 100); +} +TEST(continuous, new_config_and_idle_interval_reset_controller) { + ContinuousExposureControl control; auto c = feedbackConfig(); + CHECK(control.update(feedbackFrame(0), c).reset_reason == FeedbackResetReason::Startup); + NEAR(control.update(feedbackFrame(1), c).control.integral, .2, 1e-12); + c.global_controller.kp = .3; + auto result = control.update(feedbackFrame(2), c); + CHECK(result.reset_reason == FeedbackResetReason::ConfigurationChanged && result.control.integral == 0); + result = control.update(feedbackFrame(3), c); NEAR(result.control.integral, .2, 1e-12); + result = control.update(feedbackFrame(c.idle_timeout_us + 4), c); + CHECK(result.reset_reason == FeedbackResetReason::IdleTimeout && result.control.integral == 0); + CHECK(result.reason == Reason::ConfirmingError); +} +TEST(continuous, duplicates_out_of_order_and_manual_reset_keep_timestamp_watermark) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + control.update(feedbackFrame(10), c); + auto result = control.update(feedbackFrame(10), c); + CHECK(!result.accepted && result.reason == Reason::DuplicateFrame && !result.suggested_exposure_us); + CHECK(result.control.same_direction_count == 1); + CHECK(control.update(feedbackFrame(9), c).reason == Reason::OutOfOrderFrame); + result = control.update(feedbackFrame(11), c); + CHECK(result.accepted && result.control.same_direction_count == 2); + control.reset(); + CHECK(control.update(feedbackFrame(0), c).reason == Reason::OutOfOrderFrame); + result = control.update(feedbackFrame(12), c); + CHECK(result.reset_reason == FeedbackResetReason::Manual && result.control.integral == 0); + control.reset(FeedbackResetReason::ClockChanged); + result = control.update(feedbackFrame(0), c); + CHECK(result.accepted && result.reset_reason == FeedbackResetReason::ClockChanged); +} +TEST(continuous, invalid_feedback_is_rejected_without_consuming_frame_or_state) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + control.update(feedbackFrame(0), c); + auto bad = feedbackFrame(1); bad.metering.global.gray = std::numeric_limits::quiet_NaN(); + auto result = control.update(bad, c); + CHECK(!result.accepted && result.reason == Reason::InvalidMetering && !result.suggested_exposure_us); + CHECK(!result.effective_source && !result.errors.empty() && result.control.same_direction_count == 1); + bad = feedbackFrame(1); bad.metering.global.saturated_fraction = 1.5; + CHECK(!control.update(bad, c).accepted); + bad = feedbackFrame(1); bad.metering.global.pixels = 0; + CHECK(!control.update(bad, c).accepted); + bad = feedbackFrame(1); bad.metering.global.retained_pixels = 101; + CHECK(!control.update(bad, c).accepted); + result = control.update(feedbackFrame(1), c); + CHECK(result.accepted && result.control.same_direction_count == 2); + NEAR(result.control.integral, .2, 1e-12); +} +TEST(continuous, invalid_exposure_config_and_time_do_not_initialize) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + CHECK(control.update(feedbackFrame(-1), c).reason == Reason::InvalidTimestamp); + for (double actual : {0.0, 99.0, 10001.0, std::numeric_limits::quiet_NaN()}) { + const auto result = control.update(feedbackFrame(0, 80, FeedbackSource::Global, {}, actual), c); + CHECK(!result.accepted && result.reason == Reason::InvalidActualExposure && !result.suggested_exposure_us); + } + auto badConfig = c; badConfig.global_controller.target_gray = 0; + CHECK(control.update(feedbackFrame(0), badConfig).reason == Reason::InvalidConfiguration); + badConfig = c; badConfig.region_controller.max_step_ratio = 1; + CHECK(!validate(badConfig).empty()); + auto badInput = feedbackFrame(0); badInput.source = static_cast(100); + CHECK(control.update(badInput, c).reason == Reason::InvalidConfiguration); + const auto result = control.update(feedbackFrame(0), c); + CHECK(result.accepted && result.reset_reason == FeedbackResetReason::Startup); +} +TEST(continuous, actual_exposure_not_unapplied_previous_advice_is_used) { + ContinuousExposureControl control; const auto c = feedbackConfig(); + const auto first = control.update(feedbackFrame(0, 20), c); + const auto retry = control.update(feedbackFrame(1, 20), c); + NEAR(*first.suggested_exposure_us, 1250, 0); + NEAR(*retry.suggested_exposure_us, 1250, 0); + const auto applied = control.update(feedbackFrame(2, 20, FeedbackSource::Global, {}, 1250), c); + NEAR(*applied.suggested_exposure_us, 1562.5, 0); +} +TEST(continuous, anti_windup_and_independent_instances_are_preserved) { + ContinuousExposureControl a, b; auto c = feedbackConfig(); + c.global_controller.kp = 2; c.global_controller.ki = 1; + a.update(feedbackFrame(0), c); + const auto result = a.update(feedbackFrame(1), c); + CHECK(result.control.integral_blocked_by_limit && result.control.step_limited); + CHECK(result.control.integral == 0 && result.control.same_direction_count == 2); + const auto independent = b.update(feedbackFrame(0), c); + CHECK(independent.reset_reason == FeedbackResetReason::Startup); + CHECK(independent.reason == Reason::ConfirmingError && independent.control.same_direction_count == 1); +} } // namespace int main(int argc,char** argv) { diff --git a/实施记录.md b/实施记录.md index 7fa8750..098bc8f 100644 --- a/实施记录.md +++ b/实施记录.md @@ -1,5 +1,38 @@ # 目标测光曝光控制模块实施记录 +## 2026-09-22 现场光照分析测光扩展(1.3.0) + +批次 `daylight-metering-20260922` 为独立测光接入 Gray16 和有效图像掩膜,保留已有连续控制与原控制的策略行为。公开 `measureImage` 追加可选第五参数 `validImageMask`;原三/四参数源码调用继续可用。`Brightness` 追加原始普通均值、源位深和满量程,既有普通/截尾均值仍采用统一 0~255 标度。公开 `kVersion` 供分析输出记录库版本。 + +- Gray16 在源像素直方图上先计算精确普通/截尾均值,之后线性归一;不会先量化成 8 位。近黑/饱和阈值按统一标度映射到源像素,边界用整数比较。 +- 有效图像掩膜同时与全局及目标并集取交,非零即有效;格式错误、空交集、目标像素不足分别报告明确状态。默认无掩膜保持整图,不自动排除黑像素。 +- 新增 6 个独立测光逻辑用例,覆盖 16 位已知样本/小数精度/阈值边界/非连续存储、8 与 16 位混合区域掩膜交集、全局中央区与目标独立、非法与空掩膜、8 与 16 位同标度控制回归。安装消费工程直接验证 Gray16 和第五参数。 + +验证使用以下隔离命令,最终结果和文件指纹由 `.codex/delivery-integrity.json` 记录: + +```powershell +pwsh -NoProfile -File tools/verify.ps1 -BuildRoot E:/05_Temp/TargetExposureControl/daylight-analysis -OpenCVDir E:/06_EnvConfig/opencv/build/x64/vc16/lib -OpenCVRuntimeDir E:/06_EnvConfig/opencv/build/x64/vc16/bin +``` + +完整日志为 `E:/05_Temp/TargetExposureControl/daylight-analysis/verification.log`;上一批合同备份在同目录 `previous-delivery-integrity.json`。本批验收仅含软件测光、控制回归和安装接入,不涉及真实设备、一天现场数据的效果验收、曝光策略变更、提交或推送。 + +## 2026-09-22 连续在线反馈扩展(1.2.0) + +批次 `continuous-feedback-20260922` 面向 MatchVerify 的连续采集自动曝光应用。新增独立 `ContinuousExposureControl::update`,以已测光结果、显式来源、单调时间和真实曝光读回为输入,复用原比例/PI、死区、迟滞和限幅实现;原 `ExposureControl` 的预测、状态机和单次无目标恢复语义不变。 + +- 支持 Global、ManualRoi、YoloRegions;有效区域优先,无区域或无效区域明确回退全局,目标与全局参考独立可配。没有工件存在判断或伪造检测计数。 +- 连续无目标也可逐帧按全局亮度调节;实际来源切换、配置变化和闲置清积分。严格校验时间、真实曝光、测光数值/数量/比例,不把建议值当实际值。 +- 输出请求/实际来源、回退与重置原因、反馈状态、完整误差/积分/同向计数/限幅跟踪,以及原测光统计,供应用保存详细策略日志。 +- 新增 11 个连续反馈逻辑用例,覆盖模拟线性场景多帧收敛、手动/YOLO 优先及全局回退、积分切换、独立参考、黑图/饱和/边界、配置/闲置重置、时间水位、非法输入、真实反馈及抗积分饱和;安装消费工程直接使用公开连续 API。 + +验证命令与结果由 `.codex/delivery-integrity.json` 记录: + +```powershell +pwsh -NoProfile -File tools/verify.ps1 -BuildRoot E:/05_Temp/TargetExposureControl/online-auto-exposure -OpenCVDir E:/06_EnvConfig/opencv/build/x64/vc16/lib -OpenCVRuntimeDir E:/06_EnvConfig/opencv/build/x64/vc16/bin +``` + +隔离产物位于 `E:/05_Temp/TargetExposureControl/online-auto-exposure`,完整日志为 `verification.log`;上一批交付合同备份为同目录 `previous-delivery-integrity.json`。验收边界仅为软件、内存图像、模拟曝光响应与安装接入,不含真实相机曝光生效、光源控制或现场识别率;本批不提交、不推送。 + ## 2026-09-22 独立测光扩展(1.1.0) 批次 `stateless-metering-20260922` 配合 MatchVerify 的“采集存档 → 离线灰度统计”接入。库内只增加独立测光能力,没有相机、采图循环、文件读写或曝光控制策略变更;历史 v1 记录保留在下方。