#include #include #include #include #include #include #include #include #include using namespace target_exposure; namespace { struct Case { std::string group, name; std::function run; }; std::vector& cases() { static std::vector value; return value; } bool registerCase(const char* group, const char* name, std::function 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(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_(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(0,1) == 100); } TEST(metering, disjoint_regions_do_not_use_enclosing_box) { Fixture x; auto f = imageFrame(0); f.image = (cv::Mat_(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(1,1)=20; f.image.at(2,2)=60; f.image.at(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(2,2)=1; mask.at(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(0,0)=0; f.image.at(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::max(),0,std::numeric_limits::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::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(0,0)=0; image.at(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(0,0)==0 && image.at(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(1,1)=20; image.at(2,2)=60; image.at(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(2,2)=1; mask.at(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(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::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> 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::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::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_(1,4)<<0,1000,1001,65535); Roi roi; roi.rectangles={{0,0,4,1}}; const auto r=measureImage(image,roi,true,c); CHECK(r.target.valid() && r.global.valid()); CHECK(r.target.source_bit_depth==16 && r.global.source_max_gray==65535.0); NEAR(*r.target.source_arithmetic_gray,16884.0,0); NEAR(*r.global.source_arithmetic_gray,16884.0,0); NEAR(*r.target.arithmetic_gray,16884.0*255/65535,1e-12); NEAR(*r.target.gray,1000.5*255/65535,1e-12); CHECK(r.target.retained_pixels==2 && r.target.pixels==4); NEAR(r.target.near_black_fraction,.25,0); NEAR(r.target.saturated_fraction,.25,0); CHECK(image.at(0,2)==1001); } TEST(stateless, gray16_threshold_boundaries_black_saturation_and_stride) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=0; cv::Mat padded(4,8,CV_16UC1,cv::Scalar(9999)); cv::Mat image=padded(cv::Rect{1,1,6,2}); const unsigned short samples[]={0,514,515,65020,65021,65535}; for(int y=0;y(y,x)=samples[x]; Roi roi; roi.rectangles={{0,0,6,2}}; const auto r=measureImage(image,roi,true,c); NEAR(r.target.near_black_fraction,1.0/3,0); NEAR(r.target.saturated_fraction,1.0/3,0); NEAR(*r.target.source_arithmetic_gray,196605.0/6,0); CHECK(padded.at(0,0)==9999 && !image.isContinuous()); image.setTo(0); const auto black=measureImage(image,{},false); CHECK(black.target.status==MeterStatus::NoTarget && black.target.source_bit_depth==16); NEAR(*black.global.source_arithmetic_gray,0,0); NEAR(black.global.near_black_fraction,1,0); image.setTo(65535); const auto bright=measureImage(image,roi,true,c); NEAR(*bright.target.gray,255,0); NEAR(*bright.target.source_arithmetic_gray,65535,0); NEAR(bright.target.saturated_fraction,1,0); } TEST(stateless, valid_image_mask_intersects_union_and_global_without_mutation) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=0; const cv::Mat image8=(cv::Mat_(2,4)<<0,10,20,30,40,50,60,255); const cv::Mat valid=(cv::Mat_(2,4)<<0,1,128,0,2,255,0,0); Roi roi; roi.rectangles={{0,0,2,2},{1,0,2,1}}; roi.rotated_rectangles.emplace_back(cv::Point2f{2,1},cv::Size2f{2,2},0.0f); cv::Mat part(2,4,CV_8UC1,cv::Scalar(0)); part.at(1,1)=1; roi.masks={part,part}; for(int depth : {CV_8U,CV_16U}) { cv::Mat image; image8.convertTo(image,depth,depth==CV_16U?257:1); const auto r=measureImage(image,roi,true,c,valid); CHECK(r.global.pixels==4 && r.target.pixels==4); NEAR(*r.global.arithmetic_gray,30,1e-12); NEAR(*r.target.arithmetic_gray,30,1e-12); NEAR(*r.target.source_arithmetic_gray,depth==CV_16U?7710:30,0); CHECK(cv::countNonZero(valid)==4 && valid.at(0,2)==128); CHECK(cv::countNonZero(part)==1 && part.at(1,1)==1); CHECK(image8.at(1,3)==255); const auto repeat=measureImage(image,roi,true,c,valid); CHECK(repeat.target.source_arithmetic_gray==r.target.source_arithmetic_gray); } } TEST(stateless, valid_image_mask_keeps_target_independent_of_global_center) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.global_center_fraction=.5; cv::Mat image(4,4,CV_8UC1,cv::Scalar(200)); image.at(1,1)=20; image.at(0,0)=80; cv::Mat valid(4,4,CV_8UC1,cv::Scalar(0)); valid.at(1,1)=1; valid.at(0,0)=1; Roi roi; roi.rectangles={{0,0,1,1}}; const auto r=measureImage(image,roi,true,c,valid); CHECK(r.global.pixels==1 && r.target.pixels==1); NEAR(*r.global.arithmetic_gray,20,0); NEAR(*r.target.arithmetic_gray,80,0); CHECK(r.target.source_bit_depth==8 && r.target.source_max_gray==255.0); } TEST(stateless, invalid_valid_image_masks_and_empty_intersections_are_explicit) { const cv::Mat image(8,8,CV_16UC1,cv::Scalar(1001)); Roi roi; roi.rectangles={{0,0,4,8}}; const int sizes[]={2,4,8}; const std::vector invalid={cv::Mat(7,8,CV_8UC1,cv::Scalar(1)), cv::Mat(8,8,CV_16UC1,cv::Scalar(1)),cv::Mat(8,8,CV_8UC3,cv::Scalar(1,1,1)), cv::Mat(3,sizes,CV_8UC1,cv::Scalar(1))}; for(const auto& mask:invalid) { const auto r=measureImage(image,roi,true,{},mask); CHECK(r.target.status==MeterStatus::InvalidRoi && r.global.status==MeterStatus::InvalidRoi); CHECK(!r.target.arithmetic_gray && !r.global.source_arithmetic_gray); const auto noTarget=measureImage(image,{},false,{},mask); CHECK(noTarget.target.status==MeterStatus::NoTarget && noTarget.global.status==MeterStatus::InvalidRoi); } cv::Mat valid(8,8,CV_8UC1,cv::Scalar(0)); auto r=measureImage(image,roi,true,{},valid); CHECK(r.global.status==MeterStatus::EmptyRoi && r.target.status==MeterStatus::EmptyRoi); CHECK(!r.global.source_arithmetic_gray && r.global.pixels==0 && r.global.source_bit_depth==16); valid(cv::Rect{4,0,4,8}).setTo(255); r=measureImage(image,roi,true,{},valid); CHECK(r.global.valid() && r.target.status==MeterStatus::EmptyRoi); NEAR(*r.global.source_arithmetic_gray,1001,0); valid.setTo(0); valid.at(0,0)=1; r=measureImage(image,roi,true,{},valid); CHECK(r.global.valid() && r.target.status==MeterStatus::InsufficientPixels); CHECK(!r.target.source_arithmetic_gray && !r.target.arithmetic_gray); const auto defaultMask=measureImage(image,roi,true); const auto explicitEmptyMask=measureImage(image,roi,true,{},{}); CHECK(defaultMask.global.pixels==64 && defaultMask.global.source_arithmetic_gray==explicitEmptyMask.global.source_arithmetic_gray); } TEST(stateless, gray16_and_gray8_equivalent_scale_preserves_control_trajectory) { Fixture a,b; for(int gray : {118,80,40,200,118}) { auto frame8=imageFrame(a.time+1'000'000,gray); auto frame16=frame8; frame8.image.convertTo(frame16.image,CV_16U,257); const auto r8=a.send(frame8), r16=b.send(frame16); CHECK(r8.state==r16.state && r8.reason==r16.reason); NEAR(*r8.metering.target.gray,*r16.metering.target.gray,1e-12); CHECK(r8.adjustment_suggested==r16.adjustment_suggested); if(r8.suggested_exposure_us) NEAR(*r8.suggested_exposure_us,*r16.suggested_exposure_us,1e-10); } } TEST(history, window_inclusive_boundary_and_filtering) { Fixture x; x.c.history_window_us=3'000'000; x.c.idle_timeout_us=100'000'000; x.at(0); x.at(1'000'000); x.at(3'000'000); 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,.3,1e-12); 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(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::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 regionGray = {}, double actual = 1000) { ContinuousFrameInput input; input.timestamp_us = time; input.actual_exposure_us = actual; input.source = source; input.metering = measureImage(cv::Mat(10, 10, CV_8UC1, cv::Scalar(globalGray)), {}, false); if (regionGray) { Roi roi; roi.rectangles.emplace_back(0, 0, 10, 10); input.metering.target = measureImage(cv::Mat(10, 10, CV_8UC1, cv::Scalar(*regionGray)), roi, true).target; } return input; } ContinuousConfig feedbackConfig() { ContinuousConfig config; config.min_exposure_us = 100; config.max_exposure_us = 10000; config.region_controller.target_gray = config.global_controller.target_gray = 100; return config; } TEST(continuous, no_target_feedback_converges_without_a_recovery_budget) { ContinuousExposureControl control; const auto c = feedbackConfig(); double actual = 1000; int adjustments = 0; ContinuousFrameResult result; for (int i = 0; i < 40; ++i) { const int gray = std::clamp(static_cast(std::round(actual * .02)), 0, 255); result = control.update(feedbackFrame(i, gray, FeedbackSource::YoloRegions, {}, actual), c); CHECK(result.accepted && result.effective_source == FeedbackSource::Global); CHECK(result.metering.target.status == MeterStatus::NoTarget); CHECK(result.fallback_reason == FeedbackFallbackReason::RegionUnavailable); CHECK(std::abs(result.control.limited_delta_exposure_us) <= actual * .25 + 1e-9); if (result.adjustment_suggested) ++adjustments; actual = *result.suggested_exposure_us; } CHECK(adjustments > 3); CHECK(result.state == FeedbackState::Holding && !result.adjustment_suggested); CHECK(*result.control_gray >= 88 && *result.control_gray <= 112); } TEST(continuous, manual_and_yolo_regions_take_priority_then_fallback_explicitly) { ContinuousExposureControl control; const auto c = feedbackConfig(); auto result = control.update(feedbackFrame(0, 0, FeedbackSource::ManualRoi, 100), c); CHECK(result.effective_source == FeedbackSource::ManualRoi && result.reason == Reason::Deadband); CHECK(result.fallback_reason == FeedbackFallbackReason::None && *result.control_gray == 100); result = control.update(feedbackFrame(1, 255, FeedbackSource::YoloRegions, 20), c); CHECK(result.effective_source == FeedbackSource::YoloRegions && result.adjustment_suggested); CHECK(*result.suggested_exposure_us == 1250 && result.reset_reason == FeedbackResetReason::SourceChanged); result = control.update(feedbackFrame(2, 100, FeedbackSource::YoloRegions), c); CHECK(result.effective_source == FeedbackSource::Global && result.reason == Reason::Deadband); CHECK(result.fallback_reason == FeedbackFallbackReason::RegionUnavailable); auto invalid = feedbackFrame(3, 20, FeedbackSource::ManualRoi); invalid.metering.target.status = MeterStatus::InvalidRoi; result = control.update(invalid, c); CHECK(result.accepted && result.fallback_reason == FeedbackFallbackReason::InvalidRegionMeasurement); CHECK(result.effective_source == FeedbackSource::Global && !result.reset_reason); } TEST(continuous, source_changes_clear_integral_and_direction_count) { ContinuousExposureControl control; const auto c = feedbackConfig(); CHECK(control.update(feedbackFrame(0, 80, FeedbackSource::ManualRoi, 80), c).reason == Reason::ConfirmingError); auto result = control.update(feedbackFrame(1, 80, FeedbackSource::ManualRoi, 80), c); NEAR(result.control.integral, .2, 1e-12); result = control.update(feedbackFrame(2), c); CHECK(result.reset_reason == FeedbackResetReason::SourceChanged && result.reason == Reason::ConfirmingError); CHECK(result.control.integral == 0 && result.control.same_direction_count == 1); result = control.update(feedbackFrame(3), c); NEAR(result.control.integral, .2, 1e-12); result = control.update(feedbackFrame(4, 80, FeedbackSource::YoloRegions, 80), c); CHECK(result.reset_reason == FeedbackResetReason::SourceChanged && result.control.integral == 0); } TEST(continuous, global_reference_is_independent_of_region_reference) { ContinuousExposureControl control; auto c = feedbackConfig(); c.region_controller.target_gray = 180; c.global_controller.target_gray = 100; auto result = control.update(feedbackFrame(0, 100, FeedbackSource::YoloRegions), c); CHECK(result.target_gray == 100 && result.reason == Reason::Deadband); result = control.update(feedbackFrame(1, 100, FeedbackSource::YoloRegions, 100), c); CHECK(result.target_gray == 180 && result.reason == Reason::ProportionalAdjustment); } TEST(continuous, black_saturation_step_limits_and_exposure_limits_are_traced) { ContinuousExposureControl control; const auto c = feedbackConfig(); auto result = control.update(feedbackFrame(0, 0), c); CHECK(result.state == FeedbackState::Adjusting && result.control.step_limited); NEAR(result.control.raw_delta_exposure_us, 700, 1e-10); NEAR(result.control.limited_delta_exposure_us, 250, 0); CHECK(result.metering.global.near_black_fraction == 1); result = control.update(feedbackFrame(1, 255), c); CHECK(*result.suggested_exposure_us == 750 && result.metering.global.saturated_fraction == 1); CHECK(result.control.current_error_ratio == result.control.control_error_ratio); result = control.update(feedbackFrame(2, 0, FeedbackSource::Global, {}, 10000), c); CHECK(result.state == FeedbackState::Limited && result.reason == Reason::ExposureLimit); CHECK(!result.adjustment_suggested && result.control.exposure_limited); result = control.update(feedbackFrame(3, 255, FeedbackSource::Global, {}, 100), c); CHECK(result.state == FeedbackState::Limited && *result.suggested_exposure_us == 100); } TEST(continuous, new_config_and_idle_interval_reset_controller) { ContinuousExposureControl control; auto c = feedbackConfig(); CHECK(control.update(feedbackFrame(0), c).reset_reason == FeedbackResetReason::Startup); NEAR(control.update(feedbackFrame(1), c).control.integral, .2, 1e-12); c.global_controller.kp = .3; auto result = control.update(feedbackFrame(2), c); CHECK(result.reset_reason == FeedbackResetReason::ConfigurationChanged && result.control.integral == 0); result = control.update(feedbackFrame(3), c); NEAR(result.control.integral, .2, 1e-12); result = control.update(feedbackFrame(c.idle_timeout_us + 4), c); CHECK(result.reset_reason == FeedbackResetReason::IdleTimeout && result.control.integral == 0); CHECK(result.reason == Reason::ConfirmingError); } TEST(continuous, duplicates_out_of_order_and_manual_reset_keep_timestamp_watermark) { ContinuousExposureControl control; const auto c = feedbackConfig(); control.update(feedbackFrame(10), c); auto result = control.update(feedbackFrame(10), c); CHECK(!result.accepted && result.reason == Reason::DuplicateFrame && !result.suggested_exposure_us); CHECK(result.control.same_direction_count == 1); CHECK(control.update(feedbackFrame(9), c).reason == Reason::OutOfOrderFrame); result = control.update(feedbackFrame(11), c); CHECK(result.accepted && result.control.same_direction_count == 2); control.reset(); CHECK(control.update(feedbackFrame(0), c).reason == Reason::OutOfOrderFrame); result = control.update(feedbackFrame(12), c); CHECK(result.reset_reason == FeedbackResetReason::Manual && result.control.integral == 0); control.reset(FeedbackResetReason::ClockChanged); result = control.update(feedbackFrame(0), c); CHECK(result.accepted && result.reset_reason == FeedbackResetReason::ClockChanged); } TEST(continuous, invalid_feedback_is_rejected_without_consuming_frame_or_state) { ContinuousExposureControl control; const auto c = feedbackConfig(); control.update(feedbackFrame(0), c); auto bad = feedbackFrame(1); bad.metering.global.gray = std::numeric_limits::quiet_NaN(); auto result = control.update(bad, c); CHECK(!result.accepted && result.reason == Reason::InvalidMetering && !result.suggested_exposure_us); CHECK(!result.effective_source && !result.errors.empty() && result.control.same_direction_count == 1); bad = feedbackFrame(1); bad.metering.global.saturated_fraction = 1.5; CHECK(!control.update(bad, c).accepted); bad = feedbackFrame(1); bad.metering.global.pixels = 0; CHECK(!control.update(bad, c).accepted); bad = feedbackFrame(1); bad.metering.global.retained_pixels = 101; CHECK(!control.update(bad, c).accepted); result = control.update(feedbackFrame(1), c); CHECK(result.accepted && result.control.same_direction_count == 2); NEAR(result.control.integral, .2, 1e-12); } TEST(continuous, invalid_exposure_config_and_time_do_not_initialize) { ContinuousExposureControl control; const auto c = feedbackConfig(); CHECK(control.update(feedbackFrame(-1), c).reason == Reason::InvalidTimestamp); for (double actual : {0.0, 99.0, 10001.0, std::numeric_limits::quiet_NaN()}) { const auto result = control.update(feedbackFrame(0, 80, FeedbackSource::Global, {}, actual), c); CHECK(!result.accepted && result.reason == Reason::InvalidActualExposure && !result.suggested_exposure_us); } auto badConfig = c; badConfig.global_controller.target_gray = 0; CHECK(control.update(feedbackFrame(0), badConfig).reason == Reason::InvalidConfiguration); badConfig = c; badConfig.region_controller.max_step_ratio = 1; CHECK(!validate(badConfig).empty()); auto badInput = feedbackFrame(0); badInput.source = static_cast(100); CHECK(control.update(badInput, c).reason == Reason::InvalidConfiguration); const auto result = control.update(feedbackFrame(0), c); CHECK(result.accepted && result.reset_reason == FeedbackResetReason::Startup); } TEST(continuous, actual_exposure_not_unapplied_previous_advice_is_used) { ContinuousExposureControl control; const auto c = feedbackConfig(); const auto first = control.update(feedbackFrame(0, 20), c); const auto retry = control.update(feedbackFrame(1, 20), c); NEAR(*first.suggested_exposure_us, 1250, 0); NEAR(*retry.suggested_exposure_us, 1250, 0); const auto applied = control.update(feedbackFrame(2, 20, FeedbackSource::Global, {}, 1250), c); NEAR(*applied.suggested_exposure_us, 1562.5, 0); } TEST(continuous, anti_windup_and_independent_instances_are_preserved) { ContinuousExposureControl a, b; auto c = feedbackConfig(); c.global_controller.kp = 2; c.global_controller.ki = 1; a.update(feedbackFrame(0), c); const auto result = a.update(feedbackFrame(1), c); CHECK(result.control.integral_blocked_by_limit && result.control.step_limited); CHECK(result.control.integral == 0 && result.control.same_direction_count == 2); const auto independent = b.update(feedbackFrame(0), c); CHECK(independent.reset_reason == FeedbackResetReason::Startup); CHECK(independent.reason == Reason::ConfirmingError && independent.control.same_direction_count == 1); } } // namespace int main(int argc,char** argv) { 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 "<