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:
parent
86de01c649
commit
51e364997a
@ -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()
|
||||
|
||||
49
README.md
49
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)`。
|
||||
- 图像只在调用期间读取,不改原图、不保存图像副本;调用期间调用方须保证数据有效且不被其他线程修改。
|
||||
|
||||
@ -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`;配置、来源或闲置超时变化清反馈状态,拒绝帧不消费时间水位。曝光建议仍是库输出,调用方必须落实“成功写入 → 读回 → 下一帧用实际值反馈”,才能构成设备闭环;库内验证不代表相机已生效。
|
||||
|
||||
## 单次无目标恢复
|
||||
|
||||
从首次未检出开始,到连续检出达到配置数量结束,视为同一丢失过程。恢复机会独立于三态及初始化记录。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
110
src/continuous_control.cpp
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
285
tests/tests.cpp
285
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_<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) {
|
||||
|
||||
33
实施记录.md
33
实施记录.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 记录保留在下方。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user