1265 lines
73 KiB
C++
1265 lines
73 KiB
C++
#include <target_exposure_control/exposure_control.hpp>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <functional>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
using namespace target_exposure;
|
|
namespace {
|
|
struct Case { std::string group, name; std::function<void()> run; };
|
|
std::vector<Case>& cases() { static std::vector<Case> value; return value; }
|
|
bool registerCase(const char* group, const char* name, std::function<void()> run) {
|
|
cases().push_back({group, name, std::move(run)}); return true;
|
|
}
|
|
void check(bool value, const char* expression, int line) {
|
|
if (!value) throw std::runtime_error("line " + std::to_string(line) + ": " + expression);
|
|
}
|
|
void near(double actual, double expected, double tolerance, int line) {
|
|
if (!std::isfinite(actual) || std::abs(actual - expected) > tolerance)
|
|
throw std::runtime_error("line " + std::to_string(line) + ": actual=" + std::to_string(actual) + ", expected=" + std::to_string(expected));
|
|
}
|
|
#define CHECK(expression) check(static_cast<bool>(expression), #expression, __LINE__)
|
|
#define NEAR(actual, expected, tolerance) near((actual), (expected), (tolerance), __LINE__)
|
|
#define TEST(group, name) void group##_##name(); const bool reg_##group##_##name = registerCase(#group, #name, group##_##name); void group##_##name()
|
|
|
|
FrameInput imageFrame(TimestampUs time, int gray = 100, bool target = true, double exposure = 1000) {
|
|
FrameInput f;
|
|
f.image = cv::Mat(10, 10, CV_8UC1, cv::Scalar(gray));
|
|
f.roi.rectangles.emplace_back(0, 0, 10, 10);
|
|
f.timestamp_us = time; f.actual_exposure_us = exposure; f.has_usable_target = target;
|
|
return f;
|
|
}
|
|
struct Fixture {
|
|
Config c;
|
|
ExposureControl module;
|
|
TimestampUs time = 0;
|
|
PreCaptureResult last_pre;
|
|
Fixture() {
|
|
c.min_exposure_us = 100; c.max_exposure_us = 10000; c.empirical_exposure_us = 1000;
|
|
c.metering.min_target_pixels = 1; c.metering.min_retained_pixels = 1;
|
|
c.metering.trim_fraction_each_side = 0;
|
|
c.detect_confirm_frames = 1;
|
|
c.prediction.enabled = false;
|
|
c.prediction.photometric_model_confirmed = true;
|
|
c.prediction.nominal_interval_us = 1'000'000;
|
|
}
|
|
FrameResult send(FrameInput f) {
|
|
time = f.timestamp_us;
|
|
last_pre = module.preCapture(time, c);
|
|
CHECK(last_pre.accepted);
|
|
auto result = module.processFrame(f);
|
|
CHECK(result.accepted);
|
|
return result;
|
|
}
|
|
FrameResult at(TimestampUs t, int gray = 100, bool target = true, double exposure = 1000) {
|
|
return send(imageFrame(t, gray, target, exposure));
|
|
}
|
|
FrameResult next(int gray = 100, bool target = true, double exposure = 1000) {
|
|
return at(time + 1'000'000, gray, target, exposure);
|
|
}
|
|
void track(int gray = 118) {
|
|
for (int i = 0; i < c.detect_confirm_frames; ++i) next(gray);
|
|
CHECK(module.state() == State::TRACK);
|
|
}
|
|
};
|
|
|
|
TEST(metering, rectangle_union_deduplicates_pixels) {
|
|
Fixture x;
|
|
auto f = imageFrame(0);
|
|
f.image = (cv::Mat_<unsigned char>(1,4) << 10,100,30,200);
|
|
f.roi.rectangles = {{0,0,2,1},{1,0,2,1}};
|
|
const auto r = x.send(f);
|
|
CHECK(r.metering.target.pixels == 3);
|
|
NEAR(*r.metering.target.gray, 140.0/3, 1e-10);
|
|
NEAR(*r.metering.global.gray, 85, 1e-10);
|
|
CHECK(f.image.at<unsigned char>(0,1) == 100);
|
|
}
|
|
TEST(metering, disjoint_regions_do_not_use_enclosing_box) {
|
|
Fixture x;
|
|
auto f = imageFrame(0);
|
|
f.image = (cv::Mat_<unsigned char>(1,4) << 10,100,30,200);
|
|
f.roi.rectangles = {{0,0,1,1},{3,0,1,1}};
|
|
const auto r = x.send(f);
|
|
CHECK(r.metering.target.pixels == 2);
|
|
NEAR(*r.metering.target.gray, 105, 1e-10);
|
|
}
|
|
TEST(metering, rotated_mask_and_rectangle_union) {
|
|
Fixture x;
|
|
auto f = imageFrame(0);
|
|
f.image = cv::Mat(4,4,CV_8UC1,cv::Scalar(255));
|
|
f.image.at<unsigned char>(1,1)=20; f.image.at<unsigned char>(2,2)=60; f.image.at<unsigned char>(0,3)=100;
|
|
f.roi.rectangles = {{1,1,1,1}};
|
|
f.roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{3,1},45.0f);
|
|
cv::Mat mask(4,4,CV_8UC1,cv::Scalar(0));
|
|
mask.at<unsigned char>(2,2)=1; mask.at<unsigned char>(0,3)=128;
|
|
f.roi.masks = {mask,mask};
|
|
const auto r=x.send(f);
|
|
CHECK(r.metering.target.pixels==3);
|
|
NEAR(*r.metering.target.gray,60,1e-10);
|
|
}
|
|
TEST(metering, trim_removes_both_five_percent_tails) {
|
|
Fixture x; x.c.metering.trim_fraction_each_side=.05;
|
|
auto f=imageFrame(0); f.image=cv::Mat(1,20,CV_8UC1,cv::Scalar(100));
|
|
f.image.at<unsigned char>(0,0)=0; f.image.at<unsigned char>(0,19)=255;
|
|
f.roi.rectangles={{0,0,20,1}};
|
|
const auto r=x.send(f);
|
|
CHECK(r.metering.target.pixels==20 && r.metering.target.retained_pixels==18);
|
|
NEAR(*r.metering.target.gray,100,1e-10);
|
|
NEAR(*r.metering.global.gray,102.75,1e-10);
|
|
}
|
|
TEST(metering, clipping_empty_invalid_and_insufficient) {
|
|
Fixture x; auto f=imageFrame(0); f.roi.rectangles={{-1,-1,2,2}};
|
|
auto r=x.send(f); CHECK(r.metering.target.pixels==1);
|
|
f.timestamp_us++; f.roi.rectangles={{50,50,1,1}};
|
|
r=x.send(f); CHECK(r.metering.target.status==MeterStatus::EmptyRoi && !r.metering.target.gray);
|
|
f.timestamp_us++; f.roi.rectangles.clear();
|
|
r=x.send(f); CHECK(r.metering.target.status==MeterStatus::EmptyRoi);
|
|
f.timestamp_us++; f.roi.rectangles={{0,0,-1,1}};
|
|
r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InvalidRoi);
|
|
f.timestamp_us++; f.roi.rectangles={{0,0,10,10}}; f.roi.masks={cv::Mat(1,1,CV_8UC1)};
|
|
r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InvalidRoi);
|
|
x.c.metering.min_target_pixels=101; f.timestamp_us++; f.roi.masks.clear();
|
|
r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InsufficientPixels);
|
|
CHECK(!r.adjustment_suggested && !r.metering.target.gray);
|
|
}
|
|
TEST(metering, target_flag_and_real_zero_are_distinct) {
|
|
Fixture x;
|
|
auto r=x.next(0,false); CHECK(r.metering.target.status==MeterStatus::NoTarget && !r.metering.target.gray);
|
|
CHECK(r.valid_history_count==0 && r.metering.global.valid());
|
|
r=x.next(0,true); CHECK(r.metering.target.valid()); NEAR(*r.metering.target.gray,0,0);
|
|
CHECK(r.valid_history_count==1);
|
|
}
|
|
TEST(metering, bgr_strides_and_fixed_central_global_region) {
|
|
Fixture x;
|
|
auto f=imageFrame(0); cv::Mat padded(6,6,CV_8UC3,cv::Scalar(255,0,0));
|
|
f.image=padded(cv::Rect{1,1,4,4}); f.roi.rectangles={{0,0,4,4}};
|
|
auto r=x.send(f); NEAR(*r.metering.target.gray,29,0);
|
|
x.c.metering.global_center_fraction=.5;
|
|
f=imageFrame(1); f.image.setTo(200); f.image(cv::Rect{2,2,5,5}).setTo(80);
|
|
r=x.send(f); NEAR(*r.metering.global.gray,80,0); CHECK(r.metering.global.pixels==25);
|
|
}
|
|
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_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);
|
|
}
|
|
TEST(metering, retained_sample_limit_and_extreme_coordinates) {
|
|
Fixture x; x.c.metering.trim_fraction_each_side=.05; x.c.metering.min_retained_pixels=100;
|
|
auto f=imageFrame(0); auto r=x.send(f);
|
|
CHECK(r.metering.target.retained_pixels==90 && r.metering.target.status==MeterStatus::InsufficientPixels);
|
|
f.timestamp_us++; f.roi.rectangles={{std::numeric_limits<int>::max(),0,std::numeric_limits<int>::max(),1}};
|
|
r=x.send(f); CHECK(r.metering.target.status==MeterStatus::EmptyRoi);
|
|
f.timestamp_us++; f.roi.rectangles.clear();
|
|
f.roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{3,1},std::numeric_limits<float>::quiet_NaN());
|
|
r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InvalidRoi);
|
|
}
|
|
|
|
TEST(stateless, ordinary_and_trimmed_means_share_known_samples) {
|
|
cv::Mat image(1,20,CV_8UC1,cv::Scalar(100));
|
|
image.at<unsigned char>(0,0)=0; image.at<unsigned char>(0,19)=255;
|
|
Roi roi; roi.rectangles={{0,0,20,1}};
|
|
const auto r=measureImage(image,roi,true);
|
|
CHECK(r.target.valid() && r.global.valid());
|
|
CHECK(r.target.pixels==20 && r.target.retained_pixels==18);
|
|
NEAR(*r.target.arithmetic_gray,102.75,0);
|
|
NEAR(*r.target.gray,100,0);
|
|
NEAR(*r.global.arithmetic_gray,102.75,0);
|
|
NEAR(*r.global.gray,102.75,0);
|
|
NEAR(r.target.near_black_fraction,.05,0);
|
|
NEAR(r.target.saturated_fraction,.05,0);
|
|
CHECK(image.at<unsigned char>(0,0)==0 && image.at<unsigned char>(0,19)==255);
|
|
}
|
|
TEST(stateless, roi_union_mixed_shapes_and_masks) {
|
|
MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1;
|
|
cv::Mat image(4,4,CV_8UC1,cv::Scalar(255));
|
|
image.at<unsigned char>(1,1)=20; image.at<unsigned char>(2,2)=60; image.at<unsigned char>(0,3)=100;
|
|
Roi roi; roi.rectangles={{1,1,1,1},{1,1,1,1}};
|
|
roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{3,1},45.0f);
|
|
cv::Mat mask(4,4,CV_8UC1,cv::Scalar(0));
|
|
mask.at<unsigned char>(2,2)=1; mask.at<unsigned char>(0,3)=128;
|
|
roi.masks={mask,mask};
|
|
const auto r=measureImage(image,roi,true,c);
|
|
CHECK(r.target.pixels==3 && r.target.valid());
|
|
NEAR(*r.target.arithmetic_gray,60,0); NEAR(*r.target.gray,60,0);
|
|
NEAR(*r.global.arithmetic_gray,(180.0+13*255)/16,0);
|
|
CHECK(cv::countNonZero(mask)==2 && mask.at<unsigned char>(0,3)==128);
|
|
}
|
|
TEST(stateless, absent_region_is_distinct_from_black_and_saturated) {
|
|
cv::Mat image(8,8,CV_8UC1,cv::Scalar(0)); Roi roi; roi.rectangles={{0,0,8,8}};
|
|
auto r=measureImage(image,roi,false);
|
|
CHECK(r.target.status==MeterStatus::NoTarget && !r.target.arithmetic_gray);
|
|
CHECK(r.global.valid()); NEAR(*r.global.arithmetic_gray,0,0);
|
|
r=measureImage(image,roi,true);
|
|
CHECK(r.target.valid()); NEAR(*r.target.arithmetic_gray,0,0);
|
|
NEAR(r.target.near_black_fraction,1,0);
|
|
image.setTo(255); r=measureImage(image,roi,true);
|
|
CHECK(r.target.valid()); NEAR(*r.target.arithmetic_gray,255,0);
|
|
NEAR(r.target.saturated_fraction,1,0);
|
|
}
|
|
TEST(stateless, invalid_roi_and_images_keep_explicit_missing_values) {
|
|
const cv::Mat image(8,8,CV_8UC1,cv::Scalar(90)); Roi roi;
|
|
auto r=measureImage(image,roi,true);
|
|
CHECK(r.target.status==MeterStatus::EmptyRoi && !r.target.arithmetic_gray && r.global.valid());
|
|
roi.rectangles={{0,0,1,1}}; r=measureImage(image,roi,true);
|
|
CHECK(r.target.status==MeterStatus::InsufficientPixels && !r.target.arithmetic_gray);
|
|
roi.rectangles={{0,0,-1,1}}; r=measureImage(image,roi,true);
|
|
CHECK(r.target.status==MeterStatus::InvalidRoi && r.global.valid());
|
|
roi.rectangles.clear(); roi.masks={cv::Mat(1,1,CV_8UC1,cv::Scalar(1))};
|
|
r=measureImage(image,roi,true); CHECK(r.target.status==MeterStatus::InvalidRoi && !r.target.arithmetic_gray);
|
|
roi.masks={cv::Mat(8,8,CV_32FC1,cv::Scalar(1))};
|
|
CHECK(measureImage(image,roi,true).target.status==MeterStatus::InvalidRoi);
|
|
roi.masks.clear(); roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{2,2},std::numeric_limits<float>::infinity());
|
|
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_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);
|
|
CHECK(r.global.status==MeterStatus::UnsupportedImage);
|
|
}
|
|
TEST(stateless, invalid_config_is_rejected_before_image_or_roi_access) {
|
|
const std::vector<std::function<void(MeteringConfig&)>> corrupt={
|
|
[](MeteringConfig& c){c.trim_fraction_each_side=-.1;},
|
|
[](MeteringConfig& c){c.trim_fraction_each_side=.5;},
|
|
[](MeteringConfig& c){c.trim_fraction_each_side=std::numeric_limits<double>::quiet_NaN();},
|
|
[](MeteringConfig& c){c.min_target_pixels=0;},
|
|
[](MeteringConfig& c){c.min_retained_pixels=0;},
|
|
[](MeteringConfig& c){c.global_center_fraction=0;},
|
|
[](MeteringConfig& c){c.global_center_fraction=1.1;},
|
|
[](MeteringConfig& c){c.global_center_fraction=std::numeric_limits<double>::infinity();},
|
|
[](MeteringConfig& c){c.near_black_level=-1;},
|
|
[](MeteringConfig& c){c.saturation_level=256;},
|
|
[](MeteringConfig& c){c.saturation_level=c.near_black_level;}
|
|
};
|
|
for(const auto& mutate:corrupt) {
|
|
MeteringConfig c; mutate(c);
|
|
const auto r=measureImage({}, {}, false, c);
|
|
CHECK(r.target.status==MeterStatus::InvalidConfiguration && r.global.status==MeterStatus::InvalidConfiguration);
|
|
CHECK(!r.target.gray && !r.target.arithmetic_gray && !r.global.arithmetic_gray);
|
|
Fixture x; x.c.metering=c; CHECK(!validate(x.c).empty());
|
|
}
|
|
CHECK(std::string(toString(MeterStatus::InvalidConfiguration))=="InvalidConfiguration");
|
|
}
|
|
TEST(stateless, bgr_strides_and_global_center_are_supported_without_controller) {
|
|
cv::Mat padded(6,6,CV_8UC3,cv::Scalar(255,0,0));
|
|
const cv::Mat image=padded(cv::Rect{1,1,4,4}); Roi roi; roi.rectangles={{0,0,4,4}};
|
|
auto r=measureImage(image,roi,true);
|
|
CHECK(r.target.valid()); NEAR(*r.target.arithmetic_gray,29,0);
|
|
cv::Mat gray(10,10,CV_8UC1,cv::Scalar(200)); gray(cv::Rect{2,2,5,5}).setTo(80);
|
|
MeteringConfig c; c.global_center_fraction=.5;
|
|
r=measureImage(gray,{},false,c);
|
|
CHECK(r.global.pixels==25); NEAR(*r.global.arithmetic_gray,80,0);
|
|
}
|
|
TEST(stateless, repeats_are_deterministic_and_leave_controller_trajectory_unchanged) {
|
|
Fixture a,b; a.track(); b.track();
|
|
const auto frame=imageFrame(a.time+1'000'000,80);
|
|
const auto first=measureImage(frame.image,frame.roi,true,a.c.metering);
|
|
const auto history=a.module.history(); const auto state=a.module.state();
|
|
for(int i=0;i<8;++i) {
|
|
const auto r=measureImage(frame.image,frame.roi,true,a.c.metering);
|
|
CHECK(r.target.pixels==first.target.pixels && r.target.gray==first.target.gray &&
|
|
r.target.arithmetic_gray==first.target.arithmetic_gray && r.global.gray==first.global.gray);
|
|
measureImage(frame.image,{},false);
|
|
}
|
|
CHECK(a.module.state()==state && a.module.history().size()==history.size());
|
|
CHECK(a.module.history().back().timestamp_us==history.back().timestamp_us);
|
|
for(int i=0;i<3;++i) {
|
|
const auto actual=a.next(80), control=b.next(80);
|
|
CHECK(actual.state==control.state && actual.reason==control.reason &&
|
|
actual.suggested_exposure_us==control.suggested_exposure_us &&
|
|
actual.control.integral==control.control.integral &&
|
|
actual.control.same_direction_count==control.control.same_direction_count &&
|
|
actual.valid_history_count==control.valid_history_count);
|
|
CHECK(actual.metering.target.gray==first.target.gray &&
|
|
actual.metering.target.arithmetic_gray==first.target.arithmetic_gray);
|
|
}
|
|
}
|
|
|
|
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);
|
|
auto r=x.at(4'000'000); CHECK(r.valid_history_count==3);
|
|
CHECK(x.module.history().front().timestamp_us==1'000'000);
|
|
r=x.at(4'000'001,0); // zero gray is still a real valid target
|
|
CHECK(x.module.history().front().timestamp_us==3'000'000);
|
|
}
|
|
TEST(history, five_points_are_not_silently_replaced_by_three) {
|
|
Fixture x; x.c.prediction.enabled=true; x.c.prediction.points=5;
|
|
for(int i=0;i<4;++i) CHECK(!x.next(90+5*i).prediction.available);
|
|
const auto r=x.next(110); CHECK(r.prediction.available && r.prediction.points_used==5);
|
|
NEAR(*r.prediction.predicted_gray,115,1e-9);
|
|
}
|
|
TEST(history, invalid_frames_do_not_evict_recent_valid_targets) {
|
|
Fixture x; x.c.prediction.enabled=true; x.c.history_capacity=3;
|
|
x.next(); x.next(); x.next();
|
|
for(int i=0;i<10;++i) x.next(30,false);
|
|
const auto r=x.next(100,true);
|
|
CHECK(r.valid_history_count==3 && r.prediction.available);
|
|
CHECK(x.module.history().size()==3);
|
|
NEAR(*r.prediction.predicted_gray,100,1e-9);
|
|
}
|
|
TEST(history, actual_exposure_normalization_with_black_offset) {
|
|
Fixture x; x.c.prediction.enabled=true; x.c.prediction.black_level=10;
|
|
x.at(0,50,true,400); x.at(1'000'000,90,true,800);
|
|
const auto r=x.at(4'000'000,170,true,1600);
|
|
CHECK(r.prediction.available);
|
|
NEAR(*r.prediction.normalized_slope_per_second,0,1e-12);
|
|
NEAR(*r.prediction.predicted_gray,170,1e-9);
|
|
NEAR(x.module.history().back().actual_exposure_us,1600,0);
|
|
}
|
|
|
|
TEST(prediction, unequal_time_intervals_and_explicit_horizon) {
|
|
Fixture x; x.c.prediction.enabled=true;
|
|
x.at(0,100); x.at(1'000'000,110);
|
|
auto f=imageFrame(4'000'000,140); f.next_capture_timestamp_us=6'000'000;
|
|
const auto r=x.send(f); CHECK(r.prediction.available && r.prediction.adopted);
|
|
NEAR(*r.prediction.normalized_slope_per_second,.01,1e-12);
|
|
NEAR(*r.prediction.predicted_gray,160,1e-9);
|
|
}
|
|
TEST(prediction, large_timestamp_epoch_does_not_change_fit) {
|
|
Fixture x; x.c.prediction.enabled=true;
|
|
const TimestampUs epoch=1'800'000'000'000'000;
|
|
x.at(epoch,100); x.at(epoch+1'000'000,110);
|
|
auto f=imageFrame(epoch+4'000'000,140); f.next_capture_timestamp_us=epoch+6'000'000;
|
|
const auto r=x.send(f); CHECK(r.prediction.available);
|
|
NEAR(*r.prediction.predicted_gray,160,1e-9);
|
|
}
|
|
FrameResult trendResult(bool rising, bool enabled) {
|
|
Fixture x; x.c.prediction.enabled=enabled; x.c.controller.ki=0; x.c.controller.same_direction_frames=1;
|
|
x.at(0,rising?80:120); x.at(1'000'000,rising?90:110);
|
|
return x.at(2'000'000,100);
|
|
}
|
|
TEST(prediction, history_changes_exposure_and_is_not_double_compensated) {
|
|
const auto rise=trendResult(true,true), fall=trendResult(false,true);
|
|
CHECK(rise.prediction.adopted && fall.prediction.adopted);
|
|
NEAR(*rise.prediction.predicted_gray,110,1e-9);
|
|
NEAR(*fall.prediction.predicted_gray,90,1e-9);
|
|
CHECK(!rise.adjustment_suggested && fall.adjustment_suggested);
|
|
NEAR(*rise.suggested_exposure_us,1000,1e-9);
|
|
NEAR(*fall.suggested_exposure_us,1000*(1+.4*(118-90)/118.0),1e-9);
|
|
const auto no_rise=trendResult(true,false), no_fall=trendResult(false,false);
|
|
NEAR(*no_rise.suggested_exposure_us,*no_fall.suggested_exposure_us,1e-9);
|
|
NEAR(*no_fall.suggested_exposure_us,1000*(1+.4*(118-100)/118.0),1e-9);
|
|
}
|
|
TEST(prediction, insufficient_missing_horizon_and_model_fallback) {
|
|
Fixture x; x.c.prediction.enabled=true; x.c.prediction.nominal_interval_us=0;
|
|
CHECK(x.next(100).prediction.reason==PredictionReason::InsufficientHistory);
|
|
x.next(100); auto r=x.next(100); CHECK(r.prediction.reason==PredictionReason::MissingHorizon);
|
|
CHECK(!r.prediction.adopted && r.control.control_error_ratio.has_value());
|
|
x.c.prediction.photometric_model_confirmed=false;
|
|
r=x.next(100); CHECK(r.prediction.reason==PredictionReason::ModelNotConfirmed);
|
|
}
|
|
TEST(prediction, reject_clipping_bad_fit_and_excessive_horizon) {
|
|
Fixture x; x.c.prediction.enabled=true;
|
|
x.next(100); x.next(100); auto r=x.next(255);
|
|
CHECK(r.prediction.reason==PredictionReason::DarkOrSaturated && !r.prediction.adopted);
|
|
Fixture y; y.c.prediction.enabled=true; y.c.prediction.max_relative_fit_rmse=.01;
|
|
y.next(100); y.next(200); r=y.next(100);
|
|
CHECK(r.prediction.reason==PredictionReason::ResidualTooLarge && !r.prediction.adopted);
|
|
auto f=imageFrame(y.time+1'000'000,100); f.next_capture_timestamp_us=f.timestamp_us+61'000'000;
|
|
r=y.send(f); CHECK(r.prediction.reason==PredictionReason::InvalidHorizon);
|
|
Fixture z; z.c.prediction.enabled=true;
|
|
z.next(10); z.next(100); r=z.next(190);
|
|
CHECK(r.prediction.reason==PredictionReason::OutOfRange && !r.prediction.adopted);
|
|
}
|
|
|
|
TEST(controller, large_error_is_immediate_but_step_limited) {
|
|
Fixture x; x.track(); const auto r=x.next(40);
|
|
CHECK(r.reason==Reason::ProportionalAdjustment && r.adjustment_suggested);
|
|
NEAR(*r.suggested_exposure_us,1250,1e-9);
|
|
CHECK(r.control.step_limited && r.control.same_direction_count==1);
|
|
NEAR(r.control.integral,0,0);
|
|
}
|
|
TEST(controller, small_error_confirmation_pi_and_integral_limit) {
|
|
Fixture x; x.c.controller.target_gray=100; x.c.controller.integral_abs_limit=.3; x.track(100);
|
|
auto r=x.next(80); CHECK(r.reason==Reason::ConfirmingError); NEAR(r.control.integral,0,0);
|
|
r=x.next(80); NEAR(r.control.integral,.2,1e-12); NEAR(*r.suggested_exposure_us,1100,1e-9);
|
|
r=x.next(80); NEAR(r.control.integral,.3,1e-12); NEAR(*r.suggested_exposure_us,1110,1e-9);
|
|
r=x.next(80); NEAR(r.control.integral,.3,1e-12);
|
|
r=x.next(100); CHECK(r.reason==Reason::Deadband); NEAR(r.control.integral,0,0);
|
|
r=x.next(100,false); NEAR(r.control.integral,0,0);
|
|
}
|
|
TEST(controller, hysteresis_boundary_and_active_band) {
|
|
Fixture x; x.c.controller.target_gray=100; x.c.controller.same_direction_frames=1; x.track(100);
|
|
CHECK(x.next(89).reason==Reason::Hysteresis);
|
|
CHECK(x.next(88).adjustment_suggested);
|
|
CHECK(x.next(89).adjustment_suggested);
|
|
CHECK(x.next(91).reason==Reason::Deadband);
|
|
CHECK(x.next(89).reason==Reason::Hysteresis);
|
|
}
|
|
TEST(controller, opposite_direction_requires_new_confirmation) {
|
|
Fixture x; x.c.controller.target_gray=100; x.track(100);
|
|
CHECK(!x.next(80).adjustment_suggested);
|
|
auto r=x.next(120); CHECK(!r.adjustment_suggested && r.control.same_direction_count==1);
|
|
r=x.next(120); CHECK(r.adjustment_suggested && *r.suggested_exposure_us<1000);
|
|
}
|
|
TEST(controller, exposure_limits_and_step_limits_block_windup) {
|
|
Fixture x; x.c.controller.target_gray=100; x.c.controller.same_direction_frames=1;
|
|
x.c.max_exposure_us=1100; x.track(100);
|
|
auto r=x.next(80,true,1090); NEAR(*r.suggested_exposure_us,1100,1e-9);
|
|
CHECK(r.control.integral_blocked_by_limit && r.control.exposure_limited); NEAR(r.control.integral,0,0);
|
|
r=x.next(80,true,1100); CHECK(!r.adjustment_suggested && r.reason==Reason::ExposureLimit);
|
|
NEAR(r.control.integral,0,0);
|
|
Fixture y; y.c.controller.target_gray=100; y.c.controller.same_direction_frames=1;
|
|
y.c.controller.kp=1; y.c.controller.ki=1; y.track(100);
|
|
r=y.next(70); CHECK(r.control.integral_blocked_by_limit && r.control.step_limited); NEAR(r.control.integral,0,0);
|
|
NEAR(*r.suggested_exposure_us,1250,1e-9);
|
|
Fixture z; z.c.min_exposure_us=900; z.c.controller.target_gray=100; z.c.controller.same_direction_frames=1; z.track(100);
|
|
r=z.next(130,true,900); CHECK(!r.adjustment_suggested && r.control.integral_blocked_by_limit);
|
|
}
|
|
TEST(controller, track_ignores_global_background) {
|
|
const auto calculate=[](int background) {
|
|
Fixture x; x.c.controller.same_direction_frames=1; x.track();
|
|
auto f=imageFrame(x.time+1'000'000,background); f.roi.rectangles={{4,4,2,2}};
|
|
f.image(cv::Rect{4,4,2,2}).setTo(80); return x.send(f);
|
|
};
|
|
const auto a=calculate(0), b=calculate(240);
|
|
CHECK(*a.metering.global.gray!=*b.metering.global.gray);
|
|
NEAR(*a.suggested_exposure_us,*b.suggested_exposure_us,0);
|
|
}
|
|
|
|
TEST(states, capture_track_lost_freeze_and_capture) {
|
|
Fixture x; x.c.detect_confirm_frames=3;
|
|
CHECK(x.next().state==State::CAPTURE); CHECK(x.next().state==State::CAPTURE);
|
|
auto r=x.next(); CHECK(r.state==State::TRACK && !r.adjustment_suggested);
|
|
CHECK(x.next(30,false).state==State::TRACK); CHECK(x.next(30,false).state==State::TRACK);
|
|
r=x.next(30,false); CHECK(r.state==State::LOST && r.freeze_remaining==2);
|
|
r=x.next(30,false); CHECK(r.state==State::LOST && r.freeze_remaining==1);
|
|
r=x.next(30,false); CHECK(r.state==State::CAPTURE && r.freeze_remaining==0 && !r.adjustment_suggested);
|
|
}
|
|
TEST(states, short_loss_and_reacquisition_keep_exposure_and_history) {
|
|
Fixture x; x.c.detect_confirm_frames=3; x.track();
|
|
auto r=x.next(50,false,2345); CHECK(r.state==State::TRACK && !x.last_pre.initialization_required);
|
|
NEAR(*r.suggested_exposure_us,2345,0); CHECK(r.valid_history_count==3);
|
|
x.next(50,false); x.next(50,false); CHECK(x.module.state()==State::LOST);
|
|
CHECK(x.next(100).state==State::LOST); CHECK(x.next(100).state==State::LOST);
|
|
r=x.next(100); CHECK(r.state==State::TRACK && r.reason==Reason::TrackingActivated);
|
|
CHECK(!x.last_pre.initialization_required && r.valid_history_count==6);
|
|
}
|
|
TEST(states, bad_roi_is_not_a_detection_failure) {
|
|
Fixture x; x.track(); auto f=imageFrame(x.time+1'000'000); f.roi.rectangles.clear();
|
|
const auto r=x.send(f); CHECK(r.state==State::TRACK && r.loss_count==0);
|
|
CHECK(r.reason==Reason::InvalidMetering && !r.adjustment_suggested);
|
|
}
|
|
TEST(states, freeze_counts_new_frames_even_with_intermittent_detections) {
|
|
Fixture x; x.c.detect_confirm_frames=3; x.track();
|
|
x.next(20,false); x.next(20,false); x.next(20,false);
|
|
const auto r=x.next(100,true); CHECK(r.state==State::LOST && r.freeze_remaining==1);
|
|
CHECK(x.next(20,false).state==State::CAPTURE);
|
|
}
|
|
|
|
FrameResult missingWithPermission(Fixture& x,int gray=20) {
|
|
auto f=imageFrame(x.time+1'000'000,gray,false); f.allow_no_target_recovery=true; return x.send(f);
|
|
}
|
|
TEST(recovery, default_denial_and_single_recovery_per_episode) {
|
|
Fixture x; x.c.detect_confirm_frames=3; x.c.recovery.global_reference_gray=100;
|
|
auto r=x.next(20,false); CHECK(r.reason==Reason::RecoveryNotAllowed && !r.recovery_used);
|
|
r=missingWithPermission(x); CHECK(r.reason==Reason::RecoverySuggested && r.recovery_used);
|
|
NEAR(*r.suggested_exposure_us,1100,1e-9);
|
|
for(int i=0;i<10;++i) { r=missingWithPermission(x); CHECK(!r.adjustment_suggested && r.reason==Reason::NotRecovered); }
|
|
x.next(100,true); x.next(100,true); r=missingWithPermission(x);
|
|
CHECK(r.recovery_used && !r.adjustment_suggested);
|
|
x.next(100,true); x.next(100,true); r=x.next(100,true); CHECK(!r.recovery_used && !r.loss_episode_active);
|
|
}
|
|
TEST(recovery, freeze_finishes_before_recovery_and_no_scanning) {
|
|
Fixture x; x.c.recovery.global_reference_gray=100; x.track();
|
|
for(int i=0;i<3;++i) CHECK(!missingWithPermission(x).adjustment_suggested);
|
|
CHECK(x.module.state()==State::LOST);
|
|
for(int i=0;i<2;++i) CHECK(!missingWithPermission(x).adjustment_suggested);
|
|
CHECK(x.module.state()==State::CAPTURE);
|
|
CHECK(missingWithPermission(x).reason==Reason::RecoverySuggested);
|
|
CHECK(missingWithPermission(x).reason==Reason::NotRecovered);
|
|
}
|
|
TEST(recovery, automatic_initialization_and_manual_reset_do_not_rearm) {
|
|
Fixture x; x.c.detect_confirm_frames=3; x.c.recovery.global_reference_gray=100;
|
|
CHECK(missingWithPermission(x).recovery_used);
|
|
x.c.revision++; auto r=missingWithPermission(x);
|
|
CHECK(x.last_pre.initialization_required && r.recovery_used && !r.adjustment_suggested);
|
|
x.module.reset(ResetReason::Manual); r=missingWithPermission(x);
|
|
CHECK(r.recovery_used && !r.adjustment_suggested);
|
|
auto pre=x.module.preCapture(x.time+86'400'000'000LL,x.c);
|
|
CHECK(pre.initialization_required);
|
|
x.time+=86'400'000'000LL; r=missingWithPermission(x); CHECK(r.recovery_used && !r.adjustment_suggested);
|
|
x.module.reset(ResetReason::NewBusinessCycle); r=missingWithPermission(x);
|
|
CHECK(r.reason==Reason::RecoverySuggested);
|
|
}
|
|
TEST(recovery, missing_reference_normal_global_invalid_frame_and_limit) {
|
|
Fixture x; auto r=missingWithPermission(x); CHECK(r.reason==Reason::RecoveryReferenceMissing && !r.recovery_used);
|
|
x.c.recovery.global_reference_gray=100;
|
|
r=missingWithPermission(x,100); CHECK(r.reason==Reason::RecoveryNotNeeded && !r.recovery_used);
|
|
auto f=imageFrame(x.time+1'000'000,20,false); f.allow_no_target_recovery=true; f.image.release();
|
|
r=x.send(f); CHECK(r.reason==Reason::InvalidMetering && !r.recovery_used);
|
|
f=imageFrame(x.time+1'000'000,20,false,x.c.max_exposure_us); f.allow_no_target_recovery=true;
|
|
r=x.send(f); CHECK(r.reason==Reason::ExposureLimit && r.recovery_used && !r.adjustment_suggested);
|
|
CHECK(missingWithPermission(x).reason==Reason::NotRecovered);
|
|
}
|
|
TEST(recovery, history_expiry_cannot_refresh_consumed_opportunity) {
|
|
Fixture x; x.c.recovery.global_reference_gray=100; x.c.history_window_us=10'000'000;
|
|
x.c.idle_timeout_us=100'000'000; x.track();
|
|
for(int i=0;i<5;++i) missingWithPermission(x);
|
|
CHECK(missingWithPermission(x).reason==Reason::RecoverySuggested);
|
|
int initializations=0;
|
|
for(int i=0;i<20;++i) {
|
|
const auto r=missingWithPermission(x);
|
|
if(x.last_pre.initialization_required) {
|
|
++initializations; CHECK(x.last_pre.initialization_reason==ResetReason::HistoryExpired);
|
|
}
|
|
CHECK(r.recovery_used && !r.adjustment_suggested);
|
|
}
|
|
CHECK(initializations==1);
|
|
}
|
|
|
|
TEST(lifecycle, first_start_and_repeated_prechecks_initialize_once) {
|
|
Fixture x;
|
|
auto p=x.module.preCapture(0,x.c); CHECK(p.accepted && p.initialization_required && p.initialization_reason==ResetReason::Startup);
|
|
NEAR(*p.suggested_exposure_us,1000,0);
|
|
CHECK(!x.module.preCapture(0,x.c).initialization_required);
|
|
CHECK(!x.module.preCapture(900'000'000,x.c).initialization_required);
|
|
}
|
|
TEST(lifecycle, overnight_checks_before_capture_once) {
|
|
Fixture x; x.track();
|
|
const auto overnight=x.time+86'400'000'000LL;
|
|
auto p=x.module.preCapture(overnight,x.c);
|
|
CHECK(p.initialization_required && p.initialization_reason==ResetReason::IdleTimeout && p.state==State::CAPTURE);
|
|
CHECK(x.module.history().empty());
|
|
CHECK(!x.module.preCapture(overnight,x.c).initialization_required);
|
|
CHECK(!x.module.preCapture(overnight+1,x.c).initialization_required);
|
|
}
|
|
TEST(lifecycle, history_expiry_during_active_missing_frames_is_one_shot) {
|
|
Fixture x; x.c.history_window_us=5'000'000; x.c.idle_timeout_us=100'000'000;
|
|
x.at(0); x.at(2'000'000,20,false); x.at(4'000'000,20,false);
|
|
auto p=x.module.preCapture(6'000'000,x.c);
|
|
CHECK(p.initialization_required && p.initialization_reason==ResetReason::HistoryExpired);
|
|
CHECK(!x.module.preCapture(6'000'000,x.c).initialization_required);
|
|
CHECK(x.module.processFrame(imageFrame(6'000'000,20,false)).accepted);
|
|
for(TimestampUs t=8'000'000;t<30'000'000;t+=2'000'000) {
|
|
x.at(t,20,false); CHECK(!x.last_pre.initialization_required);
|
|
}
|
|
}
|
|
TEST(lifecycle, continuous_no_target_does_not_repeat_initialization) {
|
|
Fixture x;
|
|
for(int i=0;i<610;++i) {
|
|
x.at(static_cast<TimestampUs>(i)*1'000'000,20,false);
|
|
CHECK(x.last_pre.initialization_required==(i==0));
|
|
}
|
|
}
|
|
TEST(lifecycle, changed_values_or_revision_initialize_once) {
|
|
Fixture x; x.track(); x.c.controller.kp=.3;
|
|
auto p=x.module.preCapture(x.time+1,x.c); CHECK(p.initialization_reason==ResetReason::ConfigurationChanged);
|
|
CHECK(!x.module.preCapture(x.time+1,x.c).initialization_required);
|
|
x.c.revision++; p=x.module.preCapture(x.time+1,x.c); CHECK(p.initialization_required);
|
|
CHECK(!x.module.preCapture(x.time+1,x.c).initialization_required);
|
|
}
|
|
TEST(lifecycle, missing_or_stale_precheck_is_rejected) {
|
|
Fixture x;
|
|
CHECK(x.module.processFrame(imageFrame(0)).reason==Reason::PreCaptureRequired);
|
|
x.track();
|
|
auto r=x.module.processFrame(imageFrame(x.time+1'000'000)); CHECK(!r.accepted && r.reason==Reason::PreCaptureRequired);
|
|
CHECK(x.module.preCapture(x.time+1,x.c).accepted);
|
|
r=x.module.processFrame(imageFrame(x.time+301'000'000)); CHECK(!r.accepted && r.reason==Reason::PreCaptureRequired);
|
|
}
|
|
|
|
TEST(feedback, duplicates_and_out_of_order_do_not_mutate) {
|
|
Fixture x; x.c.controller.target_gray=100; x.track(100);
|
|
auto r=x.next(80); CHECK(r.control.same_direction_count==1);
|
|
const auto count=x.module.history().size();
|
|
r=x.module.processFrame(imageFrame(x.time,80)); CHECK(!r.accepted && r.reason==Reason::DuplicateFrame);
|
|
CHECK(r.control.same_direction_count==1 && !r.suggested_exposure_us);
|
|
r=x.module.processFrame(imageFrame(x.time-1,80)); CHECK(!r.accepted && r.reason==Reason::OutOfOrderFrame);
|
|
CHECK(x.module.history().size()==count && r.control.integral==0);
|
|
r=x.next(80); CHECK(r.adjustment_suggested && r.control.same_direction_count==2);
|
|
}
|
|
TEST(feedback, actual_exposure_not_previous_advice_is_used) {
|
|
Fixture x; x.track(); auto first=x.next(40,true,1000); auto second=x.next(40,true,1000);
|
|
NEAR(*first.suggested_exposure_us,1250,0); NEAR(*second.suggested_exposure_us,1250,0);
|
|
const auto applied=x.next(40,true,1250); NEAR(*applied.suggested_exposure_us,1562.5,0);
|
|
}
|
|
TEST(feedback, invalid_roi_holds_integral_and_breaks_confirmation) {
|
|
Fixture x; x.c.controller.target_gray=100; x.track(100); x.next(80); auto r=x.next(80);
|
|
NEAR(r.control.integral,.2,1e-12);
|
|
auto f=imageFrame(x.time+1'000'000,80); f.roi.rectangles.clear(); r=x.send(f);
|
|
CHECK(!r.adjustment_suggested && r.reason==Reason::InvalidMetering);
|
|
NEAR(r.control.integral,.2,1e-12); CHECK(r.control.same_direction_count==0);
|
|
CHECK(x.next(80).reason==Reason::ConfirmingError);
|
|
}
|
|
TEST(feedback, invalid_exposure_clock_change_and_reset) {
|
|
Fixture x; x.track(); const auto size=x.module.history().size();
|
|
CHECK(x.module.preCapture(x.time+1,x.c).accepted);
|
|
auto f=imageFrame(x.time+1); f.actual_exposure_us=std::numeric_limits<double>::quiet_NaN();
|
|
auto r=x.module.processFrame(f); CHECK(!r.accepted && r.reason==Reason::InvalidActualExposure);
|
|
CHECK(x.module.history().size()==size);
|
|
CHECK(!x.module.preCapture(x.time-1,x.c).accepted);
|
|
x.module.reset(ResetReason::ClockChanged);
|
|
auto p=x.module.preCapture(0,x.c); CHECK(p.accepted && p.initialization_required);
|
|
CHECK(x.module.processFrame(imageFrame(0)).accepted);
|
|
}
|
|
TEST(feedback, exposure_outside_configured_range_is_reported_not_clipped_as_feedback) {
|
|
Fixture x; x.track(); CHECK(x.module.preCapture(x.time+1,x.c).accepted);
|
|
const auto r=x.module.processFrame(imageFrame(x.time+1,80,true,20000));
|
|
CHECK(!r.accepted && r.reason==Reason::InvalidActualExposure && !r.suggested_exposure_us);
|
|
NEAR(r.actual_exposure_us,20000,0);
|
|
CHECK(x.module.history().size()==1);
|
|
}
|
|
|
|
TEST(config, reject_invalid_contract_without_accepting_frames) {
|
|
Config empty; CHECK(!validate(empty).empty());
|
|
Fixture x; CHECK(validate(x.c).empty());
|
|
auto bad=x.c; bad.metering.trim_fraction_each_side=.5;
|
|
CHECK(!x.module.preCapture(0,bad).accepted);
|
|
CHECK(!x.module.processFrame(imageFrame(0)).accepted);
|
|
bad=x.c; bad.prediction.points=4; CHECK(!validate(bad).empty());
|
|
bad=x.c; bad.controller.target_gray=0; CHECK(!validate(bad).empty());
|
|
bad=x.c; bad.max_exposure_us=500; CHECK(!validate(bad).empty());
|
|
bad=x.c; bad.recovery.max_step_ratio=.5; CHECK(!validate(bad).empty());
|
|
CHECK(x.module.preCapture(0,x.c).accepted);
|
|
}
|
|
TEST(config, independent_instances_do_not_share_state) {
|
|
Fixture a,b; a.track(); a.next(40);
|
|
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);
|
|
}
|
|
TEST(continuous, sparse_frames_preserve_confirmation_without_accumulating_stale_integral) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig();
|
|
c.preserve_confirmation_on_idle = true;
|
|
const TimestampUs interval = c.idle_timeout_us + 1;
|
|
auto result = control.update(feedbackFrame(0), c);
|
|
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
|
for (int frame = 1; frame <= 4; ++frame) {
|
|
result = control.update(feedbackFrame(frame * interval), c);
|
|
CHECK(result.accepted && result.reset_reason == FeedbackResetReason::IdleTimeout);
|
|
CHECK(result.reason == Reason::PiAdjustment && result.control.same_direction_count == 2);
|
|
CHECK(result.control.integral == 0 && result.control.integral_suppressed);
|
|
NEAR(*result.suggested_exposure_us, 1080, 1e-9);
|
|
}
|
|
// A subsequent close frame may integrate normally; the sparse frames do not.
|
|
result = control.update(feedbackFrame(4 * interval + 1), c);
|
|
CHECK(!result.reset_reason && !result.control.integral_suppressed);
|
|
NEAR(result.control.integral, .2, 1e-12);
|
|
// An idle gap after accumulated integral clears that integral before computing.
|
|
result = control.update(feedbackFrame(5 * interval + 1), c);
|
|
CHECK(result.control.integral == 0 && result.control.integral_suppressed);
|
|
NEAR(*result.suggested_exposure_us, 1080, 1e-9);
|
|
}
|
|
TEST(continuous, sparse_idle_preserves_hysteresis_and_reconfirms_after_direction_change) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig();
|
|
c.preserve_confirmation_on_idle = true;
|
|
const TimestampUs interval = c.idle_timeout_us + 1;
|
|
control.update(feedbackFrame(0), c);
|
|
// 11 percent error is outside the deadband but inside the re-entry hysteresis.
|
|
auto result = control.update(feedbackFrame(interval, 89), c);
|
|
CHECK(result.adjustment_suggested && result.control.same_direction_count == 2);
|
|
result = control.update(feedbackFrame(2 * interval, 120), c);
|
|
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
|
result = control.update(feedbackFrame(3 * interval, 120), c);
|
|
CHECK(result.adjustment_suggested && *result.suggested_exposure_us < 1000);
|
|
result = control.update(feedbackFrame(4 * interval, 100), c);
|
|
CHECK(result.reason == Reason::Deadband && result.control.same_direction_count == 0);
|
|
result = control.update(feedbackFrame(5 * interval, 89), c);
|
|
CHECK(result.reason == Reason::Hysteresis && result.control.same_direction_count == 0);
|
|
}
|
|
TEST(continuous, idle_option_preserves_default_and_never_weakens_explicit_or_context_resets) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig();
|
|
const TimestampUs interval = c.idle_timeout_us + 1;
|
|
control.update(feedbackFrame(0), c);
|
|
for (int frame = 1; frame <= 3; ++frame) {
|
|
auto result = control.update(feedbackFrame(frame * interval), c);
|
|
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
|
CHECK(!result.control.integral_suppressed);
|
|
}
|
|
const auto oldConfig = c;
|
|
c.preserve_confirmation_on_idle = true;
|
|
CHECK(c != oldConfig);
|
|
auto result = control.update(feedbackFrame(4 * interval), c);
|
|
CHECK(result.reset_reason == FeedbackResetReason::ConfigurationChanged);
|
|
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
|
result = control.update(feedbackFrame(5 * interval, 80, FeedbackSource::ManualRoi, 80), c);
|
|
CHECK(result.reset_reason == FeedbackResetReason::SourceChanged);
|
|
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
|
for (const auto reason : {FeedbackResetReason::Manual, FeedbackResetReason::IdleTimeout,
|
|
FeedbackResetReason::ClockChanged}) {
|
|
control.reset(reason);
|
|
result = control.update(feedbackFrame(6 * interval, 80, FeedbackSource::ManualRoi, 80), c);
|
|
CHECK(result.reset_reason == reason && result.reason == Reason::ConfirmingError);
|
|
CHECK(result.control.same_direction_count == 1 && !result.control.integral_suppressed);
|
|
result = control.update(feedbackFrame(7 * interval, 80, FeedbackSource::ManualRoi, 80), c);
|
|
CHECK(result.adjustment_suggested);
|
|
// Keep the next explicit reset's accepted timestamp strictly newer.
|
|
control.reset(FeedbackResetReason::ClockChanged);
|
|
}
|
|
}
|
|
TEST(continuous, external_override_clears_integral_without_restarting_confirmation) {
|
|
ContinuousExposureControl control; const auto c = feedbackConfig();
|
|
control.update(feedbackFrame(0), c);
|
|
auto result = control.update(feedbackFrame(1), c);
|
|
NEAR(result.control.integral, .2, 1e-12);
|
|
for (TimestampUs time = 2; time <= 4; ++time) {
|
|
auto input = feedbackFrame(time); input.suppress_integral = true;
|
|
result = control.update(input, c);
|
|
CHECK(result.accepted && !result.reset_reason && result.adjustment_suggested);
|
|
CHECK(result.control.integral_suppressed && result.control.integral == 0);
|
|
CHECK(result.control.same_direction_count == 2);
|
|
NEAR(*result.suggested_exposure_us, 1080, 1e-9);
|
|
}
|
|
result = control.update(feedbackFrame(5), c);
|
|
CHECK(!result.control.integral_suppressed);
|
|
NEAR(result.control.integral, .2, 1e-12);
|
|
auto deadband = feedbackFrame(6, 100); deadband.suppress_integral = true;
|
|
result = control.update(deadband, c);
|
|
CHECK(result.reason == Reason::Deadband && result.control.integral == 0 && result.control.integral_suppressed);
|
|
auto confirming = feedbackFrame(7); confirming.suppress_integral = true;
|
|
result = control.update(confirming, c);
|
|
CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1);
|
|
result = control.update(feedbackFrame(8), c);
|
|
CHECK(result.adjustment_suggested && result.control.same_direction_count == 2);
|
|
}
|
|
TEST(continuous, rejected_override_inputs_do_not_clear_integral_or_consume_time_and_configuration) {
|
|
ContinuousExposureControl control; const auto c = feedbackConfig();
|
|
control.update(feedbackFrame(0), c);
|
|
NEAR(control.update(feedbackFrame(1), c).control.integral, .2, 1e-12);
|
|
const auto rejected = [&](ContinuousFrameInput bad, const ContinuousConfig& config) {
|
|
bad.suppress_integral = true;
|
|
const auto result = control.update(bad, config);
|
|
CHECK(!result.accepted && !result.control.integral_suppressed);
|
|
CHECK(result.control.same_direction_count == 2);
|
|
NEAR(result.control.integral, .2, 1e-12);
|
|
};
|
|
rejected(feedbackFrame(1), c);
|
|
rejected(feedbackFrame(0), c);
|
|
rejected(feedbackFrame(-1), c);
|
|
rejected(feedbackFrame(2, 80, FeedbackSource::Global, {}, 0), c);
|
|
auto bad = feedbackFrame(2); bad.metering.global.gray = std::numeric_limits<double>::quiet_NaN();
|
|
auto changed = c; changed.preserve_confirmation_on_idle = true;
|
|
rejected(bad, changed);
|
|
bad = feedbackFrame(2); bad.source = static_cast<FeedbackSource>(999);
|
|
rejected(bad, c);
|
|
auto invalid = changed; invalid.global_controller.target_gray = 0;
|
|
rejected(feedbackFrame(2), invalid);
|
|
const auto result = control.update(feedbackFrame(2), c);
|
|
CHECK(result.accepted && !result.reset_reason && result.control.same_direction_count == 2);
|
|
NEAR(result.control.integral, .4, 1e-12);
|
|
}
|
|
TEST(continuous, reached_target_and_error_reversal_clear_obsolete_integral) {
|
|
for (const bool timeAware : {false, true}) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = timeAware;
|
|
c.global_controller.target_gray = 118;
|
|
double actual = 5000;
|
|
TimestampUs time = 0;
|
|
for (int i = 0; i < 7; ++i, time += 5'000'000) {
|
|
const auto r = control.update(feedbackFrame(time, 100, FeedbackSource::Global, {}, actual), c);
|
|
actual = *r.suggested_exposure_us;
|
|
}
|
|
for (int i = 0; i < 2; ++i, time += 5'000'000) {
|
|
const auto r = control.update(feedbackFrame(time, 118, FeedbackSource::Global, {}, actual), c);
|
|
CHECK(r.control.integral == 0 && !r.adjustment_suggested);
|
|
}
|
|
for (int i = 0; i < 3; ++i, time += 5'000'000) {
|
|
const auto r = control.update(feedbackFrame(time, 134, FeedbackSource::Global, {}, actual), c);
|
|
CHECK(*r.suggested_exposure_us <= actual && r.control.integral <= 0);
|
|
actual = *r.suggested_exposure_us;
|
|
}
|
|
control.reset(FeedbackResetReason::ClockChanged);
|
|
control.update(feedbackFrame(0, 100), c);
|
|
const auto dark = control.update(feedbackFrame(5'000'000, 100), c);
|
|
CHECK(dark.control.integral > 0);
|
|
const auto reversed = control.update(feedbackFrame(10'000'000, 134), c);
|
|
CHECK(reversed.control.integral == 0 && *reversed.suggested_exposure_us <= 1000);
|
|
}
|
|
}
|
|
TEST(controller, original_entrypoint_clears_integral_on_return_and_reversal) {
|
|
Fixture x; x.c.controller.target_gray = 118; x.track(118);
|
|
for (int i = 0; i < 7; ++i) x.next(100);
|
|
auto r = x.next(118); CHECK(r.control.integral == 0);
|
|
x.next(118);
|
|
for (int i = 0; i < 3; ++i) {
|
|
r = x.next(134); CHECK(*r.suggested_exposure_us <= 1000 && r.control.integral <= 0);
|
|
}
|
|
x.next(100); x.next(100);
|
|
r = x.next(134); CHECK(r.control.integral == 0 && !r.adjustment_suggested);
|
|
}
|
|
TEST(continuous, elapsed_time_limits_rate_and_integral_at_multiple_intervals) {
|
|
for (const TimestampUs interval : {TimestampUs{100'000}, TimestampUs{1'000'000},
|
|
TimestampUs{5'000'000}, TimestampUs{60'000'000}}) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true;
|
|
const auto first = control.update(feedbackFrame(0, 20), c);
|
|
CHECK(first.control.elapsed_us == c.nominal_interval_us);
|
|
const auto r = control.update(feedbackFrame(interval, 20), c);
|
|
const double expectedStep = std::min(.25, .05 * interval / 1'000'000.0);
|
|
NEAR(*r.suggested_exposure_us, 1000 * (1 + expectedStep), 1e-8);
|
|
NEAR(r.control.effective_max_step_ratio, expectedStep, 1e-12);
|
|
CHECK(r.control.time_rate_limited == (interval < 5'000'000));
|
|
CHECK(r.control.elapsed_us == interval && r.control.filter_alpha == 1);
|
|
control.reset(FeedbackResetReason::ClockChanged);
|
|
c.confirmation_us = 0;
|
|
CHECK(control.update(feedbackFrame(0), c).reason == Reason::ConfirmingError);
|
|
const auto small = control.update(feedbackFrame(interval), c);
|
|
CHECK(small.control.same_direction_count == 2);
|
|
NEAR(small.control.integral_time_scale, std::min(1.0, interval / 5'000'000.0), 1e-12);
|
|
if (interval >= 5'000'000) NEAR(small.control.integral, .2, 1e-12);
|
|
}
|
|
}
|
|
TEST(continuous, time_confirmation_requires_duration_and_two_samples) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true;
|
|
c.global_controller.same_direction_frames = 1;
|
|
CHECK(control.update(feedbackFrame(0), c).reason == Reason::ConfirmingError);
|
|
for (int i = 1; i < 10; ++i) {
|
|
const auto r = control.update(feedbackFrame(i * 100'000), c);
|
|
CHECK(r.reason == Reason::ConfirmingError && !r.control.confirmation_time_satisfied);
|
|
CHECK(r.control.integral == 0);
|
|
}
|
|
const auto r = control.update(feedbackFrame(1'000'000), c);
|
|
CHECK(r.adjustment_suggested && r.control.confirmation_time_satisfied);
|
|
CHECK(r.control.same_direction_duration_us == 1'000'000);
|
|
NEAR(r.control.integral_time_scale, .02, 1e-12);
|
|
}
|
|
TEST(continuous, smoothing_normalizes_exposure_and_never_reverses_raw_error) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true;
|
|
control.update(feedbackFrame(0, 80), c);
|
|
// Same scene response at changed exposure must not be mistaken for light drift.
|
|
auto r = control.update(feedbackFrame(100'000, 88, FeedbackSource::Global, {}, 1100), c);
|
|
NEAR(*r.control_gray, 88, 1e-10);
|
|
NEAR(*r.raw_control_gray, 88, 0);
|
|
CHECK(r.control.filter_alpha > 0 && r.control.filter_alpha < 1);
|
|
r = control.update(feedbackFrame(200'000, 120, FeedbackSource::Global, {}, 1100), c);
|
|
CHECK(*r.control_gray == 120 && r.control.filter_alpha == 1);
|
|
CHECK(*r.suggested_exposure_us <= 1100 && r.control.integral <= 0);
|
|
r = control.update(feedbackFrame(300'000, 100, FeedbackSource::Global, {}, 1100), c);
|
|
CHECK(r.reason == Reason::Deadband && !r.adjustment_suggested && r.control.integral == 0);
|
|
auto overrideFrame = feedbackFrame(400'000, 90); overrideFrame.suppress_integral = true;
|
|
r = control.update(overrideFrame, c);
|
|
CHECK(*r.control_gray == 90 && r.control.filter_alpha == 1 && r.control.integral == 0);
|
|
}
|
|
TEST(continuous, timed_resets_and_long_gaps_clear_filter_without_stale_integral) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig();
|
|
c.time_aware = true; c.preserve_confirmation_on_idle = true;
|
|
control.update(feedbackFrame(0), c);
|
|
auto r = control.update(feedbackFrame(3'600'000'000), c);
|
|
CHECK(r.adjustment_suggested && r.control.integral == 0 && r.control.integral_time_scale == 0);
|
|
CHECK(r.control.filter_alpha == 1 && r.control.same_direction_duration_us == 3'600'000'000);
|
|
for (const auto reason : {FeedbackResetReason::Manual, FeedbackResetReason::ClockChanged}) {
|
|
control.reset(reason);
|
|
r = control.update(feedbackFrame(3'601'000'000, 85), c);
|
|
CHECK(r.reset_reason == reason && r.control.filter_alpha == 1 && *r.control_gray == 85);
|
|
CHECK(r.reason == Reason::ConfirmingError && r.control.same_direction_duration_us == 0);
|
|
}
|
|
r = control.update(feedbackFrame(3'602'000'000, 85, FeedbackSource::ManualRoi, 90), c);
|
|
CHECK(r.reset_reason == FeedbackResetReason::SourceChanged && r.control.filter_alpha == 1);
|
|
++c.confirmation_us;
|
|
r = control.update(feedbackFrame(3'603'000'000, 85, FeedbackSource::ManualRoi, 90), c);
|
|
CHECK(r.reset_reason == FeedbackResetReason::ConfigurationChanged && r.control.filter_alpha == 1);
|
|
}
|
|
TEST(continuous, rejected_frames_do_not_consume_temporal_filter_or_timestamps) {
|
|
ContinuousExposureControl a, b; auto c = feedbackConfig(); c.time_aware = true;
|
|
a.update(feedbackFrame(0), c); b.update(feedbackFrame(0), c);
|
|
auto bad = feedbackFrame(500'000, 255); bad.actual_exposure_us = 0;
|
|
CHECK(!a.update(bad, c).accepted);
|
|
bad = feedbackFrame(500'000, 255); bad.metering.global.gray = -1;
|
|
CHECK(!a.update(bad, c).accepted);
|
|
for (int field = 0; field < 4; ++field) {
|
|
auto invalid = c;
|
|
if (field == 0) invalid.nominal_interval_us = 0;
|
|
if (field == 1) invalid.smoothing_tau_us = -1;
|
|
if (field == 2) invalid.confirmation_us = -1;
|
|
if (field == 3) invalid.max_step_ratio_per_second = 0;
|
|
CHECK(!a.update(feedbackFrame(500'000, 255), invalid).accepted);
|
|
}
|
|
const auto x = a.update(feedbackFrame(1'000'000, 85), c);
|
|
const auto y = b.update(feedbackFrame(1'000'000, 85), c);
|
|
CHECK(x.reason == y.reason && x.control_gray == y.control_gray && x.suggested_exposure_us == y.suggested_exposure_us);
|
|
CHECK(x.control.elapsed_us == y.control.elapsed_us && x.control.integral == y.control.integral);
|
|
CHECK(x.control.same_direction_duration_us == y.control.same_direction_duration_us);
|
|
}
|
|
TEST(continuous, tiny_finite_exposure_cannot_poison_temporal_filter) {
|
|
ContinuousExposureControl control; auto c = feedbackConfig();
|
|
c.time_aware = true; c.min_exposure_us = 1e-320;
|
|
for (int i = 0; i < 3; ++i) {
|
|
const auto r = control.update(feedbackFrame(i * 1'000'000, 80, FeedbackSource::Global, {}, 1e-319), c);
|
|
CHECK(r.accepted && r.control_gray == 80 && std::isfinite(*r.suggested_exposure_us));
|
|
CHECK(std::isfinite(r.control.integral));
|
|
}
|
|
}
|
|
ContinuousConfig predictionConfig() {
|
|
auto c = feedbackConfig(); c.time_aware = true; c.max_exposure_us = 30000;
|
|
c.region_controller.target_gray = c.global_controller.target_gray = 118;
|
|
return c;
|
|
}
|
|
ContinuousFrameInput predictionFrame(TimestampUs time, int gray, double predicted) {
|
|
auto input = feedbackFrame(time, gray, FeedbackSource::ManualRoi, gray, 10000);
|
|
input.predicted_gray = predicted;
|
|
return input;
|
|
}
|
|
TEST(continuous, prediction_uses_shared_deadband_hysteresis_and_confirmation) {
|
|
ContinuousExposureControl control; const auto c = predictionConfig();
|
|
auto r = control.update(predictionFrame(0, 110, 110), c);
|
|
CHECK(r.accepted && !r.prediction_used && r.reset_reason == FeedbackResetReason::Startup);
|
|
r = control.update(predictionFrame(5'000'000, 110, 110), c);
|
|
CHECK(r.prediction_used && r.reason == Reason::Deadband && *r.suggested_exposure_us == 10000);
|
|
r = control.update(predictionFrame(10'000'000, 105, 105), c);
|
|
CHECK(r.prediction_used && r.reason == Reason::Hysteresis && *r.suggested_exposure_us == 10000);
|
|
r = control.update(predictionFrame(15'000'000, 110, 102), c);
|
|
CHECK(r.prediction_used && r.reason == Reason::ConfirmingError && !r.adjustment_suggested);
|
|
CHECK(r.control.same_direction_count == 1 && r.control.integral == 0);
|
|
r = control.update(predictionFrame(20'000'000, 110, 101), c);
|
|
CHECK(r.prediction_used && r.reason == Reason::PiAdjustment && r.adjustment_suggested);
|
|
CHECK(r.control.same_direction_count == 2 && r.control.confirmation_time_satisfied);
|
|
CHECK(*r.suggested_exposure_us > 10000 && *r.suggested_exposure_us <= 11000);
|
|
CHECK(r.feedback_reason == Reason::Deadband && *r.feedback_suggested_exposure_us == 10000);
|
|
CHECK(r.metering.target.gray == 110 && r.raw_control_gray == 110 && r.feedback_control_gray == 110);
|
|
CHECK(r.control.filtered_gray == 110 && r.control_gray == 101);
|
|
NEAR(*r.control.current_error_ratio, 8.0 / 118, 1e-12);
|
|
NEAR(*r.control.control_error_ratio, 17.0 / 118, 1e-12);
|
|
}
|
|
TEST(continuous, prediction_keeps_time_confirmation_and_both_step_limits) {
|
|
ContinuousExposureControl control; auto c = predictionConfig();
|
|
control.update(predictionFrame(0, 110, 110), c);
|
|
auto r = control.update(predictionFrame(100'000, 110, 80), c);
|
|
CHECK(r.reason == Reason::ConfirmingError);
|
|
r = control.update(predictionFrame(200'000, 110, 80), c);
|
|
CHECK(r.reason == Reason::ConfirmingError && !r.control.confirmation_time_satisfied);
|
|
r = control.update(predictionFrame(1'100'000, 110, 80), c);
|
|
CHECK(r.adjustment_suggested && r.control.confirmation_time_satisfied && r.control.time_rate_limited);
|
|
NEAR(*r.suggested_exposure_us, 10450, 1e-8);
|
|
r = control.update(predictionFrame(6'100'000, 110, 60), c);
|
|
CHECK(r.adjustment_suggested && r.control.step_limited && !r.control.time_rate_limited);
|
|
NEAR(*r.suggested_exposure_us, 11000, 1e-8);
|
|
control.reset();
|
|
c.time_aware = false;
|
|
control.update(predictionFrame(7'000'000, 110, 110), c);
|
|
r = control.update(predictionFrame(8'000'000, 110, 80), c);
|
|
CHECK(r.reason == Reason::ConfirmingError);
|
|
r = control.update(predictionFrame(9'000'000, 110, 80), c);
|
|
CHECK(r.adjustment_suggested && *r.suggested_exposure_us <= 11000);
|
|
}
|
|
TEST(continuous, prediction_cannot_override_active_feedback_or_protection_resets) {
|
|
const auto c = predictionConfig();
|
|
for (int scenario = 0; scenario < 5; ++scenario) {
|
|
ContinuousExposureControl control;
|
|
control.update(predictionFrame(0, 110, 110), c);
|
|
auto input = predictionFrame(5'000'000, 110, 60);
|
|
if (scenario == 0) input = predictionFrame(5'000'000, 60, 200);
|
|
if (scenario == 1) input.source = FeedbackSource::Global;
|
|
if (scenario == 2) input.suppress_integral = true;
|
|
if (scenario == 3) control.reset(FeedbackResetReason::ClockChanged);
|
|
if (scenario == 4) input.timestamp_us = c.idle_timeout_us + 1;
|
|
const auto r = control.update(input, c);
|
|
CHECK(r.accepted && !r.prediction_used && r.feedback_reason == r.reason);
|
|
CHECK(r.feedback_suggested_exposure_us == r.suggested_exposure_us);
|
|
if (scenario == 0) CHECK(r.adjustment_suggested && *r.suggested_exposure_us > 10000);
|
|
else CHECK(!r.adjustment_suggested);
|
|
}
|
|
}
|
|
TEST(continuous, rejected_prediction_does_not_mutate_feedback_state) {
|
|
const auto c = predictionConfig(); ContinuousExposureControl a, b;
|
|
a.update(predictionFrame(0, 110, 110), c); b.update(predictionFrame(0, 110, 110), c);
|
|
a.update(predictionFrame(5'000'000, 110, 102), c); b.update(predictionFrame(5'000'000, 110, 102), c);
|
|
auto bad = predictionFrame(6'000'000, 110, std::numeric_limits<double>::quiet_NaN());
|
|
CHECK(!a.update(bad, c).accepted);
|
|
bad.predicted_gray = 101; bad.prediction_max_step_ratio = 0;
|
|
CHECK(!a.update(bad, c).accepted);
|
|
const auto x = a.update(predictionFrame(10'000'000, 110, 101), c);
|
|
const auto y = b.update(predictionFrame(10'000'000, 110, 101), c);
|
|
CHECK(x.adjustment_suggested && x.reason == y.reason && x.suggested_exposure_us == y.suggested_exposure_us);
|
|
CHECK(x.control.same_direction_count == y.control.same_direction_count && x.control.integral == y.control.integral);
|
|
}
|
|
} // namespace
|
|
|
|
int main(int argc,char** argv) {
|
|
const std::string selected=argc>1?argv[1]:"";
|
|
int total=0, failures=0;
|
|
for(const auto& test:cases()) if(selected.empty()||test.group==selected) {
|
|
++total;
|
|
try { test.run(); std::cout<<"PASS "<<test.group<<"/"<<test.name<<'\n'; }
|
|
catch(const std::exception& error) { ++failures; std::cerr<<"FAIL "<<test.group<<"/"<<test.name<<": "<<error.what()<<'\n'; }
|
|
}
|
|
std::cout<<"RESULT "<<selected<<": "<<total-failures<<"/"<<total<<" passed\n";
|
|
return failures||total==0?1:0;
|
|
}
|