camera logic improved

This commit is contained in:
pgdalmeida 2026-08-07 15:04:47 +02:00
parent 48c9aa529d
commit 376e79536c
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
39 changed files with 2326 additions and 73 deletions

View File

@ -59,6 +59,10 @@ add_library(fgc_core STATIC
src/core/Calibration.cpp src/core/Calibration.cpp
src/core/CalibrationRoutine.cpp src/core/CalibrationRoutine.cpp
src/core/MtiProtocol.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/sensors/Sht41Protocol.cpp
src/ui/UiSnapshot.cpp src/ui/UiSnapshot.cpp
src/ui/HeadlessUi.cpp src/ui/HeadlessUi.cpp

View File

@ -114,6 +114,15 @@ While running, the program reads commands from stdin (one per line):
| `help [topic]` | List commands / expand one section. | | `help [topic]` | List commands / expand one section. |
| `exit` | Stop everything and quit (Ctrl-D also works). | | `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 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`, 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 or `--mock-env`) supplies the panel's ambient temperature and humidity — the MTi's device-internal

View File

@ -57,6 +57,70 @@ white_balance_auto = true
jxl_distance = 0.8 jxl_distance = 0.8
jxl_effort = 4 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] [Serial]
; Motor-controller serial device and baud rate. ; Motor-controller serial device and baud rate.
device = /dev/ttyACM0 device = /dev/ttyACM0

View File

@ -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 | | Serial I/O | `SerialMotorController` | Boost.Asio `io_context`; async read-until parses telemetry |
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish | | Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) | | 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 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 (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the
@ -75,7 +75,10 @@ corrupted.
- ControlCode 0: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (ping-pong). - ControlCode 0: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (ping-pong).
- ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`. - 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. - 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 `ImagePipeline`. The worker rotates it 90° CCW, encodes JPEG XL (or copies the demo image), writes
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders). `<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders).
@ -92,13 +95,104 @@ corrupted.
┌──────────────────────┐ ┌──────────────────────┐
│ camera.trigger() │ on success: clear moving, advance grid (ControlCode 0) │ camera.trigger() │ on success: clear moving, advance grid (ControlCode 0)
└──────────────────────┘ └──────────────────────┘ on failure: retry next tick, do NOT advance
``` ```
Triggering only at the settled target replaces the original's trigger-while-moving behaviour (see Triggering only at the settled target replaces the original's trigger-while-moving behaviour (see
[known-issues.md](known-issues.md) #7). The scheduler is unit-tested with mock doubles and an injected clock [known-issues.md](known-issues.md) #7). The scheduler is unit-tested with mock doubles and an injected clock
([tests/test_scheduler.cpp](../tests/test_scheduler.cpp)). ([tests/test_scheduler.cpp](../tests/test_scheduler.cpp)).
Note the scheduler knows nothing about image quality. It asks for an image and advances when it gets one;
everything below is an implementation detail of the camera source. That is what keeps it testable.
## Image capture
### Why not just take a picture
The first implementation streamed continuously at 1 fps and kept whichever frame arrived most recently
("paced free-run + keep-latest"). Two problems followed. The frame saved at a waypoint was captured at some
unknown moment *before* the axes finished settling, and its exposure had been metered for wherever the camera
happened to be pointing at the time — so brightness swung wildly between waypoints as the horizon changed.
And nothing checked that the saved image was usable at all.
The camera's own continuous auto-exposure cannot fix this: it needs **several frames to converge** every time
the scene changes, and a scan changes scene at every waypoint. Waiting for it costs exactly the time the scan
is trying to save.
### The current model
One deliberate frame per attempt, judged before it is kept:
```
seed exposure/gain from the per-angle store
┌──────────────────────────┐
│ setExposure / setGain │
│ TriggerSoftware → 1 frame│ attempt 1..max_attempts
└────────────┬─────────────┘
┌──────────────────────────┐ mean luma, clipped %, dark %, sharpness
│ analyzeFrame (metrics) │ + gain read back from the camera
└────────────┬─────────────┘
┌──────────────────────────┐ accept → deliver
│ evaluate (verdict) │ reject → corrected settings, shoot again
└──────────────────────────┘ budget spent → deliver BEST attempt, flag degraded
```
Layering, all behind `ICameraSource` so the scheduler is unaffected:
| Piece | Role |
|---|---|
| [`ImageQuality`](../include/fgc/ImageQuality.h) | Pure metrics over the raw buffer. No OpenCV, so it lives in `fgc_core` and is testable on synthetic frames |
| [`ExposurePolicy`](../include/fgc/ExposurePolicy.h) | Pure control law: verdict + corrected settings + a score for ranking attempts |
| [`ExposureStore`](../include/fgc/ExposureStore.h) | Per-angle memory of what worked, persisted as CSV |
| [`GatedCameraSource`](../include/fgc/GatedCameraSource.h) | Decorator wiring the three around a real camera |
Because the gate depends only on the interface plus pure modules, the whole retry loop is unit-tested against
a scripted fake camera — no hardware, no OpenCV ([tests/test_gatedcamera.cpp](../tests/test_gatedcamera.cpp)).
### The reasoning behind each rule
**Clipping outranks brightness.** A bright sky can saturate while the mean still looks perfectly reasonable,
and blown highlights are unrecoverable — a slightly dark frame is not. So clipping is checked first and
forces exposure down regardless of the mean.
**Exposure before gain when brightening; gain before exposure when darkening.** Gain buys brightness with
noise, so it is the last resort on the way up and the first thing surrendered on the way down.
**The exposure ceiling is the motion-blur budget.** A long exposure on a wind-loaded tower is itself a blur
source, so `[Camera] exposure_max_us` bounds both. This couples the two checks: the cure for darkness cannot
be allowed to cause blur. It matters more with a long lens, where angular blur scales with focal length — a
telephoto needs *more* light but tolerates *less* exposure time, and the resolution of that tension is gain
(and its noise), not time.
**Blur is relative, never absolute.** A foggy or featureless horizon is legitimately low-detail; a fixed
sharpness threshold would retry forever on it. Each frame is compared against *the same angle's own* recent
sharpness, which is scene-independent: fog stays fog, but a gust or an early trigger shows as a sudden
collapse. Blur never re-meters — reshooting is the fix, not a different exposure.
**Pinned at the limits means accept, not retry.** When the proposed correction would not move the camera
(exposure and gain both at their caps), the verdict is `saturated` and the frame is taken: further attempts
could not do better, and the budget is better spent elsewhere.
**A waypoint is never lost.** If the budget runs out, the best-scoring attempt is delivered anyway and marked
`degraded` — in the log, in the TUI `c` panel, and on the MQTT `CamEvent` — so coverage stays complete while
a degraded frame is never mistaken for a good one.
**Per-angle memory beats the camera's auto.** Accepted settings are stored per quantised heading, so the next
visit starts from a value known to work there instead of re-converging from scratch. Seeding falls through
three tiers: this angle's fresh entry → the last settings accepted *anywhere* (outdoor light drifts, so a
recent reading one heading over predicts current light better than an hour-old reading at this exact heading)
→ the configured defaults. Keying by angle rather than waypoint index means it survives grid edits and also
serves ControlCode 1 directed moves.
**White balance stays on the camera.** It is far less time-critical than exposure and is correctable in post,
unlike a blown or blurred frame.
Full parameter reference: `[Capture]` in [configuration.md](configuration.md).
## Why this shape ## Why this shape
Decoupling the control logic from the SDKs makes the core testable and the binary buildable/runnable without Decoupling the control logic from the SDKs makes the core testable and the binary buildable/runnable without

View File

@ -39,14 +39,33 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Camera` | `width`/`height` | int | `0` | ROI size in pixels; `0` = sensor maximum | | `Camera` | `width`/`height` | int | `0` | ROI size in pixels; `0` = sensor maximum |
| `Camera` | `pixel_format` | string | `RGB8` | On-camera format (RGB8 keeps de-Bayer + white balance on-camera) | | `Camera` | `pixel_format` | string | `RGB8` | On-camera format (RGB8 keeps de-Bayer + white balance on-camera) |
| `Camera` | `throughput_mbytes` | int > 0 | `250` | `DeviceLinkThroughputLimit` (MByte/s); do **not** max it (≥450 drops frames) | | `Camera` | `throughput_mbytes` | int > 0 | `250` | `DeviceLinkThroughputLimit` (MByte/s); do **not** max it (≥450 drops frames) |
| `Camera` | `stream_fps` | double > 0 | `1.0` | Paced acquisition rate; keeps on-camera auto converged. Keep low (~2 fps of 15 MB frames stalls this USB3 host; 1 fps sustains) | | `Camera` | `stream_fps` | double > 0 | `1.0` | Paced acquisition rate, **`[Capture] mode = freerun` only**. Keep low (~2 fps of 15 MB frames stalls this USB3 host; 1 fps sustains) |
| `Camera` | `exposure_auto` | bool | `true` | `ExposureAuto=Continuous` (adapt to changing light) | | `Camera` | `exposure_auto` | bool | `true` | `ExposureAuto=Continuous`. Forced off when the quality gate owns exposure |
| `Camera` | `exposure_max_us` | double | `0` | `ExposureAutoMax` cap (µs); `0` = camera default | | `Camera` | `exposure_max_us` | double | `0` | Exposure ceiling (µs); `0` = camera default. Also the gate's **motion-blur budget** |
| `Camera` | `gain_auto` | bool | `true` | `GainAuto=Continuous` | | `Camera` | `gain_auto` | bool | `true` | `GainAuto=Continuous`. Forced off when the quality gate owns exposure |
| `Camera` | `gain_max_db` | double | `0` | `GainAutoMax` cap (dB); `0` = camera default | | `Camera` | `gain_max_db` | double | `0` | Gain cap (dB); `0` = camera default. The gate's **noise ceiling** |
| `Camera` | `white_balance_auto` | bool | `true` | `BalanceWhiteAuto=Continuous` | | `Camera` | `white_balance_auto` | bool | `true` | `BalanceWhiteAuto=Continuous` |
| `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) | | `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) |
| `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) | | `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) |
| `Capture` | `mode` | `trigger`\|`freerun` | `trigger` | Per-waypoint software trigger, or the older paced stream + keep-latest |
| `Capture` | `quality_gate` | bool | `true` | Judge each frame and reshoot with corrected exposure until it passes |
| `Capture` | `max_attempts` | int ≥ 1 | `3` | Retry budget per waypoint |
| `Capture` | `min_attempts` | int 1..max | `1` | Always shoot this many and keep the sharpest (raise at windy sites) |
| `Capture` | `acquire_timeout_ms` | int | `2000` | How long to wait for a triggered frame |
| `Capture` | `settle_delay_ms` | int | `150` | Pause after standstill before the first shot |
| `Capture` | `target_mean` | 0..255 | `110` | Desired mean brightness |
| `Capture` | `mean_tolerance` | double | `12` | Band around `target_mean` that counts as good |
| `Capture` | `clip_max_fraction` | 0..1 | `0.005` | Max blown-out pixels; **outranks the mean** |
| `Capture` | `exposure_min_us` | double | `50` | Exposure floor |
| `Capture` | `damping` | (0..1] | `0.8` | Correction damping; below 1 prevents oscillation |
| `Capture` | `default_exposure_us` / `default_gain_db` | double | `5000` / `0` | Cold-start settings when the store is empty |
| `Capture` | `blur_relative_floor` | double | `0.5` | Reject below this fraction of the angle's own last good sharpness |
| `Capture` | `blur_absolute_floor` | double | `0` | Catastrophic-blur backstop; `0` = off |
| `Capture` | `metric_stride` | int ≥ 1 | `4` | Subsampling for exposure/clipping stats |
| `Capture` | `sharpness_roi_px` | int | `512` | Centre square measured at full resolution for sharpness |
| `Capture` | `exposure_store` | string | `$XDG_DATA_HOME/fire_gimbal_control/exposure_store.csv` | Per-angle exposure memory |
| `Capture` | `angle_quantum_deg` | double | `1.0` | Angle bucket size for the store |
| `Capture` | `store_stale_s` | int | `1800` | How long an entry stays trusted |
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` | | `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) | | `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) | | `Features` | `enable_camera` | bool | `true` | (reserved) |
@ -145,6 +164,46 @@ The config is read **once at startup** and cached, so if you change the XKF prof
stays stale until you **refresh** it: press `r` (or type `refresh`). That re-queries the device (briefly stays stale until you **refresh** it: press `r` (or type `refresh`). That re-queries the device (briefly
pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view. pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view.
### `[Capture]` — triggered acquisition + image quality gate
At each waypoint the scheduler moves, waits for both axes to settle, then asks for an image.
With `mode = trigger` (the default) that is one deliberate `TriggerSoftware` frame, so the image
belongs to the waypoint that asked for it — unlike the older `freerun` model, which streamed
continuously and kept whichever frame happened to arrive last, metered for wherever the camera was
pointing at the time.
**The gate.** Each frame is measured and judged, and a failure is corrected and re-shot:
| Check | Failure | Correction |
|---|---|---|
| Blown highlights (`clip_max_fraction`) | `clipped` | Shorten exposure. Checked **first**, because a bright sky can saturate while the mean still looks fine, and clipped pixels are unrecoverable |
| Mean brightness (`target_mean` ± `mean_tolerance`) | `dark` / `bright` | Brightening spends **exposure first, gain last** (gain costs noise); darkening does the reverse |
| Noise | — | Gated by the camera's reported gain against `[Camera] gain_max_db`, not estimated from pixels |
| Sharpness | `blur` | Re-shoot; never re-meters |
Exposure is clamped to `[Camera] exposure_max_us`, which therefore doubles as the **motion-blur
budget** — a long exposure on a wind-loaded tower is itself a blur source. When the camera is pinned
at its limits and still cannot reach the target, the verdict is `saturated` and the frame is
accepted: retrying could not do better. When the budget runs out, the **best attempt is kept** and
flagged `degraded` — in the log, in the TUI `c` panel, and on the MQTT `CamEvent` — so a waypoint is
never lost but a degraded image is never mistaken for a good one.
**Blur is relative, not absolute.** A foggy or featureless horizon is legitimately low-detail, so a
fixed sharpness threshold would retry forever on it. Each frame is instead compared against *the same
angle's own* recent sharpness (`blur_relative_floor`): fog stays fog, but a wind gust or an early
trigger shows up as a sudden collapse. Among the attempts taken, the sharpest wins.
**Per-angle memory.** Accepted settings are stored per quantised heading (`exposure_store`), so the
next visit starts from a setting known to work there rather than paying the camera's own
auto-exposure several frames to re-converge. Seeding falls through three tiers: this angle's fresh
entry → the last settings accepted *anywhere* (outdoor light drifts, so a recent reading one heading
over beats an hour-old reading at this exact heading) → the configured defaults. The store is keyed
by angle rather than waypoint index, so it survives edits to the scan grid and also serves
ControlCode 1 directed moves.
White balance stays on the camera (`BalanceWhiteAuto`) even under manual exposure: it is far less
time-critical and is correctable in post, unlike a blown or blurred frame.
### `[Env]` — SHT41 ambient temperature/humidity sensor ### `[Env]` — SHT41 ambient temperature/humidity sensor
Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own

View File

@ -28,7 +28,12 @@ doctest unit-test suite (`ctest`).
| # | Issue | Status | | # | Issue | Status |
|---|-------|--------| |---|-------|--------|
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. | | 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. |
| 14 | Capture sweep untested on hardware | Homing was verified live (see below), but the `MOVE → settle → trigger` sweep was **not** run with `--start`. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) still need tuning against observed `ST` behaviour, alongside #13. | | 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). |
| 15 | **Exposure convergence unverified** | The quality gate has never metered a properly-lit scene. On the rig the lens was covered, so every frame pinned at the exposure/gain ceilings and returned `saturated`; the correction path ran (dark → corrected → saturated) but never *converged onto* `target_mean`. Re-run one sweep with the lens uncovered and confirm the mean lands within `mean_tolerance` in ≤2 attempts at most waypoints. |
| 16 | **Blur check never exercised** | The relative-sharpness test needs a reference score from a genuinely good frame at that angle; the stored values were all noise-level (sharpness ~126 on black frames). `blur_relative_floor` (0.5) is therefore an untuned guess. Verify on a lit scene, and specifically that a deliberate disturbance (nudge the rig during a shot, or shorten `settle_delay_ms` to 0) is actually caught. |
| 17 | **`degraded` path never fired on hardware** | Every rig capture either passed or hit `saturated`, so the budget-exhausted branch — best-effort frame + `degraded` flag through log/TUI/`CamEvent` — is covered only by unit tests. Confirm once thresholds are tuned. |
| 18 | **`[Camera] exposure_max_us` / `gain_max_db` unset on the device** | The deployed `config.ini` leaves both at `0`, so the gate falls back to hardcoded 20 ms / 12 dB. Those need real values from a lit scene, and matter more than usual on this rig: the long optical zoom needs more light while tolerating less exposure time (blur scales with focal length). |
| 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. |
## Camera acquisition (USB3) — root cause + approach ## Camera acquisition (USB3) — root cause + approach
@ -50,16 +55,40 @@ session. Two problems were root-caused (both **host/USB3-side**, reproduced in A
20 MB → 21 frames/20 s; 3.8 MB → 41/20 s). Freerun at max rate stalls even for medium frames. 20 MB → 21 frames/20 s; 3.8 MB → 41/20 s). Freerun at max rate stalls even for medium frames.
- Also raise `usbcore.usbfs_memory_mb` to 1000 (persist via kernel cmdline; resets to 16 on reboot). - Also raise `usbcore.usbfs_memory_mb` to 1000 (persist via kernel cmdline; resets to 16 on reboot).
**Approach (config-driven, see `[Camera]` in [configuration.md](configuration.md)):** RGB8 + **2×2 binning** **Approach (config-driven, see `[Camera]`/`[Capture]` in [configuration.md](configuration.md)):** RGB8 +
(~5 MP, ~15 MB) keeps de-Bayering **and** white balance on-camera (the on-camera pipeline applies white **2×2 binning** (~5 MP, ~15 MB) keeps de-Bayering **and** white balance on-camera (the on-camera pipeline
balance before de-Bayering); a **paced low-rate stream** keeps on-camera auto-exposure/gain/white-balance applies white balance before de-Bayering). `DeviceLinkThroughputLimit` is kept conservative (~250 MB/s;
converged to the changing outdoor light, and the scheduler saves one frame per waypoint. `DeviceLinkThroughputLimit` ≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 (20 MB) + a host de-Bayer step if
is kept conservative (~250 MB/s; ≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 reduced resolution proves insufficient.
(20 MB) + a host de-Bayer step if reduced resolution proves insufficient.
**Status:** the `[Camera]` config schema, near-lossless JPEG XL, and unit tests are in place. Still to do **Acquisition is now per-waypoint software trigger** (`[Capture] mode = trigger`), not the paced stream.
on the rig: wire the in-session imaging config + paced acquisition in `VimbaCameraSource`, add the udev/boot Note what the measurements above do and do not show: they bound **frame size and pacing**, not trigger
persistence for LPM + usbfs, and verify a full sweep captures white-balanced frames with no stall. mode. The earlier conclusion that "software trigger stalls this host" was drawn from an implementation
that triggered only `cameras[0]`, never set `TriggerSource`/`TriggerMode` at all, fired a blind 4×400 ms
burst, and ran *before* the binning fix — i.e. it was pushing 61 MB frames down the path that stalls for
unrelated reasons. One frame per waypoint is a **lower** USB load than a 1 fps stream. `mode = freerun`
restores the old paced-stream behaviour if a host ever does misbehave under trigger.
**Status (rig-tested 2026-08-07).** The `[Camera]`/`[Capture]` schema, in-session imaging config,
near-lossless JPEG XL, the quality gate and unit tests are in place. On the LattePanda with the real Alvium:
**Confirmed working** — software trigger sustains a full sweep (15/15 waypoints, **0 dropped, 0 stalls, 0
acquisition failures**, across four runs), which settles the load-bearing assumption behind `mode = trigger`.
Camera configuration was verified *independently* through Allied Vision's own `vmbpy` (`RGB8`, binning 2,
2256×2256, `ExposureAuto=Off`, `GainAuto=Off`, `TriggerMode=On`, `TriggerSource=Software`,
`TriggerSelector=FrameStart`), and `ExposureTime` writes land within quantisation (`want 20000 →
readback 19980.6`). Real-motor integration, per-angle store persistence + warm start, and clean shutdown
(camera not left wedged) all verified.
**Not confirmed** — the lens was covered for the whole test, so every frame was black (mean ~1/255) and the
gate could only ever report `saturated`. Everything that depends on seeing a real scene is still open: see
**#15#18** in *Open / needs hardware confirmation* above, plus **#19** for the USB preconditions.
A useful diagnostic for the "is it the optics or the code?" question: with frame status validated and gain 0,
a **2-second** exposure produced mean 3.6/255 (brightest pixel 80). A lit room at gain 0 should be well
exposed near 1030 ms, so that is ~1000× too little light — conclusively optical, not software. `vmbpy`
scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERMs Python and skips
`stop_streaming()`, which leaves the camera unusable until the next USB reset.
## Verification caveats ## Verification caveats
@ -68,8 +97,11 @@ persistence for LPM + usbfs, and verify a full sweep captures white-balanced fra
`cmake/Paho.cmake` for the toolchain-compat shims). `cmake/Paho.cmake` for the toolchain-compat shims).
- **Serial protocol verified live against real firmware** (LattePanda, this session): `ENABLE`/`HOME`/`SPEED` - **Serial protocol verified live against real firmware** (LattePanda, this session): `ENABLE`/`HOME`/`SPEED`
reach the firmware, the `ST` telemetry parses with zero unparsed lines across a full session, and a live reach the firmware, the `ST` telemetry parses with zero unparsed lines across a full session, and a live
`--init` drove a clean re-home of both axes to `READY`. **Still unverified on hardware:** the capture sweep `--init` drove a clean re-home of both axes to `READY`.
(#14) and real-camera (Vimba) frame capture — earlier tests used `--mock-camera`. - **Real-camera capture verified on the rig (2026-08-07)**: a live `--init --start` run with the real Alvium
and real motor captured 15/15 waypoints, one software-triggered frame each, 0 dropped and 0 stalls. What
this does **not** cover is image *content* — the lens was covered, so exposure/blur behaviour on a real
scene remains open (#15#17).
- **Makefile parity** is moot: the Makefile was removed in favour of CMake, and the full - **Makefile parity** is moot: the Makefile was removed in favour of CMake, and the full
`WITH_VIMBA=ON WITH_MQTT=ON` CMake build now runs on the device. `WITH_VIMBA=ON WITH_MQTT=ON` CMake build now runs on the device.
- **Demo mode** copies `bin/x64/Release/test_smoke.jxl`, resolved relative to the working directory. Run from a - **Demo mode** copies `bin/x64/Release/test_smoke.jxl`, resolved relative to the working directory. Run from a
@ -77,6 +109,18 @@ persistence for LPM + usbfs, and verify a full sweep captures white-balanced fra
## Possible follow-ups (not done) ## Possible follow-ups (not done)
- **Spurious `could not set AcquisitionFrameRateEnable=false` warning** on every camera start.
`AcquisitionFrameRateEnable` and `TriggerMode` lock each other: while `TriggerMode=On` the frame-rate
control is read-only, and vice versa. Re-configuring an already-triggered camera therefore tries to write a
locked feature that is *already* at the wanted value. All four state transitions were traced and the
ordering in `configureCamera` is correct — this is cosmetic — but it is misleading noise that would mask a
genuine failure of that constraint. Fix by skipping the write when the value already matches.
- **Per-angle white balance.** WB is deliberately left on the camera (`BalanceWhiteAuto=Continuous`) since it
is correctable in post; storing and seeding it per angle like exposure is the natural next step once
exposure gating is proven on a lit scene.
- **Decaying sharpness reference.** `ExposureStore` keeps the *last accepted* sharpness as the blur
reference. If a scene changes character (fog rolling in), that baseline is stale in the wrong direction. A
recent-window or decaying value would be more robust — worth revisiting with real data from #16.
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown). - Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
- Make the camera index→label map fully config-driven. (JPEG XL distance/effort and the on-camera imaging - Make the camera index→label map fully config-driven. (JPEG XL distance/effort and the on-camera imaging
settings are now config-driven under `[Camera]` — see [configuration.md](configuration.md).) settings are now config-driven under `[Camera]` — see [configuration.md](configuration.md).)

View File

@ -19,6 +19,10 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration | | [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
| [include/fgc/CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h), [src/core/CalibrationRoutine.cpp](../src/core/CalibrationRoutine.cpp) | `gimbal calib` worker thread: home-if-needed → pitch sweep at the first yaw position → pitch 0° → switch IMU to a no-mag XKF profile → IMU no-rotation + heading reset → yaw sweep, fitting degrees↔counts; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()` | | [include/fgc/CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h), [src/core/CalibrationRoutine.cpp](../src/core/CalibrationRoutine.cpp) | `gimbal calib` worker thread: home-if-needed → pitch sweep at the first yaw position → pitch 0° → switch IMU to a no-mag XKF profile → IMU no-rotation + heading reset → yaw sweep, fitting degrees↔counts; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()` |
| [include/fgc/MtiProtocol.h](../include/fgc/MtiProtocol.h), [src/core/MtiProtocol.cpp](../src/core/MtiProtocol.cpp) | Xsens MTi binary protocol: `MtiFramer` (checksum framing), config-message builders, `parseMTData``ImuSample` (temp/acc/gyr/mag/euler), config-readback query builders + `applyImuConfigAck`/`finalizeImuConfig` → `ImuDeviceConfig` (product/firmware/device-id/output mode+settings/sample rate/**XKF scenario**); orientation-control builders `msgSetNoRotation` (gyro-bias update) + `msgResetOrientation` (heading reset / store) + `msgSetFilterProfile` (select XKF profile) + `pickNoMagProfile` (choose a magnetometer-free profile from the available list) | | [include/fgc/MtiProtocol.h](../include/fgc/MtiProtocol.h), [src/core/MtiProtocol.cpp](../src/core/MtiProtocol.cpp) | Xsens MTi binary protocol: `MtiFramer` (checksum framing), config-message builders, `parseMTData``ImuSample` (temp/acc/gyr/mag/euler), config-readback query builders + `applyImuConfigAck`/`finalizeImuConfig` → `ImuDeviceConfig` (product/firmware/device-id/output mode+settings/sample rate/**XKF scenario**); orientation-control builders `msgSetNoRotation` (gyro-bias update) + `msgResetOrientation` (heading reset / store) + `msgSetFilterProfile` (select XKF profile) + `pickNoMagProfile` (choose a magnetometer-free profile from the available list) |
| [include/fgc/ImageQuality.h](../include/fgc/ImageQuality.h), [src/core/ImageQuality.cpp](../src/core/ImageQuality.cpp) | `analyzeFrame``ImageMetrics` (mean luma, clipped/dark fraction, variance-of-Laplacian sharpness) over a raw frame buffer. No OpenCV, so it lives in `fgc_core` and is testable on synthetic buffers |
| [include/fgc/ExposurePolicy.h](../include/fgc/ExposurePolicy.h), [src/core/ExposurePolicy.cpp](../src/core/ExposurePolicy.cpp) | The exposure/gain control law: `evaluate``Verdict` (accept + reason + corrected settings), `score` for ranking attempts. Clipping outranks the mean; exposure before gain when brightening, gain before exposure when darkening; damped to prevent oscillation |
| [include/fgc/ExposureStore.h](../include/fgc/ExposureStore.h), [src/core/ExposureStore.cpp](../src/core/ExposureStore.cpp) | Per-angle exposure memory (CSV), keyed by quantised yaw/pitch. Three-tier `seed()` (fresh angle entry → last accepted anywhere → defaults) + `referenceSharpness()` for the relative blur check |
| [include/fgc/GatedCameraSource.h](../include/fgc/GatedCameraSource.h), [src/core/GatedCameraSource.cpp](../src/core/GatedCameraSource.cpp) | `ICameraSource` **decorator** turning one `trigger()` into shoot → judge → correct → reshoot, seeded from the store; keeps the best attempt and flags it `degraded` when the budget runs out. Depends only on the interface + pure modules, so the whole loop is testable against a fake camera |
| [include/fgc/sensors/Sht41Protocol.h](../include/fgc/sensors/Sht41Protocol.h), [src/sensors/Sht41Protocol.cpp](../src/sensors/Sht41Protocol.cpp) | Sensirion SHT4x wire protocol, pure: command bytes, `sht41Crc8` (poly 0x31/init 0xFF), `decodeSht41Measurement` (6-byte response → `Sht41Reading`, CRC-checked, raw→°C/%RH with RH clamped 0..100) | | [include/fgc/sensors/Sht41Protocol.h](../include/fgc/sensors/Sht41Protocol.h), [src/sensors/Sht41Protocol.cpp](../src/sensors/Sht41Protocol.cpp) | Sensirion SHT4x wire protocol, pure: command bytes, `sht41Crc8` (poly 0x31/init 0xFF), `decodeSht41Measurement` (6-byte response → `Sht41Reading`, CRC-checked, raw→°C/%RH with RH clamped 0..100) |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration | | [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` | | [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
@ -82,7 +86,8 @@ firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`.
`*_available` flag. `*_available` flag.
### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h)) ### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10), `timestamp_ms`. `tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10), `timestamp_ms`,
and `degraded` (the quality gate exhausted its attempts and kept its best effort).
Serialized to the CamEvent JSON payload (see [mqtt-api.md](mqtt-api.md)). Serialized to the CamEvent JSON payload (see [mqtt-api.md](mqtt-api.md)).
### `EnvEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h)) ### `EnvEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
@ -96,7 +101,8 @@ within 2× the configured poll period, so a silently dead sensor reads as absent
value. Surfaced as `EnvView` in the TUI (`i` view) and as `SensorsView::env` in the compact panel. value. Surfaced as `EnvView` in the TUI (`i` view) and as `SensorsView::env` in the compact panel.
### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h)) ### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`. Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`, and `degraded`
(carried through to the `CamEvent`).
### `ImuSample` ([MtiProtocol.h](../include/fgc/MtiProtocol.h)) ### `ImuSample` ([MtiProtocol.h](../include/fgc/MtiProtocol.h))
One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s), One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s),

View File

@ -59,7 +59,7 @@ When a ControlCode message arrives, the program echoes the current code back on
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`: Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
```json ```json
{ "fwt":"ExampleTower", "cam":"RGB", "hdg":1373, "pit":300, "time":1719312345678 } { "fwt":"ExampleTower", "cam":"RGB", "hdg":1373, "pit":300, "time":1719312345678, "degraded":false }
``` ```
| Field | Type | Meaning | | Field | Type | Meaning |
@ -69,6 +69,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
| `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time | | `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time |
| `pit` | int | Gimbal pitch elevation **× 10** at capture time (derived from the pitch encoder) | | `pit` | int | Gimbal pitch elevation **× 10** at capture time (derived from the pitch encoder) |
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) | | `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
| `degraded` | bool | `true` when the quality gate ran out of attempts and this is its best effort rather than a frame that passed — see `[Capture]` in [configuration.md](configuration.md). Consumers should weight or skip these rather than treating them as good |
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file > The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`. > for a given event: `<RGB|ACR|NIR>/<time>.jxl`.

View File

@ -27,8 +27,16 @@ Keep entries dated; move shipped items into the reference docs.
a new `IControlChannel` implementation (richer routing/durable queues, but must a new `IControlChannel` implementation (richer routing/durable queues, but must
re-engineer retained "last value" semantics). The abstraction seam already re-engineer retained "last value" semantics). The abstraction seam already
exists (`IControlChannel`). _(TODO: confirm approach, target release.)_ exists (`IControlChannel`). _(TODO: confirm approach, target release.)_
- **RGB camera.** Integrate the production RGB sensor end-to-end (acquire → JXL → - **RGB camera.** _Largely landed._ The Alvium 1800 U-2040c runs end-to-end on the
CamEvent); enables the `camera/rgb` self-test. _(TODO: model/SDK, mounting.)_ rig (software-triggered acquire → quality gate → JXL → `CamEvent`), so the
`camera/rgb` self-test is no longer blocked on integration. What remains is
tuning against a real scene — see [known-issues.md](known-issues.md) #15#18 —
and the capture logic itself is described in
[architecture.md](architecture.md#image-capture).
- **Per-angle white balance.** Exposure and gain are already remembered per
heading; WB is still left to the camera's continuous auto. Extending the store
to WB ratios is the natural follow-on once exposure gating is proven on a lit
scene. _(TODO: confirm it is worth the extra state.)_
- **Thermal camera.** Add the thermal sensor with radiometric handling (NUC, - **Thermal camera.** Add the thermal sensor with radiometric handling (NUC,
temperature range); enables the `camera/thermal` self-test. _(TODO: model/SDK, temperature range); enables the `camera/thermal` self-test. _(TODO: model/SDK,
calibration workflow.)_ calibration workflow.)_

View File

@ -45,11 +45,24 @@ active (MQTT today, RabbitMQ once integrated — see the MQTT→RabbitMQ migrati
gaps. _Metrics:_ mean/95th round-trip latency, loss %, out-of-order count. The gaps. _Metrics:_ mean/95th round-trip latency, loss %, out-of-order count. The
message-bus analogue of `system/link`; for RabbitMQ also surface confirms/acks. message-bus analogue of `system/link`; for RabbitMQ also surface confirms/acks.
## 3. `camera / *` — imaging — *blocked on: RGB + thermal cameras on the rig* ## 3. `camera / *` — imaging — *`rgb` unblocked; `thermal` blocked on the thermal camera*
One leaf per physical sensor; shares a common frame-quality core. Uses the existing One leaf per physical sensor; shares a common frame-quality core. Uses the existing
`ICameraSource`/`ImagePipeline`. `ICameraSource`/`ImagePipeline`.
**The shared frame-quality core already exists.** `analyzeFrame()` in
[ImageQuality.h](../include/fgc/ImageQuality.h) computes mean luma, clipped/dark fraction and a
variance-of-Laplacian sharpness score over a raw buffer, with no OpenCV dependency, and is unit-tested
against synthetic frames. The `camera / rgb` leaf should call it rather than reimplementing the histogram
and sharpness checks below — it already covers "usable histogram (not clipped)" and "sharpness/focus
metric". What it does **not** yet provide is dead/hot-pixel counting, realized-FPS measurement, timestamp
monotonicity or encode-throughput timing.
The RGB camera itself is integrated and verified on the rig (see
[architecture.md](architecture.md#image-capture)), so this leaf is now an implementation task rather than a
blocked one. Note that a self-test asserting exposure health needs the lens uncovered and the limits in
`[Camera]` tuned first — [known-issues.md](known-issues.md) #15#18.
- **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the - **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the
configured rate; exposure/gain give a usable histogram (not clipped); few configured rate; exposure/gain give a usable histogram (not clipped); few
dead/hot pixels; frames sharp when focused; timestamps strictly increase; few dead/hot pixels; frames sharp when focused; timestamps strictly increase; few

View File

@ -20,8 +20,11 @@ namespace fgc {
// 2. reads motor telemetry (per-axis encoder counts + state), // 2. reads motor telemetry (per-axis encoder counts + state),
// 3. runs the capture cycle as a move -> settle -> trigger machine: when the // 3. runs the capture cycle as a move -> settle -> trigger machine: when the
// interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next // interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next
// target, waits until both axes report standstill at that target, then // target, waits until both axes report standstill at that target, then asks
// software-triggers the cameras. // the camera for an image. Whether that is a single triggered frame, and
// whether it is judged and re-shot until it passes, is decided below this
// layer (see GatedCameraSource) - the scheduler only cares that trigger()
// eventually succeeds, and retries next tick if it does not.
// //
// ControlCode 0 = automatic sweep through the ScanGrid (ping-pong); // ControlCode 0 = automatic sweep through the ScanGrid (ping-pong);
// ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held). // ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held).

View File

@ -56,6 +56,47 @@ struct CameraConfig {
int jxl_effort = 4; // libjxl effort 1..9 int jxl_effort = 4; // libjxl effort 1..9
}; };
// [Capture]: how a frame is acquired at each waypoint, and the quality bar it has
// to clear. See docs/configuration.md.
struct CaptureConfig {
// "trigger" = one deliberate software-triggered frame per attempt (default).
// "freerun" = the older paced stream + keep-latest, kept as a fallback in case
// software trigger misbehaves on a given host.
std::string mode = "trigger";
// Judge each frame and reshoot with corrected exposure until it passes. With
// this off, `mode` still applies but the first frame is always kept.
bool quality_gate = true;
int max_attempts = 3;
int min_attempts = 1; // >1 always shoots extra and keeps the sharpest
int acquire_timeout_ms = 2000;
int settle_delay_ms = 150; // pause after standstill before the first shot
// Exposure targets (see ExposurePolicy).
double target_mean = 110.0;
double mean_tolerance = 12.0;
double clip_max_fraction = 0.005;
double exposure_min_us = 50.0;
double damping = 0.8;
// Starting point when the store has nothing to offer.
double default_exposure_us = 5000.0;
double default_gain_db = 0.0;
// Blur is judged relative to the same angle's own history; see ExposurePolicy.
double blur_relative_floor = 0.5;
double blur_absolute_floor = 0.0; // 0 disables the absolute backstop
// Metric cost knobs.
int metric_stride = 4;
int sharpness_roi_px = 512;
// Per-angle exposure memory. Empty path => resolved to a default beside the
// logs at load time.
std::string exposure_store;
double angle_quantum_deg = 1.0;
long long store_stale_s = 1800;
};
struct PathsConfig { struct PathsConfig {
// Where captured .jxl images are written. Supports leading ~ and $ENV // Where captured .jxl images are written. Supports leading ~ and $ENV
// expansion. Empty => resolved to a sensible default at load time. // expansion. Empty => resolved to a sensible default at load time.
@ -144,6 +185,7 @@ struct AppConfig {
NetworkConfig network; NetworkConfig network;
SerialConfig serial; SerialConfig serial;
CameraConfig camera; CameraConfig camera;
CaptureConfig capture; // [Capture] acquisition mode + image quality gate
PathsConfig paths; PathsConfig paths;
FeaturesConfig features; FeaturesConfig features;
LoggingConfig logging; LoggingConfig logging;

View File

@ -0,0 +1,51 @@
#pragma once
#include "fgc/ImageQuality.h"
#include <string>
namespace fgc {
// The exposure/gain control law: given what an image turned out like, decide
// whether to keep it and what to change if not. Pure (no camera, no I/O, no
// clock) so the whole correction strategy is unit-testable.
struct CaptureSettings {
double exposure_us = 0.0;
double gain_db = 0.0;
};
struct PolicyParams {
double target_mean = 110.0; // desired mean luma, 0..255
double mean_tolerance = 12.0; // accept within +-this of the target
double clip_max_fraction = 0.005; // 0.5% blown pixels is the ceiling
double exposure_min_us = 50.0;
double exposure_max_us = 20000.0; // doubles as the motion-blur budget
double gain_max_db = 12.0;
double damping = 0.8; // <1 damps the correction, preventing oscillation
// Blur is judged RELATIVE to the same angle's own history: a foggy horizon is
// legitimately low-detail, so an absolute threshold would retry forever on it.
double blur_relative_floor = 0.5; // retry below this fraction of the reference
double blur_absolute_floor = 0.0; // 0 disables the catastrophic-blur backstop
};
struct Verdict {
bool accept = false;
std::string reason; // "ok" / "clipped" / "dark" / "bright" / "blur" / "saturated"
CaptureSettings next; // settings to use for the next attempt (== current if accepting)
};
// Judge one attempt. `reference_sharpness` is this angle's recent sharpness; pass
// 0 when unknown, which skips the relative blur check.
//
// Precedence matters: clipping outranks the mean, because a bright sky can blow
// out while the average still looks perfectly reasonable - and blown highlights
// are unrecoverable, whereas a slightly dark frame is not.
Verdict evaluate(const ImageMetrics& m, const CaptureSettings& current, const PolicyParams& p,
double reference_sharpness = 0.0);
// Rank attempts so "best so far" is well defined: sharpness discounted by how far
// the exposure strayed. Higher is better.
double score(const ImageMetrics& m, const PolicyParams& p);
} // namespace fgc

View File

@ -0,0 +1,78 @@
#pragma once
#include "fgc/ExposurePolicy.h"
#include <map>
#include <optional>
#include <string>
namespace fgc {
// Per-angle exposure memory: what settings worked last time the gimbal looked
// this way. This is what lets a triggered capture start from a setting known to
// be roughly right, instead of paying the camera's own auto-exposure several
// frames to re-converge at every waypoint.
//
// Keyed by QUANTISED yaw/pitch rather than waypoint index, so the memory survives
// an edit to the scan grid and also serves ControlCode 1 directed moves, which
// have no waypoint index at all.
struct StoreEntry {
double yaw_deg = 0.0;
double pitch_deg = 0.0;
double exposure_us = 0.0;
double gain_db = 0.0;
double sharpness = 0.0; // sharpness of the last accepted frame here
double mean_luma = 0.0;
long long timestamp_ms = 0; // when this entry was last updated (epoch ms)
int attempts = 0; // attempts the last visit needed
};
class ExposureStore {
public:
// quantum_deg: angle bucket size. stale_s: how old an entry may be and still
// be trusted as this angle's seed.
explicit ExposureStore(std::string path, double quantum_deg = 1.0, long long stale_s = 1800);
// Load from / save to the CSV. Both are tolerant: a missing file is simply an
// empty store, and a malformed row is skipped rather than failing the load, so
// a corrupt line can never stop a scan from running.
bool load();
bool save() const;
// Bucket key for an angle, e.g. "y137_p-5".
static std::string keyFor(double yaw_deg, double pitch_deg, double quantum_deg);
std::optional<StoreEntry> find(double yaw_deg, double pitch_deg) const;
std::size_t size() const { return entries_.size(); }
const std::string& path() const { return path_; }
// Three-tier seed, in order of how well each predicts the CURRENT light:
// 1. this angle's own entry, if fresher than stale_s;
// 2. else the last settings accepted anywhere - light changes affect all
// angles together, so a 30 s old reading one heading over beats an
// hour-old reading at this exact heading;
// 3. else the configured defaults.
CaptureSettings seed(double yaw_deg, double pitch_deg, long long now_ms,
const CaptureSettings& defaults) const;
// This angle's reference sharpness for the relative blur check, or 0 when
// unknown or too old to compare against.
double referenceSharpness(double yaw_deg, double pitch_deg, long long now_ms) const;
// Record an accepted capture.
void update(double yaw_deg, double pitch_deg, const CaptureSettings& settings,
const ImageMetrics& metrics, long long now_ms, int attempts);
private:
bool fresh(const StoreEntry& e, long long now_ms) const;
std::string path_;
double quantum_deg_;
long long stale_s_;
std::map<std::string, StoreEntry> entries_;
std::optional<CaptureSettings> last_accepted_;
long long last_accepted_ms_ = 0;
};
} // namespace fgc

View File

@ -0,0 +1,91 @@
#pragma once
#include "fgc/ExposurePolicy.h"
#include "fgc/ExposureStore.h"
#include "fgc/ICameraSource.h"
#include "fgc/ImageQuality.h"
#include <functional>
#include <memory>
#include <string>
namespace fgc {
// Outcome of one gated capture, for logging and the TUI.
struct CaptureReport {
bool captured = false; // a frame was delivered at all
bool degraded = false; // budget exhausted; this is the best effort
int attempts = 0;
std::string reason; // verdict of the attempt we kept
ImageMetrics metrics; // metrics of the frame we kept
CaptureSettings settings; // settings that produced it
double yaw_deg = 0.0, pitch_deg = 0.0;
};
// An ICameraSource that wraps another one and turns a single trigger() into
// "shoot, judge, correct, reshoot until acceptable", seeded from per-angle memory.
//
// It is a DECORATOR so that nothing above it changes: CaptureScheduler still just
// calls trigger(), and the accepted frame still reaches ImagePipeline through the
// ordinary frame callback. Because it depends only on ICameraSource plus the pure
// policy/metric modules, the entire retry loop is unit-testable against a fake
// camera - no hardware, no OpenCV.
class GatedCameraSource : public ICameraSource {
public:
struct Params {
bool enabled = true;
int max_attempts = 3;
int min_attempts = 1; // >1 always shoots extra and keeps the sharpest
int acquire_timeout_ms = 2000;
int settle_delay_ms = 0; // extra pause after the move, before attempt 1
CaptureSettings defaults; // used when the store has nothing
QualityParams quality;
PolicyParams policy;
};
// angle: where the gimbal is pointing now (degrees), used to key the store.
// now_ms: injectable clock, for deterministic tests.
GatedCameraSource(std::unique_ptr<ICameraSource> inner, ExposureStore* store, Params params,
std::function<std::pair<double, double>()> angle,
std::function<long long()> now_ms = {},
std::function<void(int)> sleep_ms = {});
~GatedCameraSource() override;
void open() override;
void close() override;
void start() override;
void stop() override;
bool trigger() override;
bool setFrameRate(double fps) override;
void setFrameCallback(FrameCallback cb) override;
int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override;
// Pass-throughs, so the gate can itself be wrapped or inspected.
bool acquireFrame(Frame& out, int timeout_ms) override;
bool setExposure(double us) override;
bool setGain(double db) override;
bool setAutoExposureGain(bool on) override;
double currentGain() override;
// Result of the most recent trigger(), for the log and the TUI.
const CaptureReport& lastReport() const { return last_report_; }
// How many captures this session fell back to a best effort. A rising count is
// the signal that thresholds or the exposure limits need revisiting.
long long degradedTotal() const { return degraded_total_; }
private:
std::unique_ptr<ICameraSource> inner_;
ExposureStore* store_; // not owned; may be null
Params params_;
std::function<std::pair<double, double>()> angle_;
std::function<long long()> now_ms_;
std::function<void(int)> sleep_ms_;
FrameCallback callback_;
CaptureReport last_report_;
long long degraded_total_ = 0;
};
} // namespace fgc

View File

@ -15,6 +15,10 @@ struct Frame {
int channels = 0; // 1 (mono) or 3 (RGB) int channels = 0; // 1 (mono) or 3 (RGB)
long long timestamp_ms = 0; // Unix epoch ms long long timestamp_ms = 0; // Unix epoch ms
int cam_id = 0; // index into the configured camera list int cam_id = 0; // index into the configured camera list
// Set when the quality gate ran out of attempts and emitted its best effort
// anyway. Carried through to the CamEvent so degraded frames stay identifiable
// downstream instead of being silently mixed in with good ones.
bool degraded = false;
}; };
// Live per-camera identity, sensor telemetry, and acquisition stats, for the // Live per-camera identity, sensor telemetry, and acquisition stats, for the
@ -46,7 +50,8 @@ struct CameraDeviceInfo {
}; };
// Abstraction over the camera array. Implemented by VimbaCameraSource (Allied // Abstraction over the camera array. Implemented by VimbaCameraSource (Allied
// Vision Vimba X) and MockCameraSource (synthetic frames, no hardware). // Vision Vimba X), MockCameraSource (synthetic frames, no hardware), and
// GatedCameraSource (a decorator adding the image-quality gate).
// //
// Completed frames are delivered to the callback set via setFrameCallback(); // Completed frames are delivered to the callback set via setFrameCallback();
// the consumer (ImagePipeline) encodes and stores them. // the consumer (ImagePipeline) encodes and stores them.
@ -61,12 +66,34 @@ public:
virtual void start() = 0; // begin acquisition virtual void start() = 0; // begin acquisition
virtual void stop() = 0; virtual void stop() = 0;
// Software-trigger a capture. Returns true on success. // Capture one image and deliver it to the frame callback. Returns true on
// success. In free-run this hands over the freshest streamed frame; behind the
// quality gate it runs the whole acquire/judge/correct loop.
virtual bool trigger() = 0; virtual bool trigger() = 0;
// Optional: change the acquisition frame rate. Default no-op (e.g. mocks). // Optional: change the acquisition frame rate. Default no-op (e.g. mocks).
virtual bool setFrameRate(double /*fps*/) { return false; } virtual bool setFrameRate(double /*fps*/) { return false; }
// ---- Deliberate acquisition + manual exposure control ----
// Used by GatedCameraSource to shoot, measure and reshoot. Defaults are no-ops
// (following setFrameRate above) so mocks and existing tests need no changes;
// a source that returns false from acquireFrame simply cannot be gated.
// Acquire exactly ONE frame and return it here rather than via the callback,
// so the caller can judge it before deciding whether to keep it.
virtual bool acquireFrame(Frame& /*out*/, int /*timeout_ms*/) { return false; }
virtual bool setExposure(double /*microseconds*/) { return false; }
virtual bool setGain(double /*db*/) { return false; }
// Turn the camera's own continuous auto-exposure/gain on or off. The gate owns
// exposure, so it switches these off; free-run leaves them on.
virtual bool setAutoExposureGain(bool /*on*/) { return false; }
// Gain actually in effect, in dB (what the camera reports, not what we asked
// for) - the noise proxy the policy gates on.
virtual double currentGain() { return 0.0; }
virtual void setFrameCallback(FrameCallback cb) = 0; virtual void setFrameCallback(FrameCallback cb) = 0;
// Number of cameras this source manages. // Number of cameras this source manages.

View File

@ -20,6 +20,10 @@ struct CamEvent {
int heading_decideg = 0; // yaw heading * 10 (one decimal as integer) int heading_decideg = 0; // yaw heading * 10 (one decimal as integer)
int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer) int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer)
long long timestamp_ms = 0; // Unix epoch ms; matches the image filename long long timestamp_ms = 0; // Unix epoch ms; matches the image filename
// True when the quality gate ran out of attempts and this is its best effort
// rather than a frame that passed. Published so consumers can weight or skip
// degraded images instead of silently treating them as good.
bool degraded = false;
}; };
// Ambient temperature/humidity reading published once per fresh sensor sample. // Ambient temperature/humidity reading published once per fresh sensor sample.

View File

@ -0,0 +1,44 @@
#pragma once
#include "fgc/ICameraSource.h"
namespace fgc {
// Image-quality metrics over a raw captured frame. Pure (no OpenCV, no I/O) so it
// lives in fgc_core and is unit-testable against synthetic buffers - mirrors
// MtiProtocol / Sht41Protocol. The app target links OpenCV, but fgc_core and the
// test target do not, and these metrics are a few dozen lines by hand.
struct QualityParams {
// Exposure/clipping stats sample every Nth pixel: 16x cheaper at stride 4 and
// statistically indistinguishable on a 5 MP natural scene. Note it CAN alias on
// strictly periodic detail whose period divides the stride (a synthetic
// checkerboard is the pathological case), biasing the mean; set stride = 1 if a
// scene ever turns out to be regular enough for that to matter.
int stride = 4;
// Sharpness is measured at FULL resolution on a centre square of this size -
// subsampling destroys exactly the high-frequency content it measures.
int sharpness_roi_px = 512;
// A pixel at or above this counts as clipped (blown highlight).
int clip_level = 254;
// A pixel at or below this counts as crushed black.
int dark_level = 1;
};
struct ImageMetrics {
bool valid = false; // false when the frame was empty/malformed
double mean_luma = 0.0; // 0..255
double clipped_fraction = 0.0; // pixels with ANY channel >= clip_level
double dark_fraction = 0.0; // pixels with ALL channels <= dark_level
double sharpness = 0.0; // variance of the Laplacian over the centre ROI
// Filled in by the caller from the camera's own Gain feature, not derived from
// pixels: the camera reports gain exactly, whereas estimating noise from a
// textured scene is unreliable.
double gain_db = 0.0;
};
// Compute the metrics for one frame. Supports 1 channel (mono) and 3 (RGB8).
// Returns `valid = false` for anything else or for an empty buffer.
ImageMetrics analyzeFrame(const Frame& frame, const QualityParams& params = {});
} // namespace fgc

View File

@ -30,6 +30,10 @@ std::string defaultOutputDir();
// ($XDG_DATA_HOME/fire_gimbal_control/logs, else ~/.local/share/...). // ($XDG_DATA_HOME/fire_gimbal_control/logs, else ~/.local/share/...).
std::string defaultLogDir(); std::string defaultLogDir();
// Default path for the per-angle exposure store
// ($XDG_DATA_HOME/fire_gimbal_control/exposure_store.csv, else ~/.local/share/...).
std::string defaultExposureStore();
// "<prefix>_YYYYMMDD-HHMMSS.log" using the local clock. // "<prefix>_YYYYMMDD-HHMMSS.log" using the local clock.
std::string timestampedLogName(const std::string& prefix); std::string timestampedLogName(const std::string& prefix);

View File

@ -8,15 +8,23 @@
namespace fgc { namespace fgc {
// Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the // Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the
// cameras in CameraConfig, applies the on-camera imaging settings in-session, runs a // cameras in CameraConfig and applies the on-camera imaging settings in-session.
// PACED FREE-RUN stream (fixed low frame rate, no software trigger - large frames //
// streamed fast stall this USB3 host), and keeps the latest complete frame; trigger() // Two acquisition modes, chosen with setAcquisitionMode() before start():
// delivers it to the callback once per waypoint. Encode/save lives in ImagePipeline. // TRIGGERED (default) - TriggerMode=On + TriggerSource=Software. acquireFrame()
// fires one TriggerSoftware and returns that frame, so the image belongs to the
// waypoint that asked for it and its exposure can be set deliberately.
// FREE-RUN - a paced low-rate stream; trigger() hands over the latest complete
// frame, restarting acquisition if the stream has stalled. Kept as a fallback.
// Encode/save lives in ImagePipeline; quality gating lives in GatedCameraSource.
class VimbaCameraSource : public ICameraSource { class VimbaCameraSource : public ICameraSource {
public: public:
explicit VimbaCameraSource(CameraConfig config); explicit VimbaCameraSource(CameraConfig config);
~VimbaCameraSource() override; ~VimbaCameraSource() override;
// Must be called before start() to take effect (features are applied there).
void setAcquisitionMode(bool triggered, bool manual_exposure);
void open() override; void open() override;
void close() override; void close() override;
void start() override; void start() override;
@ -27,6 +35,12 @@ public:
int cameraCount() const override; int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override; std::vector<CameraDeviceInfo> deviceInfo() override;
bool acquireFrame(Frame& out, int timeout_ms) override;
bool setExposure(double us) override;
bool setGain(double db) override;
bool setAutoExposureGain(bool on) override;
double currentGain() override;
private: private:
struct Impl; struct Impl;
std::unique_ptr<Impl> impl_; std::unique_ptr<Impl> impl_;

View File

@ -4,6 +4,7 @@
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <chrono> #include <chrono>
#include <cmath>
#include <string> #include <string>
namespace fgc { namespace fgc {
@ -11,6 +12,10 @@ namespace fgc {
// Synthetic camera for development without hardware. On trigger() it generates // Synthetic camera for development without hardware. On trigger() it generates
// a small RGB gradient frame (varying with time so successive captures differ) // a small RGB gradient frame (varying with time so successive captures differ)
// and delivers it to the frame callback. // and delivers it to the frame callback.
//
// It also implements the deliberate-acquisition API with a crude sensor model
// (brightness proportional to exposure x gain), so the quality gate's whole
// shoot -> judge -> correct loop can be exercised end to end with --mock-camera.
class MockCameraSource : public ICameraSource { class MockCameraSource : public ICameraSource {
public: public:
explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480) explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480)
@ -24,24 +29,7 @@ public:
bool trigger() override { bool trigger() override {
if (!callback_) return false; if (!callback_) return false;
LOG_TRACE_CAT(LogCat::Camera) << "TX trigger cam0 (mock)"; LOG_TRACE_CAT(LogCat::Camera) << "TX trigger cam0 (mock)";
Frame f; Frame f = synth();
f.width = width_;
f.height = height_;
f.channels = 3;
f.cam_id = 0;
f.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
f.data.resize(static_cast<size_t>(width_) * height_ * 3);
const uint8_t phase = static_cast<uint8_t>(f.timestamp_ms);
for (uint32_t y = 0; y < height_; ++y) {
for (uint32_t x = 0; x < width_; ++x) {
size_t i = (static_cast<size_t>(y) * width_ + x) * 3;
f.data[i + 0] = static_cast<uint8_t>(x + phase);
f.data[i + 1] = static_cast<uint8_t>(y + phase);
f.data[i + 2] = phase;
}
}
LOG_TRACE_CAT(LogCat::Camera) LOG_TRACE_CAT(LogCat::Camera)
<< "RX frame cam0 " << width_ << 'x' << height_ << ' ' << f.data.size() << "B (mock)"; << "RX frame cam0 " << width_ << 'x' << height_ << ' ' << f.data.size() << "B (mock)";
++frames_; ++frames_;
@ -52,6 +40,31 @@ public:
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); } void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
int cameraCount() const override { return count_; } int cameraCount() const override { return count_; }
// ---- Deliberate acquisition, for the quality gate ----
bool acquireFrame(Frame& out, int /*timeout_ms*/) override {
out = synth();
++frames_;
LOG_TRACE_CAT(LogCat::Camera)
<< "RX frame cam0 " << width_ << 'x' << height_ << " exp=" << exposure_us_
<< "us gain=" << gain_db_ << "dB (mock)";
return true;
}
bool setExposure(double us) override {
exposure_us_ = us;
return true;
}
bool setGain(double db) override {
gain_db_ = db;
return true;
}
bool setAutoExposureGain(bool on) override {
auto_on_ = on;
return true;
}
double currentGain() override { return gain_db_; }
// Synthetic device info so the expanded camera view renders (and is testable) // Synthetic device info so the expanded camera view renders (and is testable)
// without hardware. Non-hardware fields (temperature, exposure...) stay at 0. // without hardware. Non-hardware fields (temperature, exposure...) stay at 0.
std::vector<CameraDeviceInfo> deviceInfo() override { std::vector<CameraDeviceInfo> deviceInfo() override {
@ -73,11 +86,54 @@ public:
} }
private: private:
// A gradient frame whose overall brightness follows exposure x gain, so the
// quality gate sees its corrections take effect. The pattern varies with time
// so successive captures differ.
Frame synth() const {
Frame f;
f.width = width_;
f.height = height_;
f.channels = 3;
f.cam_id = 0;
f.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
f.data.resize(static_cast<size_t>(width_) * height_ * 3);
// Exposure/gain -> brightness. Calibrated so the default 5000 us at 0 dB
// lands near the policy's default target mean, making a mock run converge
// the way a real one should.
const double lin = exposure_us_ * std::pow(10.0, gain_db_ / 20.0);
const double level = auto_on_ ? 110.0 : lin * 0.022;
const uint8_t phase = static_cast<uint8_t>(f.timestamp_ms);
for (uint32_t y = 0; y < height_; ++y) {
for (uint32_t x = 0; x < width_; ++x) {
size_t i = (static_cast<size_t>(y) * width_ + x) * 3;
// Zero-mean texture around `level` so there is real detail for the
// sharpness metric without shifting the mean. The cast matters:
// (x+y+phase)%3 is unsigned, so subtracting 1 from the zero case
// would wrap to UINT_MAX and clip a third of the frame to white.
const double tex = 20.0 * (static_cast<int>((x + y + phase) % 3) - 1);
auto clamp8 = [](double v) {
return static_cast<uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v));
};
f.data[i + 0] = clamp8(level + tex);
f.data[i + 1] = clamp8(level + tex * 0.8);
f.data[i + 2] = clamp8(level + tex * 0.6);
}
}
return f;
}
int count_; int count_;
uint32_t width_; uint32_t width_;
uint32_t height_; uint32_t height_;
bool started_ = false; bool started_ = false;
long long frames_ = 0; long long frames_ = 0;
double exposure_us_ = 5000.0;
double gain_db_ = 0.0;
bool auto_on_ = true;
FrameCallback callback_; FrameCallback callback_;
}; };

View File

@ -135,6 +135,20 @@ struct CaptureView {
bool scan_from_file = false; // grid_file CSV vs generated bool scan_from_file = false; // grid_file CSV vs generated
bool scan_pitch = false; // 2-axis rig (show pitch column) bool scan_pitch = false; // 2-axis rig (show pitch column)
std::string scan_error; // grid_file load failure (auto-sweep off) std::string scan_error; // grid_file load failure (auto-sweep off)
// Outcome of the last gated capture (from GatedCameraSource::lastReport()).
// `gate_active` is false when the quality gate is off, in which case the rest
// of these are meaningless.
bool gate_active = false;
bool gate_degraded = false; // budget exhausted; best effort was kept
int gate_attempts = 0;
std::string gate_reason; // "ok" / "clipped" / "dark" / "blur" / ...
double gate_mean_luma = 0.0;
double gate_clipped_pct = 0.0;
double gate_sharpness = 0.0;
double gate_exposure_us = 0.0;
double gate_gain_db = 0.0;
long long gate_degraded_total = 0; // degraded captures this session
// Last published capture (from ImagePipeline::lastEvent()). // Last published capture (from ImagePipeline::lastEvent()).
bool has_last = false; bool has_last = false;
std::string last_label; std::string last_label;

View File

@ -104,6 +104,7 @@ void ImagePipeline::process(const Frame& frame) {
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10); ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10); ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
ev.timestamp_ms = frame.timestamp_ms; ev.timestamp_ms = frame.timestamp_ms;
ev.degraded = frame.degraded;
channel_.publishCamEvent(ev); channel_.publishCamEvent(ev);
{ {
std::lock_guard<std::mutex> lock(last_event_mutex_); std::lock_guard<std::mutex> lock(last_event_mutex_);

View File

@ -169,12 +169,19 @@ double wbRatio(const CameraPtr& cam, const char* channel) {
} }
// Apply the config-driven imaging settings, in-session. Order per the Alvium user // Apply the config-driven imaging settings, in-session. Order per the Alvium user
// guide: ROI/binning -> throughput -> exposure. We drive a PACED FREE-RUN stream // guide: ROI/binning -> throughput -> trigger -> exposure.
// (TriggerMode=Off + a fixed AcquisitionFrameRate), NOT software trigger: large //
// frames streamed fast stall this USB3 host, but a low paced rate sustains and keeps // Two acquisition modes:
// the on-camera auto adjustments converged. (TriggerMode for FrameStart is read-only // triggered (default) - TriggerMode=On + TriggerSource=Software: exactly one frame
// while AcquisitionFrameRateEnable is true, so trigger must be turned off first.) // per TriggerSoftware, so the frame belongs to the waypoint it was asked for and
void configureCamera(const CameraPtr& cam, const CameraConfig& c) { // its exposure can be chosen deliberately. This is also a LOWER USB load than a
// continuous stream, which matters on this host (see docs/known-issues.md).
// freerun - the older paced stream + keep-latest, kept as a fallback.
//
// `manual_exposure` means the quality gate owns exposure/gain, so the on-camera auto
// is switched off.
void configureCamera(const CameraPtr& cam, const CameraConfig& c, bool triggered,
bool manual_exposure) {
setInt(cam, "BinningHorizontal", c.binning); setInt(cam, "BinningHorizontal", c.binning);
setInt(cam, "BinningVertical", c.binning); setInt(cam, "BinningVertical", c.binning);
setInt(cam, "OffsetX", 0); setInt(cam, "OffsetX", 0);
@ -190,25 +197,40 @@ void configureCamera(const CameraPtr& cam, const CameraConfig& c) {
setInt(cam, "DeviceLinkThroughputLimit", static_cast<VmbInt64_t>(c.throughput_mbytes) * 1000000); setInt(cam, "DeviceLinkThroughputLimit", static_cast<VmbInt64_t>(c.throughput_mbytes) * 1000000);
setEnum(cam, "TriggerSelector", "FrameStart"); setEnum(cam, "TriggerSelector", "FrameStart");
// AcquisitionFrameRateEnable=On makes TriggerMode (FrameStart) read-only. If the camera was // AcquisitionFrameRateEnable=On makes TriggerMode (FrameStart) read-only, so the rate
// left with the rate enabled (or in software-trigger mode from a previous run), TriggerMode=Off // control must be dropped FIRST in either mode - otherwise the TriggerMode write is
// would be rejected and we'd get NO free-run frames. So drop the rate control first to unlock // silently rejected and the camera stays in whatever mode the last run left it in.
// TriggerMode, force free-run, then re-enable the paced rate.
setBool(cam, "AcquisitionFrameRateEnable", false); setBool(cam, "AcquisitionFrameRateEnable", false);
setEnum(cam, "TriggerMode", "Off"); // free-run
setEnum(cam, "AcquisitionMode", "Continuous"); setEnum(cam, "AcquisitionMode", "Continuous");
if (triggered) {
// One deliberate frame per TriggerSoftware. The camera ships in HARDWARE trigger
// mode (TriggerSource=Line0) and produces nothing until the source is redirected,
// so Source must be set explicitly - it is not enough to enable TriggerMode.
setEnum(cam, "TriggerSource", "Software");
setEnum(cam, "TriggerMode", "On");
} else {
setEnum(cam, "TriggerMode", "Off"); // free-run
setBool(cam, "AcquisitionFrameRateEnable", true); setBool(cam, "AcquisitionFrameRateEnable", true);
setFloat(cam, "AcquisitionFrameRate", c.stream_fps); setFloat(cam, "AcquisitionFrameRate", c.stream_fps);
}
setEnum(cam, "ExposureAuto", c.exposure_auto ? "Continuous" : "Off"); // With the quality gate driving exposure, the camera's own continuous auto would
// fight it - and its several-frames-to-converge cost is exactly what we are avoiding.
const bool auto_exposure = c.exposure_auto && !manual_exposure;
const bool auto_gain = c.gain_auto && !manual_exposure;
setEnum(cam, "ExposureAuto", auto_exposure ? "Continuous" : "Off");
if (c.exposure_max_us > 0.0) setFloat(cam, "ExposureAutoMax", c.exposure_max_us); if (c.exposure_max_us > 0.0) setFloat(cam, "ExposureAutoMax", c.exposure_max_us);
setEnum(cam, "GainAuto", c.gain_auto ? "Continuous" : "Off"); setEnum(cam, "GainAuto", auto_gain ? "Continuous" : "Off");
if (c.gain_max_db > 0.0) setFloat(cam, "GainAutoMax", c.gain_max_db); if (c.gain_max_db > 0.0) setFloat(cam, "GainAutoMax", c.gain_max_db);
// White balance stays on the camera even under manual exposure: it is far less
// time-critical and is correctable in post, unlike a blown or blurred frame.
setEnum(cam, "BalanceWhiteAuto", c.white_balance_auto ? "Continuous" : "Off"); setEnum(cam, "BalanceWhiteAuto", c.white_balance_auto ? "Continuous" : "Off");
LOG_INFO << "camera configured: binning=" << c.binning << " fmt=" << c.pixel_format LOG_INFO << "camera configured: binning=" << c.binning << " fmt=" << c.pixel_format
<< " " << c.stream_fps << "fps throughput=" << c.throughput_mbytes << "MB/s" << " mode=" << (triggered ? "trigger" : "freerun@" + std::to_string(c.stream_fps) + "fps")
<< " autoExp=" << c.exposure_auto << " autoWB=" << c.white_balance_auto; << " throughput=" << c.throughput_mbytes << "MB/s"
<< " autoExp=" << auto_exposure << " autoWB=" << c.white_balance_auto;
} }
} // namespace } // namespace
@ -222,6 +244,8 @@ struct VimbaCameraSource::Impl {
}; };
CameraConfig config; CameraConfig config;
bool triggered = true; // software trigger vs free-run
bool manual_exposure = true; // gate owns exposure/gain
VmbSystem& sys; VmbSystem& sys;
std::vector<CameraPtr> cameras; std::vector<CameraPtr> cameras;
std::vector<IFrameObserverPtr> observers; std::vector<IFrameObserverPtr> observers;
@ -258,6 +282,32 @@ struct VimbaCameraSource::Impl {
t = std::max(t, SP_DYN_CAST<FrameObserver>(obs)->latestTimestamp()); t = std::max(t, SP_DYN_CAST<FrameObserver>(obs)->latestTimestamp());
return t; return t;
} }
// Fire TriggerSoftware on EVERY camera (the old implementation triggered only
// cameras[0], which is why multi-camera rigs saw nothing from the others).
bool fireSoftwareTrigger() {
bool any = false;
for (auto& cam : cameras) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName("TriggerSoftware", f) == VmbErrorSuccess &&
f->RunCommand() == VmbErrorSuccess)
any = true;
}
if (!any) LOG_WARN << "camera: TriggerSoftware command failed";
return any;
}
// Set a float feature on every camera, tolerating cameras that lack it.
bool setFloatAll(const char* name, double value) {
bool any = false;
for (auto& cam : cameras) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess &&
f->SetValue(value) == VmbErrorSuccess)
any = true;
}
return any;
}
}; };
VimbaCameraSource::VimbaCameraSource(CameraConfig config) VimbaCameraSource::VimbaCameraSource(CameraConfig config)
@ -297,8 +347,14 @@ void VimbaCameraSource::close() {
impl_->sys.Shutdown(); impl_->sys.Shutdown();
} }
void VimbaCameraSource::setAcquisitionMode(bool triggered, bool manual_exposure) {
impl_->triggered = triggered;
impl_->manual_exposure = manual_exposure;
}
void VimbaCameraSource::start() { void VimbaCameraSource::start() {
for (auto& cam : impl_->cameras) configureCamera(cam, impl_->config); for (auto& cam : impl_->cameras)
configureCamera(cam, impl_->config, impl_->triggered, impl_->manual_exposure);
impl_->startAcquisition(); impl_->startAcquisition();
impl_->started = true; impl_->started = true;
} }
@ -350,6 +406,58 @@ bool VimbaCameraSource::trigger() {
return any; return any;
} }
bool VimbaCameraSource::acquireFrame(Frame& out, int timeout_ms) {
if (impl_->observers.empty()) return false;
// Watermark first, then trigger: any frame newer than this is the one we asked
// for, so a frame already in flight can never be mistaken for the new capture.
const long long before = impl_->newestTs();
if (impl_->triggered && !impl_->fireSoftwareTrigger()) return false;
const long long deadline = nowMs() + timeout_ms;
while (impl_->newestTs() <= before && nowMs() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
if (impl_->newestTs() <= before) {
LOG_WARN << "camera: no frame within " << timeout_ms << " ms of trigger";
return false;
}
// Take the freshest frame from whichever camera delivered it.
bool got = false;
for (auto& obs : impl_->observers) {
Frame f;
if (SP_DYN_CAST<FrameObserver>(obs)->takeLatest(f) && f.timestamp_ms > before) {
if (!got || f.timestamp_ms > out.timestamp_ms) out = std::move(f);
got = true;
}
}
if (got) impl_->last_captured_ts = std::max(impl_->last_captured_ts, out.timestamp_ms);
return got;
}
bool VimbaCameraSource::setExposure(double us) {
return impl_->setFloatAll("ExposureTime", us);
}
bool VimbaCameraSource::setGain(double db) { return impl_->setFloatAll("Gain", db); }
bool VimbaCameraSource::setAutoExposureGain(bool on) {
const char* v = on ? "Continuous" : "Off";
for (auto& cam : impl_->cameras) {
setEnum(cam, "ExposureAuto", v);
setEnum(cam, "GainAuto", v);
}
impl_->manual_exposure = !on;
return true;
}
double VimbaCameraSource::currentGain() {
if (impl_->cameras.empty()) return 0.0;
return getFloat(impl_->cameras.front(), "Gain", 0.0);
}
bool VimbaCameraSource::setFrameRate(double fps) { bool VimbaCameraSource::setFrameRate(double fps) {
VmbErrorType result = VmbErrorSuccess; VmbErrorType result = VmbErrorSuccess;
for (auto& cam : impl_->cameras) { for (auto& cam : impl_->cameras) {

View File

@ -7,6 +7,8 @@
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include "fgc/HelpText.h" #include "fgc/HelpText.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include "fgc/ExposureStore.h"
#include "fgc/GatedCameraSource.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/IEnvSensor.h" #include "fgc/IEnvSensor.h"
@ -181,6 +183,12 @@ struct Application::Impl {
std::unique_ptr<IControlChannel> channel; std::unique_ptr<IControlChannel> channel;
std::unique_ptr<IMotorController> motor; std::unique_ptr<IMotorController> motor;
std::unique_ptr<ICameraSource> camera; std::unique_ptr<ICameraSource> camera;
// Borrowed view of the quality gate inside `camera` (null when it is off), so
// buildSnapshot can report the last capture's verdict without a downcast.
GatedCameraSource* gate = nullptr;
// Per-angle exposure memory. Owned here (not by the gate) so it outlives the
// camera and can be flushed to disk on shutdown.
std::unique_ptr<ExposureStore> exposure_store;
std::unique_ptr<IImuSource> imu; std::unique_ptr<IImuSource> imu;
std::unique_ptr<IEnvSensor> env; std::unique_ptr<IEnvSensor> env;
std::unique_ptr<ImagePipeline> pipeline; std::unique_ptr<ImagePipeline> pipeline;
@ -282,7 +290,8 @@ struct Application::Impl {
std::chrono::milliseconds(cfg.env.period_ms)); std::chrono::milliseconds(cfg.env.period_ms));
} }
std::unique_ptr<ICameraSource> makeCamera() { // The bare camera driver, before any quality gating is layered on.
std::unique_ptr<ICameraSource> makeRawCamera() {
bool mock = opts.mock_camera.value_or(cfg.features.mock_camera); bool mock = opts.mock_camera.value_or(cfg.features.mock_camera);
#if !FGC_WITH_VIMBA #if !FGC_WITH_VIMBA
if (!mock) { if (!mock) {
@ -295,12 +304,70 @@ struct Application::Impl {
return std::make_unique<MockCameraSource>(count); return std::make_unique<MockCameraSource>(count);
} }
#if FGC_WITH_VIMBA #if FGC_WITH_VIMBA
return std::make_unique<VimbaCameraSource>(cfg.camera); auto cam = std::make_unique<VimbaCameraSource>(cfg.camera);
cam->setAcquisitionMode(cfg.capture.mode == "trigger", cfg.capture.quality_gate);
return cam;
#else #else
return std::make_unique<MockCameraSource>(1); return std::make_unique<MockCameraSource>(1);
#endif #endif
} }
// Wrap the driver in the quality gate, which turns one trigger() into
// shoot -> judge -> correct -> reshoot, seeded from the per-angle store.
std::unique_ptr<ICameraSource> makeCamera() {
auto raw = makeRawCamera();
if (!cfg.capture.quality_gate) {
LOG_INFO << "capture: quality gate disabled (mode=" << cfg.capture.mode << ')';
return raw;
}
exposure_store = std::make_unique<ExposureStore>(
cfg.capture.exposure_store, cfg.capture.angle_quantum_deg, cfg.capture.store_stale_s);
if (exposure_store->load())
LOG_INFO << "exposure store: " << exposure_store->size() << " angle(s) from "
<< exposure_store->path();
else
LOG_INFO << "exposure store: starting empty (" << exposure_store->path() << ')';
GatedCameraSource::Params p;
p.enabled = true;
p.max_attempts = cfg.capture.max_attempts;
p.min_attempts = cfg.capture.min_attempts;
p.acquire_timeout_ms = cfg.capture.acquire_timeout_ms;
p.settle_delay_ms = cfg.capture.settle_delay_ms;
p.defaults = {cfg.capture.default_exposure_us, cfg.capture.default_gain_db};
p.quality.stride = cfg.capture.metric_stride;
p.quality.sharpness_roi_px = cfg.capture.sharpness_roi_px;
p.policy.target_mean = cfg.capture.target_mean;
p.policy.mean_tolerance = cfg.capture.mean_tolerance;
p.policy.clip_max_fraction = cfg.capture.clip_max_fraction;
p.policy.exposure_min_us = cfg.capture.exposure_min_us;
// The exposure ceiling doubles as the motion-blur budget; fall back to a
// sane cap when [Camera] leaves it at "camera default".
p.policy.exposure_max_us = cfg.camera.exposure_max_us > 0.0
? cfg.camera.exposure_max_us
: 20000.0;
p.policy.gain_max_db = cfg.camera.gain_max_db > 0.0 ? cfg.camera.gain_max_db : 12.0;
p.policy.damping = cfg.capture.damping;
p.policy.blur_relative_floor = cfg.capture.blur_relative_floor;
p.policy.blur_absolute_floor = cfg.capture.blur_absolute_floor;
LOG_INFO << "capture: " << cfg.capture.mode << " mode, quality gate on (target mean "
<< cfg.capture.target_mean << ", up to " << cfg.capture.max_attempts
<< " attempts)";
// Where the gimbal is pointing now, for keying the store - same encoder->degrees
// conversion the ImagePipeline orientation supplier uses.
auto angle = [this]() -> std::pair<double, double> {
MotorTelemetry t = motor->telemetry();
return {cfg.geometry.yaw.toDeg(t.yaw.xenc), cfg.geometry.pitch.toDeg(t.pitch.xenc)};
};
auto gated = std::make_unique<GatedCameraSource>(std::move(raw), exposure_store.get(), p,
angle);
gate = gated.get(); // borrowed, for the snapshot; owned by `camera`
return gated;
}
std::unique_ptr<IUserInterface> makeUi() { std::unique_ptr<IUserInterface> makeUi() {
bool want = opts.use_tui.value_or(cfg.ui.enable_tui); bool want = opts.use_tui.value_or(cfg.ui.enable_tui);
#if FGC_WITH_TUI #if FGC_WITH_TUI
@ -514,6 +581,21 @@ struct Application::Impl {
} }
} }
s.capture.images_saved = pipeline ? pipeline->imagesSaved() : 0; s.capture.images_saved = pipeline ? pipeline->imagesSaved() : 0;
// Quality-gate verdict for the last waypoint.
if (gate) {
const CaptureReport& r = gate->lastReport();
s.capture.gate_active = true;
s.capture.gate_degraded = r.degraded;
s.capture.gate_attempts = r.attempts;
s.capture.gate_reason = r.reason;
s.capture.gate_mean_luma = r.metrics.mean_luma;
s.capture.gate_clipped_pct = r.metrics.clipped_fraction * 100.0;
s.capture.gate_sharpness = r.metrics.sharpness;
s.capture.gate_exposure_us = r.settings.exposure_us;
s.capture.gate_gain_db = r.settings.gain_db;
s.capture.gate_degraded_total = gate->degradedTotal();
}
// Defined scan grid + live cursor (read on the control thread, same as the // Defined scan grid + live cursor (read on the control thread, same as the
// scheduler that mutates it, so no locking is needed). // scheduler that mutates it, so no locking is needed).
s.capture.scan_from_file = !cfg.scan.grid_file.empty(); s.capture.scan_from_file = !cfg.scan.grid_file.empty();
@ -1508,6 +1590,10 @@ struct Application::Impl {
pipeline->stop(); pipeline->stop();
camera->stop(); camera->stop();
camera->close(); camera->close();
// Persist the per-angle exposure memory so the next run starts warm.
if (exposure_store && exposure_store->save())
LOG_INFO << "exposure store: saved " << exposure_store->size() << " angle(s) to "
<< exposure_store->path();
if (imu) imu->stop(); if (imu) imu->stop();
if (env) env->stop(); if (env) env->stop();
motor->stop(); motor->stop();

View File

@ -192,6 +192,53 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9) if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9)
throw std::runtime_error("Camera.jxl_effort must be in 1..9"); throw std::runtime_error("Camera.jxl_effort must be in 1..9");
// [Capture] acquisition mode + quality gate.
cfg.capture.mode = get(kv, "Capture.mode", cfg.capture.mode);
cfg.capture.quality_gate = getBool(kv, "Capture.quality_gate", cfg.capture.quality_gate);
cfg.capture.max_attempts = getInt(kv, "Capture.max_attempts", cfg.capture.max_attempts);
cfg.capture.min_attempts = getInt(kv, "Capture.min_attempts", cfg.capture.min_attempts);
cfg.capture.acquire_timeout_ms =
getInt(kv, "Capture.acquire_timeout_ms", cfg.capture.acquire_timeout_ms);
cfg.capture.settle_delay_ms = getInt(kv, "Capture.settle_delay_ms", cfg.capture.settle_delay_ms);
cfg.capture.target_mean = getDouble(kv, "Capture.target_mean", cfg.capture.target_mean);
cfg.capture.mean_tolerance = getDouble(kv, "Capture.mean_tolerance", cfg.capture.mean_tolerance);
cfg.capture.clip_max_fraction =
getDouble(kv, "Capture.clip_max_fraction", cfg.capture.clip_max_fraction);
cfg.capture.exposure_min_us = getDouble(kv, "Capture.exposure_min_us", cfg.capture.exposure_min_us);
cfg.capture.damping = getDouble(kv, "Capture.damping", cfg.capture.damping);
cfg.capture.default_exposure_us =
getDouble(kv, "Capture.default_exposure_us", cfg.capture.default_exposure_us);
cfg.capture.default_gain_db = getDouble(kv, "Capture.default_gain_db", cfg.capture.default_gain_db);
cfg.capture.blur_relative_floor =
getDouble(kv, "Capture.blur_relative_floor", cfg.capture.blur_relative_floor);
cfg.capture.blur_absolute_floor =
getDouble(kv, "Capture.blur_absolute_floor", cfg.capture.blur_absolute_floor);
cfg.capture.metric_stride = getInt(kv, "Capture.metric_stride", cfg.capture.metric_stride);
cfg.capture.sharpness_roi_px = getInt(kv, "Capture.sharpness_roi_px", cfg.capture.sharpness_roi_px);
cfg.capture.angle_quantum_deg =
getDouble(kv, "Capture.angle_quantum_deg", cfg.capture.angle_quantum_deg);
cfg.capture.store_stale_s = getInt(kv, "Capture.store_stale_s",
static_cast<int>(cfg.capture.store_stale_s));
if (cfg.capture.mode != "trigger" && cfg.capture.mode != "freerun")
throw std::runtime_error("Capture.mode must be 'trigger' or 'freerun', got '" +
cfg.capture.mode + "'");
if (cfg.capture.max_attempts < 1)
throw std::runtime_error("Capture.max_attempts must be >= 1");
if (cfg.capture.min_attempts < 1 || cfg.capture.min_attempts > cfg.capture.max_attempts)
throw std::runtime_error("Capture.min_attempts must be in 1..max_attempts");
if (cfg.capture.target_mean <= 0.0 || cfg.capture.target_mean >= 255.0)
throw std::runtime_error("Capture.target_mean must be in 0..255");
if (cfg.capture.clip_max_fraction < 0.0 || cfg.capture.clip_max_fraction > 1.0)
throw std::runtime_error("Capture.clip_max_fraction must be in 0..1");
if (cfg.capture.damping <= 0.0 || cfg.capture.damping > 1.0)
throw std::runtime_error("Capture.damping must be in (0..1]");
if (cfg.capture.metric_stride < 1)
throw std::runtime_error("Capture.metric_stride must be >= 1");
std::string store = get(kv, "Capture.exposure_store");
cfg.capture.exposure_store =
store.empty() ? paths::defaultExposureStore() : paths::expandUser(store);
std::string out = get(kv, "Paths.output_dir"); std::string out = get(kv, "Paths.output_dir");
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);

125
src/core/ExposurePolicy.cpp Normal file
View File

@ -0,0 +1,125 @@
#include "fgc/ExposurePolicy.h"
#include <algorithm>
#include <cmath>
namespace fgc {
namespace {
// Gain is logarithmic; work in linear multiplier space when splitting a brightness
// correction between exposure time and gain.
inline double dbToLinear(double db) { return std::pow(10.0, db / 20.0); }
inline double linearToDb(double x) { return 20.0 * std::log10(std::max(x, 1e-9)); }
// Split a desired brightness multiplier across exposure and gain.
//
// Brightening spends exposure FIRST and reaches for gain only once exposure is
// capped: gain buys brightness at the cost of noise. Darkening does the reverse -
// surrender gain first, for the same reason. The exposure ceiling is also the
// motion-blur budget, so it is never exceeded to chase brightness.
CaptureSettings applyRatio(const CaptureSettings& cur, double ratio, const PolicyParams& p) {
CaptureSettings out = cur;
if (ratio >= 1.0) {
const double room = p.exposure_max_us / std::max(cur.exposure_us, 1e-6);
const double used = std::min(ratio, room);
out.exposure_us = std::clamp(cur.exposure_us * used, p.exposure_min_us, p.exposure_max_us);
const double residual = ratio / std::max(used, 1e-9);
if (residual > 1.0)
out.gain_db = std::min(p.gain_max_db, linearToDb(dbToLinear(cur.gain_db) * residual));
} else {
const double gain_lin = dbToLinear(cur.gain_db);
const double gain_room = 1.0 / std::max(gain_lin, 1e-9); // how far gain can fall to 0 dB
const double used = std::max(ratio, gain_room);
out.gain_db = std::clamp(linearToDb(gain_lin * used), 0.0, p.gain_max_db);
const double residual = ratio / std::max(used, 1e-9);
if (residual < 1.0)
out.exposure_us =
std::clamp(cur.exposure_us * residual, p.exposure_min_us, p.exposure_max_us);
}
return out;
}
// True when a proposed correction would not meaningfully move the camera - i.e.
// we are pinned at a limit and further attempts are pointless.
bool sameSettings(const CaptureSettings& a, const CaptureSettings& b) {
return std::abs(a.exposure_us - b.exposure_us) < 1.0 && std::abs(a.gain_db - b.gain_db) < 0.05;
}
} // namespace
Verdict evaluate(const ImageMetrics& m, const CaptureSettings& current, const PolicyParams& p,
double reference_sharpness) {
Verdict v;
v.next = current;
if (!m.valid) {
v.accept = false;
v.reason = "invalid";
return v;
}
auto propose = [&](double ratio, const char* reason) {
const double damped = 1.0 + p.damping * (ratio - 1.0);
CaptureSettings next = applyRatio(current, damped, p);
if (sameSettings(next, current)) {
// Pinned at a limit: the camera cannot do better here, so accept what
// we have rather than burning the remaining attempts on no-op retries.
v.accept = true;
v.reason = "saturated";
v.next = current;
} else {
v.accept = false;
v.reason = reason;
v.next = next;
}
};
// 1. Blown highlights outrank everything: unrecoverable, and invisible in the mean.
if (m.clipped_fraction > p.clip_max_fraction) {
// The mean gives no usable step here (it can look fine while the sky burns),
// so back off by a fixed factor instead.
propose(0.7, "clipped");
return v;
}
// 2. Mean luma outside the acceptance band.
const double ratio = p.target_mean / std::max(m.mean_luma, 1.0);
if (m.mean_luma < p.target_mean - p.mean_tolerance) {
propose(ratio, "dark");
return v;
}
if (m.mean_luma > p.target_mean + p.mean_tolerance) {
propose(ratio, "bright");
return v;
}
// 3. Exposure is good. Blur is checked last and never changes the settings -
// a shake or an early trigger is fixed by reshooting, not by re-metering.
if (p.blur_absolute_floor > 0.0 && m.sharpness < p.blur_absolute_floor) {
v.accept = false;
v.reason = "blur";
return v;
}
if (reference_sharpness > 0.0 && m.sharpness < p.blur_relative_floor * reference_sharpness) {
v.accept = false;
v.reason = "blur";
return v;
}
v.accept = true;
v.reason = "ok";
return v;
}
double score(const ImageMetrics& m, const PolicyParams& p) {
if (!m.valid) return -1.0;
const double clip_penalty = m.clipped_fraction / std::max(p.clip_max_fraction, 1e-9);
const double mean_penalty =
std::abs(m.mean_luma - p.target_mean) / std::max(p.mean_tolerance, 1e-9);
// Sharpness is the thing we ultimately want; exposure error only discounts it,
// so a sharp well-exposed frame always beats a sharp blown-out one.
return m.sharpness / (1.0 + clip_penalty + mean_penalty);
}
} // namespace fgc

147
src/core/ExposureStore.cpp Normal file
View File

@ -0,0 +1,147 @@
#include "fgc/ExposureStore.h"
#include "fgc/Logger.h"
#include <cmath>
#include <filesystem>
#include <fstream>
#include <sstream>
namespace fs = std::filesystem;
namespace fgc {
namespace {
constexpr const char* kHeader =
"key,yaw_deg,pitch_deg,exposure_us,gain_db,sharpness,mean_luma,timestamp_ms,attempts";
std::vector<std::string> splitCsv(const std::string& line) {
std::vector<std::string> out;
std::stringstream ss(line);
std::string field;
while (std::getline(ss, field, ',')) out.push_back(field);
return out;
}
} // namespace
ExposureStore::ExposureStore(std::string path, double quantum_deg, long long stale_s)
: path_(std::move(path)),
quantum_deg_(quantum_deg > 0.0 ? quantum_deg : 1.0),
stale_s_(stale_s) {}
std::string ExposureStore::keyFor(double yaw_deg, double pitch_deg, double quantum_deg) {
const double q = quantum_deg > 0.0 ? quantum_deg : 1.0;
const long y = static_cast<long>(std::llround(yaw_deg / q));
const long p = static_cast<long>(std::llround(pitch_deg / q));
return "y" + std::to_string(y) + "_p" + std::to_string(p);
}
bool ExposureStore::load() {
entries_.clear();
std::ifstream in(path_);
if (!in) return false; // no file yet is normal on a first run
std::string line;
bool first = true;
int skipped = 0;
while (std::getline(in, line)) {
if (line.empty()) continue;
if (first) {
first = false;
if (line.rfind("key,", 0) == 0) continue; // header
}
auto f = splitCsv(line);
if (f.size() < 9) {
++skipped;
continue;
}
try {
StoreEntry e;
e.yaw_deg = std::stod(f[1]);
e.pitch_deg = std::stod(f[2]);
e.exposure_us = std::stod(f[3]);
e.gain_db = std::stod(f[4]);
e.sharpness = std::stod(f[5]);
e.mean_luma = std::stod(f[6]);
e.timestamp_ms = std::stoll(f[7]);
e.attempts = std::stoi(f[8]);
entries_[f[0]] = e;
} catch (const std::exception&) {
++skipped; // a bad row must never stop a scan
}
}
if (skipped > 0)
LOG_WARN << "exposure store: skipped " << skipped << " malformed row(s) in " << path_;
return true;
}
bool ExposureStore::save() const {
std::error_code ec;
const fs::path p(path_);
if (p.has_parent_path()) {
fs::create_directories(p.parent_path(), ec);
if (ec) {
LOG_WARN << "exposure store: cannot create " << p.parent_path().string() << ": "
<< ec.message();
return false;
}
}
std::ofstream out(path_, std::ios::trunc);
if (!out) {
LOG_WARN << "exposure store: cannot write " << path_;
return false;
}
out << kHeader << '\n';
for (const auto& [key, e] : entries_) {
out << key << ',' << e.yaw_deg << ',' << e.pitch_deg << ',' << e.exposure_us << ','
<< e.gain_db << ',' << e.sharpness << ',' << e.mean_luma << ',' << e.timestamp_ms << ','
<< e.attempts << '\n';
}
return out.good();
}
std::optional<StoreEntry> ExposureStore::find(double yaw_deg, double pitch_deg) const {
auto it = entries_.find(keyFor(yaw_deg, pitch_deg, quantum_deg_));
if (it == entries_.end()) return std::nullopt;
return it->second;
}
bool ExposureStore::fresh(const StoreEntry& e, long long now_ms) const {
if (e.timestamp_ms <= 0) return false;
return (now_ms - e.timestamp_ms) <= stale_s_ * 1000;
}
CaptureSettings ExposureStore::seed(double yaw_deg, double pitch_deg, long long now_ms,
const CaptureSettings& defaults) const {
if (auto e = find(yaw_deg, pitch_deg); e && fresh(*e, now_ms))
return CaptureSettings{e->exposure_us, e->gain_db};
if (last_accepted_ && (now_ms - last_accepted_ms_) <= stale_s_ * 1000) return *last_accepted_;
return defaults;
}
double ExposureStore::referenceSharpness(double yaw_deg, double pitch_deg, long long now_ms) const {
auto e = find(yaw_deg, pitch_deg);
if (!e || !fresh(*e, now_ms)) return 0.0;
return e->sharpness;
}
void ExposureStore::update(double yaw_deg, double pitch_deg, const CaptureSettings& settings,
const ImageMetrics& metrics, long long now_ms, int attempts) {
StoreEntry e;
e.yaw_deg = yaw_deg;
e.pitch_deg = pitch_deg;
e.exposure_us = settings.exposure_us;
e.gain_db = settings.gain_db;
e.sharpness = metrics.sharpness;
e.mean_luma = metrics.mean_luma;
e.timestamp_ms = now_ms;
e.attempts = attempts;
entries_[keyFor(yaw_deg, pitch_deg, quantum_deg_)] = e;
last_accepted_ = settings;
last_accepted_ms_ = now_ms;
}
} // namespace fgc

View File

@ -0,0 +1,170 @@
#include "fgc/GatedCameraSource.h"
#include "fgc/Logger.h"
#include <chrono>
#include <thread>
#include <utility>
namespace fgc {
namespace {
long long steadyNowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
} // namespace
GatedCameraSource::GatedCameraSource(std::unique_ptr<ICameraSource> inner, ExposureStore* store,
Params params,
std::function<std::pair<double, double>()> angle,
std::function<long long()> now_ms,
std::function<void(int)> sleep_ms)
: inner_(std::move(inner)),
store_(store),
params_(std::move(params)),
angle_(std::move(angle)),
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
sleep_ms_(sleep_ms ? std::move(sleep_ms)
: [](int ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}) {}
GatedCameraSource::~GatedCameraSource() = default;
void GatedCameraSource::open() { inner_->open(); }
void GatedCameraSource::close() { inner_->close(); }
void GatedCameraSource::start() {
inner_->start();
// The gate owns exposure and gain; the camera's own continuous auto would fight
// it (and is exactly the several-frames-to-converge cost we are avoiding).
if (params_.enabled) inner_->setAutoExposureGain(false);
}
void GatedCameraSource::stop() { inner_->stop(); }
bool GatedCameraSource::setFrameRate(double fps) { return inner_->setFrameRate(fps); }
void GatedCameraSource::setFrameCallback(FrameCallback cb) {
callback_ = std::move(cb);
// The inner source keeps its own callback for the ungated path; when gating is
// on we deliver the accepted frame ourselves instead.
inner_->setFrameCallback(callback_);
}
int GatedCameraSource::cameraCount() const { return inner_->cameraCount(); }
std::vector<CameraDeviceInfo> GatedCameraSource::deviceInfo() { return inner_->deviceInfo(); }
bool GatedCameraSource::acquireFrame(Frame& out, int t) { return inner_->acquireFrame(out, t); }
bool GatedCameraSource::setExposure(double us) { return inner_->setExposure(us); }
bool GatedCameraSource::setGain(double db) { return inner_->setGain(db); }
bool GatedCameraSource::setAutoExposureGain(bool on) { return inner_->setAutoExposureGain(on); }
double GatedCameraSource::currentGain() { return inner_->currentGain(); }
bool GatedCameraSource::trigger() {
if (!params_.enabled) return inner_->trigger();
const auto [yaw, pitch] = angle_ ? angle_() : std::make_pair(0.0, 0.0);
const long long now = now_ms_();
CaptureSettings settings = store_ ? store_->seed(yaw, pitch, now, params_.defaults)
: params_.defaults;
const double reference = store_ ? store_->referenceSharpness(yaw, pitch, now) : 0.0;
// Best attempt so far, by policy score. Kept so an exhausted budget still
// yields the least-bad frame rather than nothing.
bool have_best = false;
double best_score = -1.0;
Frame best_frame;
ImageMetrics best_metrics;
CaptureSettings best_settings;
std::string best_reason;
bool accepted = false;
int attempt = 0;
const int max_attempts = std::max(1, params_.max_attempts);
const int min_attempts = std::max(1, params_.min_attempts);
for (attempt = 1; attempt <= max_attempts; ++attempt) {
inner_->setExposure(settings.exposure_us);
inner_->setGain(settings.gain_db);
// Let the mechanics settle before the first shot: a trigger fired the
// instant the axes report standstill still catches the tail of the motion.
if (attempt == 1 && params_.settle_delay_ms > 0) sleep_ms_(params_.settle_delay_ms);
Frame f;
if (!inner_->acquireFrame(f, params_.acquire_timeout_ms)) {
LOG_WARN << "camera: acquisition failed on attempt " << attempt << '/' << max_attempts;
continue;
}
ImageMetrics m = analyzeFrame(f, params_.quality);
m.gain_db = inner_->currentGain();
const Verdict v = evaluate(m, settings, params_.policy, reference);
const double s = score(m, params_.policy);
if (!have_best || s > best_score) {
have_best = true;
best_score = s;
best_frame = f;
best_metrics = m;
best_settings = settings;
best_reason = v.reason;
}
LOG_TRACE_CAT(LogCat::Camera)
<< "capture attempt " << attempt << '/' << max_attempts << " mean=" << m.mean_luma
<< " clip=" << m.clipped_fraction << " sharp=" << m.sharpness << " gain=" << m.gain_db
<< " exp=" << settings.exposure_us << "us -> " << v.reason;
if (v.accept && attempt >= min_attempts) {
accepted = true;
break;
}
settings = v.next;
}
const int used = std::min(attempt, max_attempts);
last_report_ = CaptureReport{};
last_report_.attempts = used;
last_report_.yaw_deg = yaw;
last_report_.pitch_deg = pitch;
if (!have_best) {
LOG_WARN << "camera: no frame acquired at yaw " << yaw << " after " << used << " attempt(s)";
return false;
}
last_report_.captured = true;
last_report_.degraded = !accepted;
last_report_.reason = accepted ? best_reason : "exhausted";
last_report_.metrics = best_metrics;
last_report_.settings = best_settings;
if (!accepted) ++degraded_total_;
if (!accepted)
LOG_WARN << "camera: quality gate exhausted after " << used << " attempts at yaw " << yaw
<< " (mean=" << best_metrics.mean_luma << " clip=" << best_metrics.clipped_fraction
<< " sharp=" << best_metrics.sharpness << "); saving best effort";
else
LOG_DEBUG << "camera: accepted after " << used << " attempt(s) at yaw " << yaw
<< " (mean=" << best_metrics.mean_luma << " exp=" << best_settings.exposure_us
<< "us gain=" << best_settings.gain_db << "dB)";
if (store_) store_->update(yaw, pitch, best_settings, best_metrics, now_ms_(), used);
best_frame.degraded = !accepted;
if (callback_) callback_(best_frame);
return true;
}
} // namespace fgc

91
src/core/ImageQuality.cpp Normal file
View File

@ -0,0 +1,91 @@
#include "fgc/ImageQuality.h"
#include <algorithm>
#include <cmath>
namespace fgc {
namespace {
// Rec.601 luma. Integer-free but cheap; the metrics are statistical, not exact.
inline double luma(const uint8_t* px, int channels) {
if (channels == 1) return px[0];
return 0.299 * px[0] + 0.587 * px[1] + 0.114 * px[2];
}
} // namespace
ImageMetrics analyzeFrame(const Frame& frame, const QualityParams& params) {
ImageMetrics m;
const int ch = frame.channels;
if ((ch != 1 && ch != 3) || frame.width == 0 || frame.height == 0) return m;
const size_t w = frame.width;
const size_t h = frame.height;
const size_t needed = w * h * static_cast<size_t>(ch);
if (frame.data.size() < needed) return m;
const uint8_t* base = frame.data.data();
const int stride = std::max(1, params.stride);
// --- Exposure / clipping / black, on the subsampled grid ---
double luma_sum = 0.0;
long long counted = 0, clipped = 0, dark = 0;
for (size_t y = 0; y < h; y += static_cast<size_t>(stride)) {
const uint8_t* row = base + y * w * static_cast<size_t>(ch);
for (size_t x = 0; x < w; x += static_cast<size_t>(stride)) {
const uint8_t* px = row + x * static_cast<size_t>(ch);
luma_sum += luma(px, ch);
++counted;
bool any_clipped = false, all_dark = true;
for (int c = 0; c < ch; ++c) {
if (px[c] >= params.clip_level) any_clipped = true;
if (px[c] > params.dark_level) all_dark = false;
}
if (any_clipped) ++clipped;
if (all_dark) ++dark;
}
}
if (counted == 0) return m;
m.valid = true;
m.mean_luma = luma_sum / static_cast<double>(counted);
m.clipped_fraction = static_cast<double>(clipped) / static_cast<double>(counted);
m.dark_fraction = static_cast<double>(dark) / static_cast<double>(counted);
// --- Sharpness: variance of the 3x3 Laplacian over a centre ROI, full res ---
// A blurred image has little high-frequency energy, so the Laplacian response
// clusters near zero and its variance collapses. The absolute value is scene
// dependent, which is why the policy compares it against the same angle's own
// history rather than a fixed threshold.
const size_t roi = std::min<size_t>({static_cast<size_t>(std::max(3, params.sharpness_roi_px)), w, h});
if (roi >= 3) {
const size_t x0 = (w - roi) / 2;
const size_t y0 = (h - roi) / 2;
double sum = 0.0, sum_sq = 0.0;
long long n = 0;
for (size_t y = y0 + 1; y < y0 + roi - 1; ++y) {
for (size_t x = x0 + 1; x < x0 + roi - 1; ++x) {
const uint8_t* c = base + (y * w + x) * static_cast<size_t>(ch);
const uint8_t* up = c - w * static_cast<size_t>(ch);
const uint8_t* dn = c + w * static_cast<size_t>(ch);
const uint8_t* lf = c - static_cast<size_t>(ch);
const uint8_t* rt = c + static_cast<size_t>(ch);
const double lap = luma(up, ch) + luma(dn, ch) + luma(lf, ch) + luma(rt, ch) -
4.0 * luma(c, ch);
sum += lap;
sum_sq += lap * lap;
++n;
}
}
if (n > 0) {
const double mean = sum / static_cast<double>(n);
m.sharpness = std::max(0.0, sum_sq / static_cast<double>(n) - mean * mean);
}
}
return m;
}
} // namespace fgc

View File

@ -102,6 +102,12 @@ std::string defaultLogDir() {
return (fs::path(base) / "fire_gimbal_control" / "logs").string(); return (fs::path(base) / "fire_gimbal_control" / "logs").string();
} }
std::string defaultExposureStore() {
// State, not a log, so it sits beside the logs directory rather than inside it.
std::string base = envOr("XDG_DATA_HOME", expandUser("~/.local/share"));
return (fs::path(base) / "fire_gimbal_control" / "exposure_store.csv").string();
}
std::string timestampedLogName(const std::string& prefix) { std::string timestampedLogName(const std::string& prefix) {
std::time_t t = std::time(nullptr); std::time_t t = std::time(nullptr);
std::tm tm{}; std::tm tm{};

View File

@ -120,7 +120,8 @@ void MqttControlChannel::publishCamEvent(const CamEvent& e) {
std::string payload = "{ \"fwt\":\"" + e.tower + "\" ,\"cam\":\"" + e.camera + std::string payload = "{ \"fwt\":\"" + e.tower + "\" ,\"cam\":\"" + e.camera +
"\", \"hdg\":" + std::to_string(e.heading_decideg) + "\", \"hdg\":" + std::to_string(e.heading_decideg) +
", \"pit\":" + std::to_string(e.pitch_decideg) + ", \"pit\":" + std::to_string(e.pitch_decideg) +
", \"time\":" + std::to_string(e.timestamp_ms) + " }"; ", \"time\":" + std::to_string(e.timestamp_ms) +
", \"degraded\":" + (e.degraded ? "true" : "false") + " }";
publish(topic_cam_event_, payload); publish(topic_cam_event_, payload);
} }

View File

@ -120,6 +120,10 @@ Element cameraPanel(const CaptureView& c) {
rows.push_back(hbox({text("scan ") | dim, rows.push_back(hbox({text("scan ") | dim,
text(" GRID LOAD FAILED ") | color(Color::Red) | bold, text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
text(" c for details") | dim})); text(" c for details") | dim}));
if (c.gate_active && c.gate_degraded)
rows.push_back(hbox({text("quality ") | dim,
text(" DEGRADED ") | color(Color::Red) | bold,
text(" c for details") | dim}));
if (c.has_last) { if (c.has_last) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>( long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
@ -157,6 +161,29 @@ Element cameraDetailPanel(const CaptureView& c) {
rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session"))); rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session")));
rows.push_back(row("output", text(c.config.output_dir.empty() ? "" : c.config.output_dir) | dim)); rows.push_back(row("output", text(c.config.output_dir.empty() ? "" : c.config.output_dir) | dim));
// --- Quality gate: how the last waypoint's image was judged ---
if (c.gate_active) {
rows.push_back(separator());
rows.push_back(text("QUALITY GATE") | bold | color(Color::Blue));
Element verdict = c.gate_degraded
? (text(" " + std::string("degraded") + " ") | color(Color::Red) | bold)
: (text(" " + (c.gate_reason.empty() ? "" : c.gate_reason) + " ") |
color(Color::Green) | bold);
rows.push_back(hbox({text("last ") | dim, verdict, filler(),
text(std::to_string(c.gate_attempts) + " attempt(s)") | dim}));
rows.push_back(row("exposure", text(num(c.gate_exposure_us, 0) + " us gain " +
num(c.gate_gain_db, 1) + " dB")));
rows.push_back(row("brightness", text("mean " + num(c.gate_mean_luma, 1) + " clipped " +
num(c.gate_clipped_pct, 2) + " %")));
rows.push_back(row("sharpness", text(num(c.gate_sharpness, 0))));
// A rising degraded count is the signal that the thresholds or the exposure
// limits need revisiting, so it is worth a colour.
rows.push_back(row("degraded", text(std::to_string(c.gate_degraded_total) +
" this session") |
(c.gate_degraded_total ? color(Color::Yellow)
: color(Color::Default))));
}
// --- Devices: identity + live telemetry --- // --- Devices: identity + live telemetry ---
rows.push_back(separator()); rows.push_back(separator());
rows.push_back(text("DEVICES") | bold | color(Color::Blue)); rows.push_back(text("DEVICES") | bold | color(Color::Blue));

View File

@ -30,6 +30,10 @@ add_executable(fgc_tests
test_uisnapshot.cpp test_uisnapshot.cpp
test_mtiprotocol.cpp test_mtiprotocol.cpp
test_sht41.cpp test_sht41.cpp
test_imagequality.cpp
test_exposurepolicy.cpp
test_exposurestore.cpp
test_gatedcamera.cpp
test_dumpparser.cpp test_dumpparser.cpp
test_helptext.cpp test_helptext.cpp
test_diagparser.cpp test_diagparser.cpp

View File

@ -60,6 +60,53 @@ TEST_CASE("ConfigLoader maps [Env]/env feature flags") {
CHECK(c.env.period_ms == 5000); CHECK(c.env.period_ms == 5000);
} }
TEST_CASE("ConfigLoader maps [Capture] and defaults to a gated trigger") {
AppConfig d = ConfigLoader::fromMap({});
CHECK(d.capture.mode == "trigger");
CHECK(d.capture.quality_gate == true);
CHECK(d.capture.max_attempts == 3);
CHECK(d.capture.min_attempts == 1);
CHECK(d.capture.target_mean == doctest::Approx(110.0));
CHECK(d.capture.blur_relative_floor == doctest::Approx(0.5));
CHECK(d.capture.blur_absolute_floor == doctest::Approx(0.0)); // backstop off by default
CHECK_FALSE(d.capture.exposure_store.empty()); // resolved to a default path
AppConfig c = ConfigLoader::fromMap({
{"Capture.mode", "freerun"},
{"Capture.quality_gate", "false"},
{"Capture.max_attempts", "5"},
{"Capture.min_attempts", "2"},
{"Capture.target_mean", "128"},
{"Capture.clip_max_fraction", "0.01"},
{"Capture.exposure_store", "/tmp/fgc_store.csv"},
{"Capture.store_stale_s", "600"},
});
CHECK(c.capture.mode == "freerun");
CHECK(c.capture.quality_gate == false);
CHECK(c.capture.max_attempts == 5);
CHECK(c.capture.min_attempts == 2);
CHECK(c.capture.target_mean == doctest::Approx(128.0));
CHECK(c.capture.clip_max_fraction == doctest::Approx(0.01));
CHECK(c.capture.exposure_store == "/tmp/fgc_store.csv");
CHECK(c.capture.store_stale_s == 600);
}
TEST_CASE("Capture config rejects settings that could not work") {
// A typo'd mode would silently pick one of the two acquisition paths.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.mode", "triggered"}}));
// min > max would make the gate unable to ever accept.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.max_attempts", "2"},
{"Capture.min_attempts", "3"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.max_attempts", "0"}}));
// A target outside the pixel range can never be reached.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.target_mean", "300"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.clip_max_fraction", "1.5"}}));
// Damping of 0 would freeze the correction; above 1 would overshoot.
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.damping", "0"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.damping", "1.5"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.metric_stride", "0"}}));
}
TEST_CASE("Env.i2c_addr accepts the hex form every datasheet uses") { TEST_CASE("Env.i2c_addr accepts the hex form every datasheet uses") {
// Plain std::stoi() stops at the 'x' and returns 0, which would silently // Plain std::stoi() stops at the 'x' and returns 0, which would silently
// configure address 0x00 — the sensor would then never answer. // configure address 0x00 — the sensor would then never answer.

View File

@ -0,0 +1,171 @@
#include <doctest/doctest.h>
#include "fgc/ExposurePolicy.h"
#include <cmath>
using namespace fgc;
namespace {
PolicyParams params() {
PolicyParams p;
p.target_mean = 110.0;
p.mean_tolerance = 12.0;
p.clip_max_fraction = 0.005;
p.exposure_min_us = 50.0;
p.exposure_max_us = 20000.0;
p.gain_max_db = 12.0;
p.damping = 0.8;
return p;
}
ImageMetrics metrics(double mean, double clipped = 0.0, double sharpness = 1000.0) {
ImageMetrics m;
m.valid = true;
m.mean_luma = mean;
m.clipped_fraction = clipped;
m.sharpness = sharpness;
return m;
}
} // namespace
TEST_CASE("a well-exposed frame is accepted unchanged") {
auto v = evaluate(metrics(110.0), {5000.0, 0.0}, params());
CHECK(v.accept);
CHECK(v.reason == "ok");
// Anywhere inside the tolerance band is good enough - chasing the exact target
// would cost extra frames for no visible gain.
CHECK(evaluate(metrics(100.0), {5000.0, 0.0}, params()).accept);
CHECK(evaluate(metrics(120.0), {5000.0, 0.0}, params()).accept);
}
TEST_CASE("underexposure raises exposure before it reaches for gain") {
auto v = evaluate(metrics(40.0), {5000.0, 0.0}, params());
CHECK_FALSE(v.accept);
CHECK(v.reason == "dark");
CHECK(v.next.exposure_us > 5000.0);
CHECK(v.next.gain_db == doctest::Approx(0.0)); // gain costs noise; it waits
}
TEST_CASE("gain is only used once exposure is capped") {
PolicyParams p = params();
// Already at the exposure ceiling (which is also the motion-blur budget), so
// the only way to brighten further is gain.
auto v = evaluate(metrics(40.0), {p.exposure_max_us, 0.0}, p);
CHECK_FALSE(v.accept);
CHECK(v.next.exposure_us == doctest::Approx(p.exposure_max_us));
CHECK(v.next.gain_db > 0.0);
CHECK(v.next.gain_db <= p.gain_max_db);
}
TEST_CASE("overexposure surrenders gain before it shortens exposure") {
auto v = evaluate(metrics(200.0), {5000.0, 6.0}, params());
CHECK_FALSE(v.accept);
CHECK(v.reason == "bright");
CHECK(v.next.gain_db < 6.0);
// Exposure is untouched while there is still gain to give back.
CHECK(v.next.exposure_us == doctest::Approx(5000.0));
}
TEST_CASE("clipping outranks the mean") {
// The mean sits right on target, but the sky is blown. Blown highlights are
// unrecoverable, so this must still be rejected and darkened.
auto v = evaluate(metrics(110.0, /*clipped=*/0.05), {5000.0, 0.0}, params());
CHECK_FALSE(v.accept);
CHECK(v.reason == "clipped");
CHECK(v.next.exposure_us < 5000.0);
}
TEST_CASE("corrections stay inside the configured limits") {
PolicyParams p = params();
// Extreme darkness cannot push exposure past the blur budget or gain past its cap.
CaptureSettings s{p.exposure_max_us, p.gain_max_db};
auto v = evaluate(metrics(1.0), s, p);
CHECK(v.next.exposure_us <= p.exposure_max_us);
CHECK(v.next.gain_db <= p.gain_max_db);
// Extreme brightness cannot drive exposure below the floor or gain negative.
CaptureSettings s2{p.exposure_min_us, 0.0};
auto v2 = evaluate(metrics(254.0, 0.9), s2, p);
CHECK(v2.next.exposure_us >= p.exposure_min_us);
CHECK(v2.next.gain_db >= 0.0);
}
TEST_CASE("a camera pinned at its limits accepts instead of retrying forever") {
PolicyParams p = params();
// Still too dark, but exposure and gain are both maxed: no correction exists,
// so burning the remaining attempts would be pointless.
auto v = evaluate(metrics(40.0), {p.exposure_max_us, p.gain_max_db}, p);
CHECK(v.accept);
CHECK(v.reason == "saturated");
}
TEST_CASE("repeated correction converges and does not oscillate") {
PolicyParams p = params();
CaptureSettings s{1000.0, 0.0};
// Model a linear sensor: mean is proportional to exposure x gain.
auto simulate = [](const CaptureSettings& cs) {
const double lin = cs.exposure_us * std::pow(10.0, cs.gain_db / 20.0);
return std::min(255.0, lin * 0.02); // 5500 us -> 110
};
int iterations = 0;
bool converged = false;
for (; iterations < 12; ++iterations) {
auto v = evaluate(metrics(simulate(s)), s, p);
if (v.accept) {
converged = true;
break;
}
s = v.next;
}
CHECK(converged);
CHECK(iterations <= 6); // damping trades a little speed for stability
CHECK(simulate(s) == doctest::Approx(p.target_mean).epsilon(0.15));
}
TEST_CASE("blur is judged against this angle's own history, not an absolute number") {
PolicyParams p = params();
// Exposure is fine and there is no reference yet: nothing to compare against,
// so a low-detail scene (fog) must NOT be rejected.
CHECK(evaluate(metrics(110.0, 0.0, /*sharpness=*/5.0), {5000.0, 0.0}, p).accept);
// With a reference from a previous good frame here, a sudden collapse in
// sharpness is a shake or an early trigger - reject and reshoot.
auto v = evaluate(metrics(110.0, 0.0, 100.0), {5000.0, 0.0}, p, /*reference=*/1000.0);
CHECK_FALSE(v.accept);
CHECK(v.reason == "blur");
// Blur never re-meters: reshooting is the fix, not a different exposure.
CHECK(v.next.exposure_us == doctest::Approx(5000.0));
CHECK(v.next.gain_db == doctest::Approx(0.0));
// A modest drop is normal scene variation and stays acceptable.
CHECK(evaluate(metrics(110.0, 0.0, 900.0), {5000.0, 0.0}, p, 1000.0).accept);
}
TEST_CASE("the absolute blur backstop is off unless configured") {
PolicyParams p = params();
CHECK(evaluate(metrics(110.0, 0.0, 0.1), {5000.0, 0.0}, p).accept);
p.blur_absolute_floor = 50.0;
CHECK_FALSE(evaluate(metrics(110.0, 0.0, 0.1), {5000.0, 0.0}, p).accept);
}
TEST_CASE("score ranks sharpness but discounts exposure error") {
PolicyParams p = params();
// Between equally sharp frames, the better-exposed one wins.
CHECK(score(metrics(110.0, 0.0, 1000.0), p) > score(metrics(200.0, 0.0, 1000.0), p));
// Between equally exposed frames, the sharper one wins.
CHECK(score(metrics(110.0, 0.0, 2000.0), p) > score(metrics(110.0, 0.0, 1000.0), p));
// A blown frame is heavily penalised even when it is sharp.
CHECK(score(metrics(110.0, 0.5, 2000.0), p) < score(metrics(110.0, 0.0, 1000.0), p));
// An unusable frame never wins.
CHECK(score(ImageMetrics{}, p) < 0.0);
}

View File

@ -0,0 +1,124 @@
#include <doctest/doctest.h>
#include "fgc/ExposureStore.h"
#include <cstdio>
#include <filesystem>
#include <fstream>
using namespace fgc;
namespace fs = std::filesystem;
namespace {
std::string tempPath(const char* name) {
return (fs::temp_directory_path() / name).string();
}
ImageMetrics metrics(double sharpness, double mean = 110.0) {
ImageMetrics m;
m.valid = true;
m.sharpness = sharpness;
m.mean_luma = mean;
return m;
}
constexpr long long kNow = 1'700'000'000'000LL;
} // namespace
TEST_CASE("keyFor buckets angles so nearby headings share a seed") {
CHECK(ExposureStore::keyFor(137.2, -5.1, 1.0) == ExposureStore::keyFor(137.4, -4.9, 1.0));
CHECK(ExposureStore::keyFor(137.0, 0.0, 1.0) != ExposureStore::keyFor(138.0, 0.0, 1.0));
// A coarser quantum merges more headings into one bucket.
CHECK(ExposureStore::keyFor(10.0, 0.0, 5.0) == ExposureStore::keyFor(12.0, 0.0, 5.0));
// Negative pitch must round consistently, not toward zero.
CHECK(ExposureStore::keyFor(0.0, -7.6, 1.0) == ExposureStore::keyFor(0.0, -7.5, 1.0));
}
TEST_CASE("store round-trips through CSV") {
const std::string path = tempPath("fgc_test_exposure_roundtrip.csv");
fs::remove(path);
{
ExposureStore s(path);
s.update(90.0, 0.0, {4200.0, 3.5}, metrics(1234.0, 108.0), kNow, 2);
s.update(180.0, -5.0, {6000.0, 0.0}, metrics(900.0), kNow, 1);
REQUIRE(s.save());
}
ExposureStore loaded(path);
REQUIRE(loaded.load());
CHECK(loaded.size() == 2);
auto e = loaded.find(90.0, 0.0);
REQUIRE(e.has_value());
CHECK(e->exposure_us == doctest::Approx(4200.0));
CHECK(e->gain_db == doctest::Approx(3.5));
CHECK(e->sharpness == doctest::Approx(1234.0));
CHECK(e->attempts == 2);
CHECK(e->timestamp_ms == kNow);
fs::remove(path);
}
TEST_CASE("a missing store is an empty store, not an error path") {
ExposureStore s(tempPath("fgc_test_exposure_absent.csv"));
fs::remove(s.path());
CHECK_FALSE(s.load()); // reports "nothing loaded"...
CHECK(s.size() == 0); // ...but is perfectly usable
CHECK_FALSE(s.find(0.0, 0.0).has_value());
}
TEST_CASE("a malformed row is skipped rather than failing the whole load") {
// A corrupt line must never stop a scan from running.
const std::string path = tempPath("fgc_test_exposure_corrupt.csv");
{
std::ofstream out(path, std::ios::trunc);
out << "key,yaw_deg,pitch_deg,exposure_us,gain_db,sharpness,mean_luma,timestamp_ms,attempts\n";
out << "y90_p0,90,0,4200,3.5,1234,108," << kNow << ",2\n";
out << "garbage,not,a,number,at,all,here,either,x\n";
out << "too,few,fields\n";
out << "y180_p0,180,0,6000,0,900,110," << kNow << ",1\n";
}
ExposureStore s(path);
REQUIRE(s.load());
CHECK(s.size() == 2);
CHECK(s.find(90.0, 0.0).has_value());
CHECK(s.find(180.0, 0.0).has_value());
fs::remove(path);
}
TEST_CASE("seeding falls through three tiers as entries go stale") {
const CaptureSettings defaults{9999.0, 9.0};
ExposureStore s(tempPath("fgc_test_exposure_seed.csv"), 1.0, /*stale_s=*/1800);
// Tier 3: nothing known at all.
CHECK(s.seed(90.0, 0.0, kNow, defaults).exposure_us == doctest::Approx(9999.0));
s.update(90.0, 0.0, {4200.0, 1.0}, metrics(1000.0), kNow, 1);
// Tier 1: this angle's own fresh entry is the best predictor.
CHECK(s.seed(90.0, 0.0, kNow + 1000, defaults).exposure_us == doctest::Approx(4200.0));
// Tier 2: the angle's entry has gone stale, but something was accepted recently
// elsewhere - light changes affect all angles together, so that beats both the
// stale entry and the cold defaults.
s.update(200.0, 0.0, {5500.0, 2.0}, metrics(800.0), kNow + 3600'000, 1);
auto seeded = s.seed(90.0, 0.0, kNow + 3600'000, defaults);
CHECK(seeded.exposure_us == doctest::Approx(5500.0));
CHECK(seeded.gain_db == doctest::Approx(2.0));
// Everything stale: fall all the way back to the configured defaults.
CHECK(s.seed(90.0, 0.0, kNow + 100'000'000, defaults).exposure_us == doctest::Approx(9999.0));
}
TEST_CASE("reference sharpness is only offered while it is still current") {
ExposureStore s(tempPath("fgc_test_exposure_ref.csv"), 1.0, /*stale_s=*/1800);
CHECK(s.referenceSharpness(90.0, 0.0, kNow) == doctest::Approx(0.0));
s.update(90.0, 0.0, {4200.0, 0.0}, metrics(1500.0), kNow, 1);
CHECK(s.referenceSharpness(90.0, 0.0, kNow + 1000) == doctest::Approx(1500.0));
// Too old to compare against: the scene may have changed character entirely
// (fog rolling in), so returning 0 disables the relative blur check.
CHECK(s.referenceSharpness(90.0, 0.0, kNow + 100'000'000) == doctest::Approx(0.0));
}

254
tests/test_gatedcamera.cpp Normal file
View File

@ -0,0 +1,254 @@
#include <doctest/doctest.h>
#include "fgc/GatedCameraSource.h"
#include <algorithm>
#include <cmath>
#include <vector>
using namespace fgc;
namespace {
// A fake camera whose frames are synthesised from whatever exposure/gain the gate
// asks for, so the whole feedback loop can be exercised without hardware.
class FakeCamera : public ICameraSource {
public:
// Sensor model: mean luma is proportional to exposure x gain, saturating at 255.
double sensitivity = 0.02; // 5500 us at 0 dB -> ~110
double sharpness_fill = 0; // 0 = checkerboard detail; >0 = flat (blurred)
int fail_first_n = 0; // acquisitions that fail outright
bool clip_always = false;
// Per-attempt sharpness overrides, consumed in order; empty = always sharp.
std::vector<double> scripted_blur;
int acquisitions = 0;
std::vector<double> exposures_seen;
bool auto_disabled = false;
bool started = false;
void open() override {}
void close() override {}
void start() override { started = true; }
void stop() override { started = false; }
bool trigger() override { return false; } // never used when gating is on
void setFrameCallback(FrameCallback cb) override { cb_ = std::move(cb); }
int cameraCount() const override { return 1; }
bool setExposure(double us) override {
exposure_ = us;
exposures_seen.push_back(us);
return true;
}
bool setGain(double db) override {
gain_ = db;
return true;
}
bool setAutoExposureGain(bool on) override {
auto_disabled = !on;
return true;
}
double currentGain() override { return gain_; }
bool acquireFrame(Frame& out, int) override {
if (acquisitions++ < fail_first_n) return false;
const double lin = exposure_ * std::pow(10.0, gain_ / 20.0);
const double mean = std::min(250.0, lin * sensitivity);
// Blur is modelled by flattening the image: a flat field has no
// second-derivative energy, so its sharpness collapses.
double blur = sharpness_fill;
if (!scripted_blur.empty()) {
blur = scripted_blur.front();
scripted_blur.erase(scripted_blur.begin());
}
const uint32_t w = 64, h = 64;
out = Frame{};
out.width = w;
out.height = h;
out.channels = 1;
out.timestamp_ms = 1000 + acquisitions;
out.data.assign(static_cast<size_t>(w) * h, 0);
for (uint32_t y = 0; y < h; ++y) {
for (uint32_t x = 0; x < w; ++x) {
double v = mean;
if (blur <= 0.0) {
// Zero-mean detail on a period of 3. A period-2 checkerboard
// would alias against the stride-4 metric sampler (which only
// ever hits one phase) and skew the mean by the full amplitude.
const int k = static_cast<int>((x + y) % 3);
v = mean + (k == 0 ? -40.0 : (k == 1 ? 0.0 : 40.0));
}
if (clip_always && x < w / 2) v = 255;
out.data[y * w + x] = static_cast<uint8_t>(std::clamp(v, 0.0, 255.0));
}
}
return true;
}
private:
double exposure_ = 1000.0;
double gain_ = 0.0;
FrameCallback cb_;
};
GatedCameraSource::Params params(int max_attempts = 3) {
GatedCameraSource::Params p;
p.max_attempts = max_attempts;
p.min_attempts = 1;
p.acquire_timeout_ms = 10;
p.settle_delay_ms = 0;
p.defaults = {1000.0, 0.0};
p.quality.sharpness_roi_px = 32;
p.policy.target_mean = 110.0;
p.policy.mean_tolerance = 12.0;
p.policy.exposure_max_us = 20000.0;
p.policy.gain_max_db = 12.0;
return p;
}
// Build a gate over a fake camera, capturing delivered frames.
struct Rig {
FakeCamera* cam;
std::unique_ptr<GatedCameraSource> gate;
std::vector<Frame> delivered;
ExposureStore store{"", 1.0, 1800};
explicit Rig(GatedCameraSource::Params p, bool with_store = false) {
auto owned = std::make_unique<FakeCamera>();
cam = owned.get();
gate = std::make_unique<GatedCameraSource>(
std::move(owned), with_store ? &store : nullptr, p,
[] { return std::make_pair(90.0, 0.0); }, [] { return 1'700'000'000'000LL; },
[](int) {});
gate->setFrameCallback([this](const Frame& f) { delivered.push_back(f); });
}
};
} // namespace
TEST_CASE("a good first frame is kept without extra acquisitions") {
Rig r(params());
r.cam->sensitivity = 0.11; // 1000 us default already lands on ~110
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions == 1);
REQUIRE(r.delivered.size() == 1);
CHECK_FALSE(r.delivered[0].degraded);
CHECK(r.gate->lastReport().attempts == 1);
CHECK(r.gate->lastReport().reason == "ok");
CHECK_FALSE(r.gate->lastReport().degraded);
}
TEST_CASE("an underexposed frame is corrected and re-shot") {
Rig r(params());
r.cam->sensitivity = 0.02; // 1000 us -> mean 20: far too dark
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions > 1);
REQUIRE(r.delivered.size() == 1);
CHECK_FALSE(r.delivered[0].degraded);
// Exposure was actually raised on the camera between attempts.
REQUIRE(r.cam->exposures_seen.size() >= 2);
CHECK(r.cam->exposures_seen[1] > r.cam->exposures_seen[0]);
}
TEST_CASE("an exhausted budget still delivers the best attempt, flagged degraded") {
auto p = params(/*max_attempts=*/2);
Rig r(p);
r.cam->clip_always = true; // half the frame is blown no matter what we do
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions == 2);
REQUIRE(r.delivered.size() == 1);
CHECK(r.delivered[0].degraded); // never lose a waypoint, but mark it
CHECK(r.gate->lastReport().degraded);
CHECK(r.gate->lastReport().reason == "exhausted");
CHECK(r.gate->lastReport().attempts == 2);
}
TEST_CASE("total acquisition failure delivers nothing and reports it") {
auto p = params(/*max_attempts=*/2);
Rig r(p);
r.cam->fail_first_n = 99;
CHECK_FALSE(r.gate->trigger());
CHECK(r.delivered.empty());
CHECK_FALSE(r.gate->lastReport().captured);
}
TEST_CASE("a transient acquisition failure does not lose the waypoint") {
Rig r(params());
r.cam->sensitivity = 0.11;
r.cam->fail_first_n = 1; // first attempt drops, second succeeds
CHECK(r.gate->trigger());
REQUIRE(r.delivered.size() == 1);
CHECK_FALSE(r.delivered[0].degraded);
}
TEST_CASE("min_attempts always shoots extra and keeps the sharpest") {
auto p = params(/*max_attempts=*/3);
p.min_attempts = 2;
Rig r(p);
r.cam->sensitivity = 0.11; // exposure is fine from the start
// First frame blurred, second sharp: with min_attempts=2 the gate must take
// both and keep the better one, which is the point of the setting.
r.cam->scripted_blur = {1.0, 0.0};
CHECK(r.gate->trigger());
CHECK(r.cam->acquisitions == 2);
REQUIRE(r.delivered.size() == 1);
CHECK(r.gate->lastReport().metrics.sharpness > 0.0);
CHECK_FALSE(r.delivered[0].degraded);
}
TEST_CASE("the gate takes exposure control away from the camera on start") {
Rig r(params());
r.gate->start();
CHECK(r.cam->started);
// The camera's own continuous auto would fight the gate, and its convergence
// cost is exactly what the per-angle store exists to avoid.
CHECK(r.cam->auto_disabled);
}
TEST_CASE("disabling the gate passes straight through to the inner camera") {
auto p = params();
p.enabled = false;
Rig r(p);
CHECK_FALSE(r.gate->trigger()); // FakeCamera::trigger() is a no-op
CHECK(r.cam->acquisitions == 0);
CHECK(r.delivered.empty());
}
TEST_CASE("accepted settings are written back to the store for the next visit") {
Rig r(params(), /*with_store=*/true);
r.cam->sensitivity = 0.02;
CHECK(r.gate->trigger());
auto e = r.store.find(90.0, 0.0);
REQUIRE(e.has_value());
CHECK(e->exposure_us > 1000.0); // the corrected value, not the cold default
CHECK(e->sharpness > 0.0);
CHECK(e->attempts >= 1);
}
TEST_CASE("a seeded angle converges in one attempt on the next visit") {
Rig r(params(), /*with_store=*/true);
r.cam->sensitivity = 0.02;
CHECK(r.gate->trigger());
const int first_sweep = r.cam->acquisitions;
CHECK(first_sweep > 1); // had to search for the right exposure
r.cam->acquisitions = 0;
CHECK(r.gate->trigger());
// Second visit starts from the stored setting, so no search is needed. This is
// the whole point of the per-angle memory.
CHECK(r.cam->acquisitions == 1);
}

114
tests/test_imagequality.cpp Normal file
View File

@ -0,0 +1,114 @@
#include <doctest/doctest.h>
#include "fgc/ImageQuality.h"
using namespace fgc;
namespace {
// A frame filled with one constant value.
Frame flat(uint32_t w, uint32_t h, int channels, uint8_t value) {
Frame f;
f.width = w;
f.height = h;
f.channels = channels;
f.data.assign(static_cast<size_t>(w) * h * channels, value);
return f;
}
// A 1-pixel checkerboard: maximum high-frequency content, so maximum sharpness.
Frame checkerboard(uint32_t w, uint32_t h) {
Frame f = flat(w, h, 1, 0);
for (uint32_t y = 0; y < h; ++y)
for (uint32_t x = 0; x < w; ++x) f.data[y * w + x] = ((x + y) % 2) ? 255 : 0;
return f;
}
// A smooth horizontal ramp: plenty of contrast, but almost no second derivative.
Frame ramp(uint32_t w, uint32_t h) {
Frame f = flat(w, h, 1, 0);
for (uint32_t y = 0; y < h; ++y)
for (uint32_t x = 0; x < w; ++x)
f.data[y * w + x] = static_cast<uint8_t>((x * 255) / (w - 1));
return f;
}
} // namespace
TEST_CASE("analyzeFrame rejects frames it cannot interpret") {
CHECK_FALSE(analyzeFrame(Frame{}).valid);
Frame bad_channels = flat(8, 8, 2, 50);
CHECK_FALSE(analyzeFrame(bad_channels).valid);
// Buffer shorter than width*height*channels must not be read past its end.
Frame truncated = flat(64, 64, 3, 50);
truncated.data.resize(100);
CHECK_FALSE(analyzeFrame(truncated).valid);
}
TEST_CASE("analyzeFrame measures mean luma for mono and RGB") {
ImageMetrics mono = analyzeFrame(flat(64, 64, 1, 128));
REQUIRE(mono.valid);
CHECK(mono.mean_luma == doctest::Approx(128.0));
CHECK(mono.clipped_fraction == doctest::Approx(0.0));
CHECK(mono.dark_fraction == doctest::Approx(0.0));
// Equal R=G=B weights sum to 1.0, so grey RGB gives the same luma.
ImageMetrics rgb = analyzeFrame(flat(64, 64, 3, 128));
REQUIRE(rgb.valid);
CHECK(rgb.mean_luma == doctest::Approx(128.0));
}
TEST_CASE("analyzeFrame detects blown highlights and crushed blacks") {
ImageMetrics blown = analyzeFrame(flat(64, 64, 3, 255));
REQUIRE(blown.valid);
CHECK(blown.clipped_fraction == doctest::Approx(1.0));
CHECK(blown.dark_fraction == doctest::Approx(0.0));
ImageMetrics black = analyzeFrame(flat(64, 64, 3, 0));
REQUIRE(black.valid);
CHECK(black.dark_fraction == doctest::Approx(1.0));
CHECK(black.clipped_fraction == doctest::Approx(0.0));
// A single blown channel is enough to count the pixel as clipped - important
// for RGB, where a red sunset can saturate one channel while the mean is fine.
Frame one_channel = flat(64, 64, 3, 100);
for (size_t i = 0; i < one_channel.data.size(); i += 3) one_channel.data[i] = 255;
ImageMetrics m = analyzeFrame(one_channel);
CHECK(m.clipped_fraction == doctest::Approx(1.0));
}
TEST_CASE("sharpness separates detailed from smooth images") {
QualityParams p;
p.sharpness_roi_px = 64;
const double checker = analyzeFrame(checkerboard(128, 128), p).sharpness;
const double smooth = analyzeFrame(ramp(128, 128), p).sharpness;
const double blank = analyzeFrame(flat(128, 128, 1, 128), p).sharpness;
// A flat field has no second derivative at all.
CHECK(blank == doctest::Approx(0.0));
// A ramp has strong contrast but is locally linear, so it is nearly as flat -
// this is why sharpness must not be inferred from contrast or stddev.
CHECK(smooth < checker / 100.0);
CHECK(checker > 1000.0);
}
TEST_CASE("stride subsampling agrees with a full scan") {
// Half the frame blown, half mid-grey: any correct sampling sees ~50%.
Frame f = flat(128, 128, 1, 128);
for (uint32_t y = 0; y < 64; ++y)
for (uint32_t x = 0; x < 128; ++x) f.data[y * 128 + x] = 255;
QualityParams full;
full.stride = 1;
QualityParams strided;
strided.stride = 4;
ImageMetrics a = analyzeFrame(f, full);
ImageMetrics b = analyzeFrame(f, strided);
CHECK(b.mean_luma == doctest::Approx(a.mean_luma).epsilon(0.02));
CHECK(b.clipped_fraction == doctest::Approx(a.clipped_fraction).epsilon(0.02));
CHECK(b.clipped_fraction == doctest::Approx(0.5).epsilon(0.02));
}