#pragma once #include #include #include #include #include #include #include namespace target_exposure { // All timestamps use caller-supplied, nonnegative microseconds in one clock domain. using TimestampUs = std::int64_t; enum class State { CAPTURE, TRACK, LOST }; enum class ResetReason { Startup, Manual, NewBusinessCycle, ClockChanged, ConfigurationChanged, IdleTimeout, HistoryExpired }; enum class MeterStatus { Valid, NoTarget, EmptyImage, UnsupportedImage, InvalidRoi, EmptyRoi, InsufficientPixels, InvalidConfiguration }; enum class PredictionReason { Available, Disabled, ModelNotConfirmed, CurrentMeasurementInvalid, InsufficientHistory, MissingHorizon, InvalidHorizon, DarkOrSaturated, ResidualTooLarge, OutOfRange, InvalidNumbers }; enum class Reason { Initialized, Ready, InvalidConfiguration, InvalidTimestamp, DuplicateFrame, OutOfOrderFrame, PreCaptureRequired, InvalidActualExposure, ConfirmingTarget, TrackingActivated, InvalidMetering, Deadband, Hysteresis, ConfirmingError, ProportionalAdjustment, PiAdjustment, ExposureLimit, TargetMissing, EnteredLost, Frozen, CaptureAfterFreeze, RecoveryNotAllowed, RecoveryReferenceMissing, RecoveryNotNeeded, RecoverySuggested, NotRecovered }; const char* toString(State value) noexcept; const char* toString(ResetReason value) noexcept; const char* toString(MeterStatus value) noexcept; const char* toString(PredictionReason value) noexcept; const char* toString(Reason value) noexcept; struct MeteringConfig { double trim_fraction_each_side = 0.05; std::size_t min_target_pixels = 16; std::size_t min_retained_pixels = 8; // 1 = whole image; otherwise a fixed centered rectangle of this width/height fraction. double global_center_fraction = 1.0; int near_black_level = 2; int saturation_level = 253; }; struct PredictionConfig { bool enabled = true; // Set only when gain/response and known black offset support the linear model. bool photometric_model_confirmed = false; std::size_t points = 3; // exactly 3 or 5, no implicit 5-to-3 fallback double black_level = 0.0; TimestampUs nominal_interval_us = 0; // 0 requires next_capture_timestamp_us TimestampUs max_horizon_us = 60'000'000; double max_relative_fit_rmse = 0.10; double max_near_black_fraction = 0.10; double max_saturated_fraction = 0.05; double min_predicted_gray = 1.0; double max_predicted_gray = 254.0; }; struct ControllerConfig { double target_gray = 118.0; double deadband_ratio = 0.10; double hysteresis_ratio = 0.02; double large_error_ratio = 0.40; double proportional_gain = 0.70; double kp = 0.40; double ki = 0.10; double integral_abs_limit = 1.0; double max_step_ratio = 0.25; // applies to both proportional and PI branches int same_direction_frames = 2; // small error only, matching the approved pseudocode }; struct RecoveryConfig { std::optional global_reference_gray; double deadband_ratio = 0.30; double gain = 0.30; double max_step_ratio = 0.10; }; struct Config { std::string profile_id = "default"; std::uint64_t revision = 0; // Required from the caller. No motion-scene exposure maximum is hardcoded. double min_exposure_us = 0.0; double max_exposure_us = 0.0; double empirical_exposure_us = 0.0; TimestampUs history_window_us = 300'000'000; TimestampUs idle_timeout_us = 300'000'000; std::size_t history_capacity = 512; int detect_confirm_frames = 3; int lost_confirm_frames = 3; int freeze_frames = 2; MeteringConfig metering; PredictionConfig prediction; ControllerConfig controller; RecoveryConfig recovery; }; std::vector validate(const Config& config); bool operator==(const Config& a, const Config& b); inline bool operator!=(const Config& a, const Config& b) { return !(a == b); } struct Roi { std::vector rectangles; std::vector rotated_rectangles; // Each nonzero CV_8UC1 pixel is included. Full image dimensions are required. std::vector masks; }; // Explicit helpers only; processFrame never calls these on the supplied final ROI. cv::Rect inset(const cv::Rect& rectangle, double fraction_per_side); cv::RotatedRect inset(const cv::RotatedRect& rectangle, double fraction_per_side); struct Brightness { // Existing statistic: target trimmed mean; global untrimmed mean. std::optional gray; MeterStatus status = MeterStatus::EmptyImage; std::size_t pixels = 0; std::size_t retained_pixels = 0; double near_black_fraction = 0.0; double saturated_fraction = 0.0; // Ordinary mean before trimming; absent whenever the measurement is invalid. std::optional arithmetic_gray; bool valid() const noexcept { return status == MeterStatus::Valid && gray.has_value(); } }; struct MeteringResult { Brightness target; Brightness global; }; // Stateless Gray8/BGR8 measurement. No exposure, timestamp or control instance needed. // hasUsableRegion describes the supplied ROI, not confirmed physical target presence. // Reads image/masks synchronously without retaining or changing them. Calls may run // concurrently when the caller keeps the input image/mask data unchanged. MeteringResult measureImage(const cv::Mat& image, const Roi& roi, bool hasUsableRegion, const MeteringConfig& config = {}); struct HistoryRecord { TimestampUs timestamp_us = 0; double actual_exposure_us = 0.0; bool has_usable_target = false; MeteringResult metering; std::string profile_id; std::uint64_t config_revision = 0; }; struct PredictionResult { PredictionReason reason = PredictionReason::InsufficientHistory; bool available = false; bool adopted = false; std::size_t points_used = 0; std::optional timestamp_us; std::optional predicted_gray; std::optional normalized_slope_per_second; std::optional relative_fit_rmse; }; struct ControlTrace { std::optional current_error_ratio; std::optional control_error_ratio; double integral = 0.0; int same_direction_count = 0; double raw_delta_exposure_us = 0.0; double limited_delta_exposure_us = 0.0; bool integral_blocked_by_limit = false; bool step_limited = false; bool exposure_limited = false; }; struct FrameInput { cv::Mat image; // Gray8 or BGR8, read synchronously; no image retained after the call Roi roi; bool has_usable_target = false; TimestampUs timestamp_us = 0; double actual_exposure_us = 0.0; // finite and inside the configured limits std::optional next_capture_timestamp_us; bool allow_no_target_recovery = false; }; struct PreCaptureResult { bool accepted = false; State state = State::CAPTURE; bool initialization_required = false; std::optional suggested_exposure_us; Reason reason = Reason::PreCaptureRequired; std::optional initialization_reason; std::vector errors; }; struct FrameResult { bool accepted = false; State state = State::CAPTURE; Reason reason = Reason::PreCaptureRequired; double actual_exposure_us = 0.0; bool adjustment_suggested = false; std::optional suggested_exposure_us; MeteringResult metering; std::size_t valid_history_count = 0; PredictionResult prediction; ControlTrace control; int detection_count = 0; int loss_count = 0; int freeze_remaining = 0; bool loss_episode_active = false; bool recovery_used = false; }; // One instance per independent camera/stream. Serialize calls to the same instance. class ExposureControl { public: ExposureControl(); ~ExposureControl(); ExposureControl(const ExposureControl&) = delete; ExposureControl& operator=(const ExposureControl&) = delete; PreCaptureResult preCapture(TimestampUs now_us, const Config& config); FrameResult processFrame(const FrameInput& input); // Only NewBusinessCycle opens a new recovery budget without stable detections. // ClockChanged also clears the timestamp watermark; it preserves the loss budget. void reset(ResetReason reason); State state() const noexcept; std::vector history() const; private: struct Impl; std::unique_ptr impl_; }; } // namespace target_exposure