325 lines
14 KiB
C++
Raw Normal View History

#pragma once
#include <opencv2/core.hpp>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>
namespace target_exposure {
inline constexpr const char* kVersion = "1.3.0";
// All timestamps use caller-supplied, nonnegative microseconds in one clock domain.
using TimestampUs = std::int64_t;
enum class State { CAPTURE, TRACK, LOST };
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<double> 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<std::string> 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<cv::Rect> rectangles;
std::vector<cv::RotatedRect> rotated_rectangles;
// Each nonzero CV_8UC1 pixel is included. Full image dimensions are required.
std::vector<cv::Mat> 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<double> 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, on the common 0..255 scale; absent when invalid.
std::optional<double> arithmetic_gray;
// Ordinary mean in the source scale, computed before any normalization/quantization.
std::optional<double> source_arithmetic_gray;
int source_bit_depth = 8;
double source_max_gray = 255.0;
bool valid() const noexcept { return status == MeterStatus::Valid && gray.has_value(); }
};
struct MeteringResult { Brightness target; Brightness global; };
// Stateless Gray8/BGR8/Gray16 measurement. All gray/arithmetic_gray values and
// metering thresholds use the common 0..255 scale (Gray16 source maximum = 65535).
// No exposure, timestamp or control instance needed.
// hasUsableRegion describes the supplied ROI, not confirmed physical target presence.
// Optional validImageMask must be full-size CV_8UC1; its nonzero pixels intersect
// both global and target regions. Empty means the entire image is valid.
// Reads image/masks synchronously without retaining or changing them. Calls may run
// concurrently when the caller keeps the input image/mask data unchanged.
MeteringResult measureImage(const cv::Mat& image, const Roi& roi, bool hasUsableRegion,
const MeteringConfig& config = {}, const cv::Mat& validImageMask = {});
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<TimestampUs> timestamp_us;
std::optional<double> predicted_gray;
std::optional<double> normalized_slope_per_second;
std::optional<double> relative_fit_rmse;
};
struct ControlTrace {
std::optional<double> current_error_ratio;
std::optional<double> 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;
// Accepted feedback deliberately clears old integral and skips accumulation.
bool integral_suppressed = false;
bool time_aware = false;
TimestampUs elapsed_us = 0;
std::optional<double> filtered_gray;
double filter_alpha = 1.0;
double integral_time_scale = 1.0;
double effective_max_step_ratio = 0.0;
TimestampUs same_direction_duration_us = 0;
bool confirmation_time_satisfied = true;
bool time_rate_limited = false;
};
struct FrameInput {
cv::Mat image; // Gray8, BGR8 or Gray16, read synchronously; no image retained
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<TimestampUs> 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<double> suggested_exposure_us;
Reason reason = Reason::PreCaptureRequired;
std::optional<ResetReason> initialization_reason;
std::vector<std::string> 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<double> 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<HistoryRecord> history() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// A measurement source describes pixels, never physical workpiece presence.
enum class FeedbackSource { Global, ManualRoi, YoloRegions };
enum class FeedbackFallbackReason { None, RegionUnavailable, InvalidRegionMeasurement };
enum class FeedbackResetReason { Startup, Manual, ConfigurationChanged, SourceChanged,
IdleTimeout, ClockChanged };
enum class FeedbackState { Idle, Holding, Adjusting, Limited, Invalid };
const char* toString(FeedbackSource value) noexcept;
const char* toString(FeedbackFallbackReason value) noexcept;
const char* toString(FeedbackResetReason value) noexcept;
const char* toString(FeedbackState value) noexcept;
struct ContinuousConfig {
double min_exposure_us = 0.0;
double max_exposure_us = 0.0;
TimestampUs idle_timeout_us = 300'000'000;
ControllerConfig region_controller;
ControllerConfig global_controller;
// For scheduled sparse captures: idle keeps direction/hysteresis, but clears
// integral and disables accumulation on that frame. Other resets stay full.
bool preserve_confirmation_on_idle = false;
// Opt-in real elapsed-time feedback; no extrapolation or gain-response model.
bool time_aware = false;
TimestampUs nominal_interval_us = 5'000'000;
TimestampUs smoothing_tau_us = 2'000'000;
TimestampUs confirmation_us = 1'000'000;
double max_step_ratio_per_second = 0.05;
};
std::vector<std::string> validate(const ContinuousConfig& config);
bool operator==(const ContinuousConfig& a, const ContinuousConfig& b);
inline bool operator!=(const ContinuousConfig& a, const ContinuousConfig& b) { return !(a == b); }
struct ContinuousFrameInput {
MeteringResult metering;
FeedbackSource source = FeedbackSource::Global;
TimestampUs timestamp_us = 0;
double actual_exposure_us = 0.0; // Device readback for this frame, not a previous suggestion.
// Set before update when an external protection overrides the PI actuator.
// Clears previous integral and prevents this accepted frame adding integral.
bool suppress_integral = false;
// Optional externally qualified forecast at this frame's actual exposure.
// Used only for a held target measurement, never for global fallback or an
// external protection/reset. It passes through the SAME controller state.
std::optional<double> predicted_gray;
double prediction_max_step_ratio = 0.10;
};
struct ContinuousFrameResult {
bool accepted = false;
FeedbackState state = FeedbackState::Invalid;
Reason reason = Reason::InvalidMetering;
FeedbackSource requested_source = FeedbackSource::Global;
std::optional<FeedbackSource> effective_source;
FeedbackFallbackReason fallback_reason = FeedbackFallbackReason::None;
std::optional<FeedbackResetReason> reset_reason;
double actual_exposure_us = 0.0;
std::optional<double> suggested_exposure_us;
bool adjustment_suggested = false;
double target_gray = 0.0;
std::optional<double> control_gray;
MeteringResult metering;
ControlTrace control;
std::vector<std::string> errors;
// Raw selected gray before optional exposure-normalized temporal filtering.
std::optional<double> raw_control_gray;
// The measured-only decision remains explicit; prediction never alters
// metering or the temporal filter's measured-brightness history.
std::optional<double> feedback_control_gray;
std::optional<double> feedback_suggested_exposure_us;
Reason feedback_reason = Reason::InvalidMetering;
bool prediction_used = false; // Input selection, not a device write.
};
// Serial calls per image stream. No target-presence state machine, trend fitting
// or preCapture step. An optional caller forecast shares the feedback controller.
// The caller applies suggestions and reads the actual exposure of each new frame.
class ContinuousExposureControl {
public:
ContinuousExposureControl();
~ContinuousExposureControl();
ContinuousExposureControl(const ContinuousExposureControl&) = delete;
ContinuousExposureControl& operator=(const ContinuousExposureControl&) = delete;
ContinuousFrameResult update(const ContinuousFrameInput& input, const ContinuousConfig& config);
// Only ClockChanged removes the timestamp watermark. All resets clear PI state.
void reset(FeedbackResetReason reason = FeedbackResetReason::Manual);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace target_exposure