camera logic improved
This commit is contained in:
parent
48c9aa529d
commit
376e79536c
|
|
@ -59,6 +59,10 @@ add_library(fgc_core STATIC
|
|||
src/core/Calibration.cpp
|
||||
src/core/CalibrationRoutine.cpp
|
||||
src/core/MtiProtocol.cpp
|
||||
src/core/ImageQuality.cpp
|
||||
src/core/ExposurePolicy.cpp
|
||||
src/core/ExposureStore.cpp
|
||||
src/core/GatedCameraSource.cpp
|
||||
src/sensors/Sht41Protocol.cpp
|
||||
src/ui/UiSnapshot.cpp
|
||||
src/ui/HeadlessUi.cpp
|
||||
|
|
|
|||
|
|
@ -114,6 +114,15 @@ While running, the program reads commands from stdin (one per line):
|
|||
| `help [topic]` | List commands / expand one section. |
|
||||
| `exit` | Stop everything and quit (Ctrl-D also works). |
|
||||
|
||||
**Image capture** is per-waypoint and self-correcting: at each settled waypoint the camera is
|
||||
software-triggered for one deliberate frame, which is then measured (brightness, blown highlights,
|
||||
sharpness) and re-shot with corrected exposure/gain until it passes. Accepted settings are remembered
|
||||
**per heading**, so the next visit starts from a value known to work there instead of waiting for the
|
||||
camera's own auto-exposure to re-converge. If the retry budget runs out the best attempt is kept and
|
||||
flagged `degraded` rather than losing the waypoint. The reasoning behind each rule is in
|
||||
[docs/architecture.md](docs/architecture.md#image-capture); the knobs are `[Capture]` in
|
||||
[docs/configuration.md](docs/configuration.md).
|
||||
|
||||
The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live
|
||||
roll/pitch/yaw and powers `gimbal calib`; the **SHT41** (`[Features] enable_env` + `[Env] i2c_device`,
|
||||
or `--mock-env`) supplies the panel's ambient temperature and humidity — the MTi's device-internal
|
||||
|
|
|
|||
|
|
@ -57,6 +57,70 @@ white_balance_auto = true
|
|||
jxl_distance = 0.8
|
||||
jxl_effort = 4
|
||||
|
||||
[Capture]
|
||||
; How a frame is acquired at each waypoint, and the quality bar it must clear.
|
||||
;
|
||||
; mode = trigger : one deliberate software-triggered frame per attempt (default).
|
||||
; The image belongs to the waypoint that asked for it, and its
|
||||
; exposure can be chosen deliberately. Also a LOWER USB load than
|
||||
; a continuous stream.
|
||||
; mode = freerun : the older paced stream + keep-latest. Fallback only.
|
||||
mode = trigger
|
||||
; Judge each frame and reshoot with corrected exposure until it passes. Turning
|
||||
; this off keeps the first frame unconditionally and disables the per-angle store.
|
||||
quality_gate = true
|
||||
|
||||
; Retry budget per waypoint. min_attempts > 1 always shoots extra and keeps the
|
||||
; sharpest - worth raising at a windy site.
|
||||
max_attempts = 3
|
||||
min_attempts = 1
|
||||
acquire_timeout_ms = 2000
|
||||
; Pause after the axes report standstill, before the first shot: a trigger fired
|
||||
; the instant motion stops still catches the tail of it.
|
||||
settle_delay_ms = 150
|
||||
|
||||
; --- Exposure target ---
|
||||
; Desired mean brightness (0..255) and the band that counts as good enough.
|
||||
target_mean = 110
|
||||
mean_tolerance = 12
|
||||
; Ceiling on blown-out pixels (fraction). This OUTRANKS the mean: a bright sky can
|
||||
; saturate while the average still looks fine, and blown highlights are gone for good.
|
||||
clip_max_fraction = 0.005
|
||||
exposure_min_us = 50
|
||||
; Correction damping (0..1]; below 1 trades a little speed for stability.
|
||||
damping = 0.8
|
||||
; Cold-start settings, used only when the store has nothing to offer.
|
||||
default_exposure_us = 5000
|
||||
default_gain_db = 0
|
||||
; NOTE: the exposure CEILING and the gain cap come from [Camera] exposure_max_us and
|
||||
; gain_max_db. The exposure ceiling doubles as the motion-blur budget.
|
||||
|
||||
; --- Blur ---
|
||||
; Sharpness is judged RELATIVE to the same angle's own history, because a foggy or
|
||||
; featureless horizon is legitimately low-detail and an absolute threshold would
|
||||
; retry forever on it. Reject below this fraction of the angle's last good score.
|
||||
blur_relative_floor = 0.5
|
||||
; Catastrophic-blur backstop, in absolute variance-of-Laplacian units. 0 = off.
|
||||
blur_absolute_floor = 0
|
||||
|
||||
; --- Metric cost ---
|
||||
; Exposure/clipping stats sample every Nth pixel; sharpness always runs at full
|
||||
; resolution on a centre square of sharpness_roi_px.
|
||||
metric_stride = 4
|
||||
sharpness_roi_px = 512
|
||||
|
||||
; --- Per-angle exposure memory ---
|
||||
; Remembers what worked at each heading so the next visit starts from a setting
|
||||
; known to be roughly right, instead of paying the camera's auto-exposure several
|
||||
; frames to re-converge. Empty path => $XDG_DATA_HOME/fire_gimbal_control/exposure_store.csv
|
||||
exposure_store =
|
||||
; Angle bucket size in degrees (headings within one bucket share a seed).
|
||||
angle_quantum_deg = 1.0
|
||||
; How old an entry may be and still be trusted (seconds). Past this, the seed falls
|
||||
; back to the last settings accepted anywhere - outdoor light drifts, and a recent
|
||||
; reading one heading over beats an hour-old reading at this exact heading.
|
||||
store_stale_s = 1800
|
||||
|
||||
[Serial]
|
||||
; Motor-controller serial device and baud rate.
|
||||
device = /dev/ttyACM0
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ thread so all geometry/state mutation stays single-threaded.
|
|||
| Serial I/O | `SerialMotorController` | Boost.Asio `io_context`; async read-until parses telemetry |
|
||||
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
|
||||
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) |
|
||||
| Camera acquisition | Vimba X (internal) | delivers frames via the observer (real source) |
|
||||
| Camera acquisition | Vimba X (internal) | delivers frames via the observer; the gate's shoot/judge/retry loop runs on the **control thread**, so a slow waypoint delays the tick rather than racing it |
|
||||
|
||||
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (channel), the frame queue
|
||||
(pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the
|
||||
|
|
@ -75,7 +75,10 @@ corrupted.
|
|||
- ControlCode 0: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (ping-pong).
|
||||
- ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`.
|
||||
- Trigger the cameras once **both axes report standstill at the target**, then advance the grid.
|
||||
5. **Frame handling** — a triggered camera delivers a `Frame` to the callback, which `submit()`s it to the
|
||||
5. **Acquisition + quality gate** (`GatedCameraSource`) — `trigger()` does not simply take a picture. It
|
||||
seeds exposure/gain from the per-angle store, software-triggers **one** frame, measures it, corrects and
|
||||
re-shoots until it passes or the budget runs out. See *Image capture* below.
|
||||
6. **Frame handling** — the accepted `Frame` goes to the callback, which `submit()`s it to the
|
||||
`ImagePipeline`. The worker rotates it 90° CCW, encodes JPEG XL (or copies the demo image), writes
|
||||
`<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)
|
||||
└──────────────────────┘
|
||||
└──────────────────────┘ on failure: retry next tick, do NOT advance
|
||||
```
|
||||
|
||||
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
|
||||
([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
|
||||
|
||||
Decoupling the control logic from the SDKs makes the core testable and the binary buildable/runnable without
|
||||
|
|
|
|||
|
|
@ -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` | `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` | `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` | `exposure_auto` | bool | `true` | `ExposureAuto=Continuous` (adapt to changing light) |
|
||||
| `Camera` | `exposure_max_us` | double | `0` | `ExposureAutoMax` cap (µs); `0` = camera default |
|
||||
| `Camera` | `gain_auto` | bool | `true` | `GainAuto=Continuous` |
|
||||
| `Camera` | `gain_max_db` | double | `0` | `GainAutoMax` cap (dB); `0` = camera default |
|
||||
| `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`. Forced off when the quality gate owns exposure |
|
||||
| `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`. Forced off when the quality gate owns exposure |
|
||||
| `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` | `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) |
|
||||
| `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` |
|
||||
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
|
||||
| `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
|
||||
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
|
||||
|
||||
Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ doctest unit-test suite (`ctest`).
|
|||
| # | 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. |
|
||||
| 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 ~1–26 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
|
||||
|
||||
|
|
@ -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.
|
||||
- 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**
|
||||
(~5 MP, ~15 MB) keeps de-Bayering **and** white balance on-camera (the on-camera pipeline applies white
|
||||
balance before de-Bayering); a **paced low-rate stream** keeps on-camera auto-exposure/gain/white-balance
|
||||
converged to the changing outdoor light, and the scheduler saves one frame per waypoint. `DeviceLinkThroughputLimit`
|
||||
is kept conservative (~250 MB/s; ≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8
|
||||
(20 MB) + a host de-Bayer step if reduced resolution proves insufficient.
|
||||
**Approach (config-driven, see `[Camera]`/`[Capture]` in [configuration.md](configuration.md)):** RGB8 +
|
||||
**2×2 binning** (~5 MP, ~15 MB) keeps de-Bayering **and** white balance on-camera (the on-camera pipeline
|
||||
applies white balance before de-Bayering). `DeviceLinkThroughputLimit` is kept conservative (~250 MB/s;
|
||||
≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 (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
|
||||
on the rig: wire the in-session imaging config + paced acquisition in `VimbaCameraSource`, add the udev/boot
|
||||
persistence for LPM + usbfs, and verify a full sweep captures white-balanced frames with no stall.
|
||||
**Acquisition is now per-waypoint software trigger** (`[Capture] mode = trigger`), not the paced stream.
|
||||
Note what the measurements above do and do not show: they bound **frame size and pacing**, not trigger
|
||||
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 10–30 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
|
||||
|
||||
|
|
@ -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).
|
||||
- **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
|
||||
`--init` drove a clean re-home of both axes to `READY`. **Still unverified on hardware:** the capture sweep
|
||||
(#14) and real-camera (Vimba) frame capture — earlier tests used `--mock-camera`.
|
||||
`--init` drove a clean re-home of both axes to `READY`.
|
||||
- **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
|
||||
`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
|
||||
|
|
@ -77,6 +109,18 @@ persistence for LPM + usbfs, and verify a full sweep captures white-balanced fra
|
|||
|
||||
## 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).
|
||||
- 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).)
|
||||
|
|
|
|||
|
|
@ -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/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/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/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()` |
|
||||
|
|
@ -82,7 +86,8 @@ firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`.
|
|||
`*_available` flag.
|
||||
|
||||
### `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)).
|
||||
|
||||
### `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.
|
||||
|
||||
### `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))
|
||||
One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s),
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ When a ControlCode message arrives, the program echoes the current code back on
|
|||
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|
||||
|
||||
```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 |
|
||||
|
|
@ -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 |
|
||||
| `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) |
|
||||
| `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
|
||||
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`.
|
||||
|
|
|
|||
|
|
@ -27,8 +27,16 @@ Keep entries dated; move shipped items into the reference docs.
|
|||
a new `IControlChannel` implementation (richer routing/durable queues, but must
|
||||
re-engineer retained "last value" semantics). The abstraction seam already
|
||||
exists (`IControlChannel`). _(TODO: confirm approach, target release.)_
|
||||
- **RGB camera.** Integrate the production RGB sensor end-to-end (acquire → JXL →
|
||||
CamEvent); enables the `camera/rgb` self-test. _(TODO: model/SDK, mounting.)_
|
||||
- **RGB camera.** _Largely landed._ The Alvium 1800 U-2040c runs end-to-end on the
|
||||
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,
|
||||
temperature range); enables the `camera/thermal` self-test. _(TODO: model/SDK,
|
||||
calibration workflow.)_
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`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
|
||||
configured rate; exposure/gain give a usable histogram (not clipped); few
|
||||
dead/hot pixels; frames sharp when focused; timestamps strictly increase; few
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ namespace fgc {
|
|||
// 2. reads motor telemetry (per-axis encoder counts + state),
|
||||
// 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
|
||||
// target, waits until both axes report standstill at that target, then
|
||||
// software-triggers the cameras.
|
||||
// target, waits until both axes report standstill at that target, then asks
|
||||
// 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 1 = drive yaw to the MQTT-supplied target heading (pitch held).
|
||||
|
|
|
|||
|
|
@ -56,6 +56,47 @@ struct CameraConfig {
|
|||
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 {
|
||||
// Where captured .jxl images are written. Supports leading ~ and $ENV
|
||||
// expansion. Empty => resolved to a sensible default at load time.
|
||||
|
|
@ -144,6 +185,7 @@ struct AppConfig {
|
|||
NetworkConfig network;
|
||||
SerialConfig serial;
|
||||
CameraConfig camera;
|
||||
CaptureConfig capture; // [Capture] acquisition mode + image quality gate
|
||||
PathsConfig paths;
|
||||
FeaturesConfig features;
|
||||
LoggingConfig logging;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -15,6 +15,10 @@ struct Frame {
|
|||
int channels = 0; // 1 (mono) or 3 (RGB)
|
||||
long long timestamp_ms = 0; // Unix epoch ms
|
||||
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
|
||||
|
|
@ -46,7 +50,8 @@ struct CameraDeviceInfo {
|
|||
};
|
||||
|
||||
// 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();
|
||||
// the consumer (ImagePipeline) encodes and stores them.
|
||||
|
|
@ -61,12 +66,34 @@ public:
|
|||
virtual void start() = 0; // begin acquisition
|
||||
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;
|
||||
|
||||
// Optional: change the acquisition frame rate. Default no-op (e.g. mocks).
|
||||
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;
|
||||
|
||||
// Number of cameras this source manages.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ struct CamEvent {
|
|||
int heading_decideg = 0; // yaw heading * 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
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -30,6 +30,10 @@ std::string defaultOutputDir();
|
|||
// ($XDG_DATA_HOME/fire_gimbal_control/logs, else ~/.local/share/...).
|
||||
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.
|
||||
std::string timestampedLogName(const std::string& prefix);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,15 +8,23 @@
|
|||
namespace fgc {
|
||||
|
||||
// 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
|
||||
// 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()
|
||||
// delivers it to the callback once per waypoint. Encode/save lives in ImagePipeline.
|
||||
// cameras in CameraConfig and applies the on-camera imaging settings in-session.
|
||||
//
|
||||
// Two acquisition modes, chosen with setAcquisitionMode() before start():
|
||||
// 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 {
|
||||
public:
|
||||
explicit VimbaCameraSource(CameraConfig config);
|
||||
~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 close() override;
|
||||
void start() override;
|
||||
|
|
@ -27,6 +35,12 @@ public:
|
|||
int cameraCount() const 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:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "fgc/Logger.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
namespace fgc {
|
||||
|
|
@ -11,6 +12,10 @@ namespace fgc {
|
|||
// Synthetic camera for development without hardware. On trigger() it generates
|
||||
// a small RGB gradient frame (varying with time so successive captures differ)
|
||||
// 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 {
|
||||
public:
|
||||
explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480)
|
||||
|
|
@ -24,24 +29,7 @@ public:
|
|||
bool trigger() override {
|
||||
if (!callback_) return false;
|
||||
LOG_TRACE_CAT(LogCat::Camera) << "TX trigger cam0 (mock)";
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Frame f = synth();
|
||||
LOG_TRACE_CAT(LogCat::Camera)
|
||||
<< "RX frame cam0 " << width_ << 'x' << height_ << ' ' << f.data.size() << "B (mock)";
|
||||
++frames_;
|
||||
|
|
@ -52,6 +40,31 @@ public:
|
|||
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
|
||||
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)
|
||||
// without hardware. Non-hardware fields (temperature, exposure...) stay at 0.
|
||||
std::vector<CameraDeviceInfo> deviceInfo() override {
|
||||
|
|
@ -73,11 +86,54 @@ public:
|
|||
}
|
||||
|
||||
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_;
|
||||
uint32_t width_;
|
||||
uint32_t height_;
|
||||
bool started_ = false;
|
||||
long long frames_ = 0;
|
||||
double exposure_us_ = 5000.0;
|
||||
double gain_db_ = 0.0;
|
||||
bool auto_on_ = true;
|
||||
FrameCallback callback_;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,20 @@ struct CaptureView {
|
|||
bool scan_from_file = false; // grid_file CSV vs generated
|
||||
bool scan_pitch = false; // 2-axis rig (show pitch column)
|
||||
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()).
|
||||
bool has_last = false;
|
||||
std::string last_label;
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ void ImagePipeline::process(const Frame& frame) {
|
|||
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
|
||||
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
|
||||
ev.timestamp_ms = frame.timestamp_ms;
|
||||
ev.degraded = frame.degraded;
|
||||
channel_.publishCamEvent(ev);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(last_event_mutex_);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// guide: ROI/binning -> throughput -> exposure. We drive a PACED FREE-RUN stream
|
||||
// (TriggerMode=Off + a fixed AcquisitionFrameRate), NOT software trigger: large
|
||||
// frames streamed fast stall this USB3 host, but a low paced rate sustains and keeps
|
||||
// the on-camera auto adjustments converged. (TriggerMode for FrameStart is read-only
|
||||
// while AcquisitionFrameRateEnable is true, so trigger must be turned off first.)
|
||||
void configureCamera(const CameraPtr& cam, const CameraConfig& c) {
|
||||
// guide: ROI/binning -> throughput -> trigger -> exposure.
|
||||
//
|
||||
// Two acquisition modes:
|
||||
// triggered (default) - TriggerMode=On + TriggerSource=Software: exactly one frame
|
||||
// per TriggerSoftware, so the frame belongs to the waypoint it was asked for and
|
||||
// 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, "BinningVertical", c.binning);
|
||||
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);
|
||||
|
||||
setEnum(cam, "TriggerSelector", "FrameStart");
|
||||
// AcquisitionFrameRateEnable=On makes TriggerMode (FrameStart) read-only. If the camera was
|
||||
// left with the rate enabled (or in software-trigger mode from a previous run), TriggerMode=Off
|
||||
// would be rejected and we'd get NO free-run frames. So drop the rate control first to unlock
|
||||
// TriggerMode, force free-run, then re-enable the paced rate.
|
||||
// AcquisitionFrameRateEnable=On makes TriggerMode (FrameStart) read-only, so the rate
|
||||
// control must be dropped FIRST in either mode - otherwise the TriggerMode write is
|
||||
// silently rejected and the camera stays in whatever mode the last run left it in.
|
||||
setBool(cam, "AcquisitionFrameRateEnable", false);
|
||||
setEnum(cam, "TriggerMode", "Off"); // free-run
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
// 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");
|
||||
|
||||
LOG_INFO << "camera configured: binning=" << c.binning << " fmt=" << c.pixel_format
|
||||
<< " " << c.stream_fps << "fps throughput=" << c.throughput_mbytes << "MB/s"
|
||||
<< " autoExp=" << c.exposure_auto << " autoWB=" << c.white_balance_auto;
|
||||
<< " mode=" << (triggered ? "trigger" : "freerun@" + std::to_string(c.stream_fps) + "fps")
|
||||
<< " throughput=" << c.throughput_mbytes << "MB/s"
|
||||
<< " autoExp=" << auto_exposure << " autoWB=" << c.white_balance_auto;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
@ -222,6 +244,8 @@ struct VimbaCameraSource::Impl {
|
|||
};
|
||||
|
||||
CameraConfig config;
|
||||
bool triggered = true; // software trigger vs free-run
|
||||
bool manual_exposure = true; // gate owns exposure/gain
|
||||
VmbSystem& sys;
|
||||
std::vector<CameraPtr> cameras;
|
||||
std::vector<IFrameObserverPtr> observers;
|
||||
|
|
@ -258,6 +282,32 @@ struct VimbaCameraSource::Impl {
|
|||
t = std::max(t, SP_DYN_CAST<FrameObserver>(obs)->latestTimestamp());
|
||||
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)
|
||||
|
|
@ -297,8 +347,14 @@ void VimbaCameraSource::close() {
|
|||
impl_->sys.Shutdown();
|
||||
}
|
||||
|
||||
void VimbaCameraSource::setAcquisitionMode(bool triggered, bool manual_exposure) {
|
||||
impl_->triggered = triggered;
|
||||
impl_->manual_exposure = manual_exposure;
|
||||
}
|
||||
|
||||
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_->started = true;
|
||||
}
|
||||
|
|
@ -350,6 +406,58 @@ bool VimbaCameraSource::trigger() {
|
|||
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) {
|
||||
VmbErrorType result = VmbErrorSuccess;
|
||||
for (auto& cam : impl_->cameras) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
#include "fgc/DumpParser.h"
|
||||
#include "fgc/HelpText.h"
|
||||
#include "fgc/Paths.h"
|
||||
#include "fgc/ExposureStore.h"
|
||||
#include "fgc/GatedCameraSource.h"
|
||||
#include "fgc/ICameraSource.h"
|
||||
#include "fgc/IControlChannel.h"
|
||||
#include "fgc/IEnvSensor.h"
|
||||
|
|
@ -181,6 +183,12 @@ struct Application::Impl {
|
|||
std::unique_ptr<IControlChannel> channel;
|
||||
std::unique_ptr<IMotorController> motor;
|
||||
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<IEnvSensor> env;
|
||||
std::unique_ptr<ImagePipeline> pipeline;
|
||||
|
|
@ -282,7 +290,8 @@ struct Application::Impl {
|
|||
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);
|
||||
#if !FGC_WITH_VIMBA
|
||||
if (!mock) {
|
||||
|
|
@ -295,12 +304,70 @@ struct Application::Impl {
|
|||
return std::make_unique<MockCameraSource>(count);
|
||||
}
|
||||
#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
|
||||
return std::make_unique<MockCameraSource>(1);
|
||||
#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() {
|
||||
bool want = opts.use_tui.value_or(cfg.ui.enable_tui);
|
||||
#if FGC_WITH_TUI
|
||||
|
|
@ -514,6 +581,21 @@ struct Application::Impl {
|
|||
}
|
||||
}
|
||||
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
|
||||
// scheduler that mutates it, so no locking is needed).
|
||||
s.capture.scan_from_file = !cfg.scan.grid_file.empty();
|
||||
|
|
@ -1508,6 +1590,10 @@ struct Application::Impl {
|
|||
pipeline->stop();
|
||||
camera->stop();
|
||||
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 (env) env->stop();
|
||||
motor->stop();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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");
|
||||
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -102,6 +102,12 @@ std::string defaultLogDir() {
|
|||
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::time_t t = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
|
|
|
|||
|
|
@ -120,7 +120,8 @@ void MqttControlChannel::publishCamEvent(const CamEvent& e) {
|
|||
std::string payload = "{ \"fwt\":\"" + e.tower + "\" ,\"cam\":\"" + e.camera +
|
||||
"\", \"hdg\":" + std::to_string(e.heading_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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ Element cameraPanel(const CaptureView& c) {
|
|||
rows.push_back(hbox({text("scan ") | dim,
|
||||
text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
|
||||
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) {
|
||||
long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
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("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 ---
|
||||
rows.push_back(separator());
|
||||
rows.push_back(text("DEVICES") | bold | color(Color::Blue));
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ add_executable(fgc_tests
|
|||
test_uisnapshot.cpp
|
||||
test_mtiprotocol.cpp
|
||||
test_sht41.cpp
|
||||
test_imagequality.cpp
|
||||
test_exposurepolicy.cpp
|
||||
test_exposurestore.cpp
|
||||
test_gatedcamera.cpp
|
||||
test_dumpparser.cpp
|
||||
test_helptext.cpp
|
||||
test_diagparser.cpp
|
||||
|
|
|
|||
|
|
@ -60,6 +60,53 @@ TEST_CASE("ConfigLoader maps [Env]/env feature flags") {
|
|||
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") {
|
||||
// Plain std::stoi() stops at the 'x' and returns 0, which would silently
|
||||
// configure address 0x00 — the sensor would then never answer.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue