#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_16UC1,cv::Scalar(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_16UC1),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(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); } } // 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 "<