Add continuous global/ROI feedback with decision tracing, valid-image masks, and exact Gray16 source means alongside normalized metrics. Preserve legacy control behavior and expose the 1.3.0 API. Validation: Release CTest 12/12 (70 cases), installed consumer 1/1, and diff checks passed. Software-only verification; no real device operation.
289 lines
12 KiB
C++
289 lines
12 KiB
C++
#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;
|
|
};
|
|
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;
|
|
};
|
|
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.
|
|
};
|
|
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;
|
|
};
|
|
|
|
// Serial calls per image stream. No target-presence state machine, prediction or
|
|
// preCapture step: each accepted measurement supplies feedback for the next frame.
|
|
// 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
|