diff --git a/CMakeLists.txt b/CMakeLists.txt index ea996fc..22eb61f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,10 @@ add_library(fgc_core STATIC src/core/Calibration.cpp src/core/CalibrationRoutine.cpp src/core/MtiProtocol.cpp + src/core/ImageQuality.cpp + src/core/ExposurePolicy.cpp + src/core/ExposureStore.cpp + src/core/GatedCameraSource.cpp src/sensors/Sht41Protocol.cpp src/ui/UiSnapshot.cpp src/ui/HeadlessUi.cpp diff --git a/README.md b/README.md index 15c1fed..6ad0ca2 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,15 @@ While running, the program reads commands from stdin (one per line): | `help [topic]` | List commands / expand one section. | | `exit` | Stop everything and quit (Ctrl-D also works). | +**Image capture** is per-waypoint and self-correcting: at each settled waypoint the camera is +software-triggered for one deliberate frame, which is then measured (brightness, blown highlights, +sharpness) and re-shot with corrected exposure/gain until it passes. Accepted settings are remembered +**per heading**, so the next visit starts from a value known to work there instead of waiting for the +camera's own auto-exposure to re-converge. If the retry budget runs out the best attempt is kept and +flagged `degraded` rather than losing the waypoint. The reasoning behind each rule is in +[docs/architecture.md](docs/architecture.md#image-capture); the knobs are `[Capture]` in +[docs/configuration.md](docs/configuration.md). + The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live roll/pitch/yaw and powers `gimbal calib`; the **SHT41** (`[Features] enable_env` + `[Env] i2c_device`, or `--mock-env`) supplies the panel's ambient temperature and humidity — the MTi's device-internal diff --git a/config/config.example.ini b/config/config.example.ini index c6cd8be..488edd8 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -57,6 +57,70 @@ white_balance_auto = true jxl_distance = 0.8 jxl_effort = 4 +[Capture] +; How a frame is acquired at each waypoint, and the quality bar it must clear. +; +; mode = trigger : one deliberate software-triggered frame per attempt (default). +; The image belongs to the waypoint that asked for it, and its +; exposure can be chosen deliberately. Also a LOWER USB load than +; a continuous stream. +; mode = freerun : the older paced stream + keep-latest. Fallback only. +mode = trigger +; Judge each frame and reshoot with corrected exposure until it passes. Turning +; this off keeps the first frame unconditionally and disables the per-angle store. +quality_gate = true + +; Retry budget per waypoint. min_attempts > 1 always shoots extra and keeps the +; sharpest - worth raising at a windy site. +max_attempts = 3 +min_attempts = 1 +acquire_timeout_ms = 2000 +; Pause after the axes report standstill, before the first shot: a trigger fired +; the instant motion stops still catches the tail of it. +settle_delay_ms = 150 + +; --- Exposure target --- +; Desired mean brightness (0..255) and the band that counts as good enough. +target_mean = 110 +mean_tolerance = 12 +; Ceiling on blown-out pixels (fraction). This OUTRANKS the mean: a bright sky can +; saturate while the average still looks fine, and blown highlights are gone for good. +clip_max_fraction = 0.005 +exposure_min_us = 50 +; Correction damping (0..1]; below 1 trades a little speed for stability. +damping = 0.8 +; Cold-start settings, used only when the store has nothing to offer. +default_exposure_us = 5000 +default_gain_db = 0 +; NOTE: the exposure CEILING and the gain cap come from [Camera] exposure_max_us and +; gain_max_db. The exposure ceiling doubles as the motion-blur budget. + +; --- Blur --- +; Sharpness is judged RELATIVE to the same angle's own history, because a foggy or +; featureless horizon is legitimately low-detail and an absolute threshold would +; retry forever on it. Reject below this fraction of the angle's last good score. +blur_relative_floor = 0.5 +; Catastrophic-blur backstop, in absolute variance-of-Laplacian units. 0 = off. +blur_absolute_floor = 0 + +; --- Metric cost --- +; Exposure/clipping stats sample every Nth pixel; sharpness always runs at full +; resolution on a centre square of sharpness_roi_px. +metric_stride = 4 +sharpness_roi_px = 512 + +; --- Per-angle exposure memory --- +; Remembers what worked at each heading so the next visit starts from a setting +; known to be roughly right, instead of paying the camera's auto-exposure several +; frames to re-converge. Empty path => $XDG_DATA_HOME/fire_gimbal_control/exposure_store.csv +exposure_store = +; Angle bucket size in degrees (headings within one bucket share a seed). +angle_quantum_deg = 1.0 +; How old an entry may be and still be trusted (seconds). Past this, the seed falls +; back to the last settings accepted anywhere - outdoor light drifts, and a recent +; reading one heading over beats an hour-old reading at this exact heading. +store_stale_s = 1800 + [Serial] ; Motor-controller serial device and baud rate. device = /dev/ttyACM0 diff --git a/docs/architecture.md b/docs/architecture.md index 265c279..de125ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ thread so all geometry/state mutation stays single-threaded. | Serial I/O | `SerialMotorController` | Boost.Asio `io_context`; async read-until parses telemetry | | Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish | | MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) | -| Camera acquisition | Vimba X (internal) | delivers frames via the observer (real source) | +| Camera acquisition | Vimba X (internal) | delivers frames via the observer; the gate's shoot/judge/retry loop runs on the **control thread**, so a slow waypoint delays the tick rather than racing it | Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (channel), the frame queue (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the @@ -75,7 +75,10 @@ corrupted. - ControlCode 0: `MOVE ,` to the next `ScanGrid` waypoint (ping-pong). - ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`. - Trigger the cameras once **both axes report standstill at the target**, then advance the grid. -5. **Frame handling** — a triggered camera delivers a `Frame` to the callback, which `submit()`s it to the +5. **Acquisition + quality gate** (`GatedCameraSource`) — `trigger()` does not simply take a picture. It + seeds exposure/gain from the per-angle store, software-triggers **one** frame, measures it, corrects and + re-shoots until it passes or the budget runs out. See *Image capture* below. +6. **Frame handling** — the accepted `Frame` goes to the callback, which `submit()`s it to the `ImagePipeline`. The worker rotates it 90° CCW, encodes JPEG XL (or copies the demo image), writes `/