feat: add continuous exposure control and native-depth metering

Add continuous global/ROI feedback with decision tracing, valid-image masks, and exact Gray16 source means alongside normalized metrics. Preserve legacy control behavior and expose the 1.3.0 API.

Validation: Release CTest 12/12 (70 cases), installed consumer 1/1, and diff checks passed. Software-only verification; no real device operation.
This commit is contained in:
Ben 2026-09-22 20:45:56 +08:00
parent 86de01c649
commit 51e364997a
11 changed files with 689 additions and 41 deletions

View File

@ -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()

View File

@ -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)`。
- 图像只在调用期间读取,不改原图、不保存图像副本;调用期间调用方须保证数据有效且不被其他线程修改。

View File

@ -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`;配置、来源或闲置超时变化清反馈状态,拒绝帧不消费时间水位。曝光建议仍是库输出,调用方必须落实“成功写入 → 读回 → 下一帧用实际值反馈”,才能构成设备闭环;库内验证不代表相机已生效。
## 单次无目标恢复
从首次未检出开始,到连续检出达到配置数量结束,视为同一丢失过程。恢复机会独立于三态及初始化记录。

View File

@ -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<double> arithmetic_gray;
// Ordinary mean in the source scale, computed before any normalization/quantization.
std::optional<double> 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> 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<std::string> 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<FeedbackSource> effective_source;
FeedbackFallbackReason fallback_reason = FeedbackFallbackReason::None;
std::optional<FeedbackResetReason> reset_reason;
double actual_exposure_us = 0.0;
std::optional<double> suggested_exposure_us;
bool adjustment_suggested = false;
double target_gray = 0.0;
std::optional<double> control_gray;
MeteringResult metering;
ControlTrace control;
std::vector<std::string> 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> impl_;
};
} // namespace target_exposure

View File

@ -3,6 +3,30 @@
#include <tuple>
namespace target_exposure {
std::vector<std::string> detail::validateController(const ControllerConfig& k) {
std::vector<std::string> 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<std::string> detail::validateMetering(const MeteringConfig& m) {
std::vector<std::string> errors;
const auto require = [&](bool ok, const char* name) { if (!ok) errors.emplace_back(name); };
@ -47,18 +71,8 @@ std::vector<std::string> 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<std::string> validate(const ContinuousConfig& c) {
std::vector<std::string> 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"; }

110
src/continuous_control.cpp Normal file
View File

@ -0,0 +1,110 @@
#include "internal.hpp"
#include <cmath>
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<ContinuousConfig> config;
std::optional<FeedbackSource> source;
std::optional<TimestampUs> watermark;
std::optional<FeedbackResetReason> pending_reset = FeedbackResetReason::Startup;
};
ContinuousExposureControl::ContinuousExposureControl() : impl_(std::make_unique<Impl>()) {}
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

View File

@ -4,6 +4,8 @@
namespace target_exposure::detail {
std::vector<std::string> validateMetering(const MeteringConfig& config);
std::vector<std::string> validateController(const ControllerConfig& config);
bool sameController(const ControllerConfig& a, const ControllerConfig& b);
struct History {
std::deque<HistoryRecord> records;
// Separate retention prevents many no-target frames evicting the newest valid targets.

View File

@ -1,7 +1,6 @@
#include "internal.hpp"
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <stdexcept>
@ -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<class Pixel>
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<std::size_t, 256> hist{};
constexpr int sourceMax = std::numeric_limits<Pixel>::max();
constexpr double scale = 255.0 / sourceMax;
out.source_bit_depth = static_cast<int>(sizeof(Pixel) * 8);
out.source_max_gray = sourceMax;
std::vector<std::size_t> hist(static_cast<std::size_t>(sourceMax) + 1, 0);
for (int y = 0; y < gray.rows; ++y) {
const auto* pixels = gray.ptr<unsigned char>(y);
const auto* pixels = gray.ptr<Pixel>(y);
const auto* selected = mask.ptr<unsigned char>(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<double>(i) * static_cast<double>(hist[static_cast<std::size_t>(i)]);
if (i <= c.near_black_level) black += hist[static_cast<std::size_t>(i)];
if (i >= c.saturation_level) saturated += hist[static_cast<std::size_t>(i)];
if (i * 255 <= c.near_black_level * sourceMax) black += hist[static_cast<std::size_t>(i)];
if (i * 255 >= c.saturation_level * sourceMax) saturated += hist[static_cast<std::size_t>(i)];
}
out.near_black_fraction = static_cast<double>(black) / static_cast<double>(out.pixels);
out.saturated_fraction = static_cast<double>(saturated) / static_cast<double>(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<double>(out.pixels);
out.source_arithmetic_gray = untrimmedSum / static_cast<double>(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<std::size_t>(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<double>(i) * static_cast<double>(hist[i]);
out.gray = sum / static_cast<double>(out.retained_pixels);
out.gray = (sum / static_cast<double>(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<unsigned short>(gray, mask, trim, min_pixels, min_retained, c);
return measureTyped<unsigned char>(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<int>(std::floor(gray.cols * c.global_center_fraction)));
const int height = std::max(1, static_cast<int>(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;

View File

@ -1,14 +1,25 @@
#include <target_exposure_control/exposure_control.hpp>
#include <cmath>
#include <iostream>
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_<unsigned short>(1,4)<<0,1000,1001,65535);
const cv::Mat validMask=(cv::Mat_<unsigned char>(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";
}

View File

@ -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_<unsigned short>(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<unsigned short>(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<image.rows;++y) for(int x=0;x<image.cols;++x) image.at<unsigned short>(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<unsigned short>(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_<unsigned char>(2,4)<<0,10,20,30,40,50,60,255);
const cv::Mat valid=(cv::Mat_<unsigned char>(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<unsigned char>(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<unsigned char>(0,2)==128);
CHECK(cv::countNonZero(part)==1 && part.at<unsigned char>(1,1)==1);
CHECK(image8.at<unsigned char>(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<unsigned char>(1,1)=20; image.at<unsigned char>(0,0)=80;
cv::Mat valid(4,4,CV_8UC1,cv::Scalar(0)); valid.at<unsigned char>(1,1)=1; valid.at<unsigned char>(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<cv::Mat> 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<unsigned char>(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<int> 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<int>(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<double>::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<double>::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<FeedbackSource>(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) {

View File

@ -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 记录保留在下方。