Count the picture interval from the last picture, and let a settle wait of 0 wait for the exposure to settle
This commit is contained in:
parent
4488a2181d
commit
fb0f5117df
|
|
@ -14,7 +14,7 @@
|
||||||
[General]
|
[General]
|
||||||
; Tower identity, substituted into all MQTT topics and CamEvent payloads.
|
; Tower identity, substituted into all MQTT topics and CamEvent payloads.
|
||||||
tower_name = ExampleTower
|
tower_name = ExampleTower
|
||||||
; Seconds between captures.
|
; Desired seconds from one picture to the next (decimals allowed, e.g. 2.5).
|
||||||
image_interval = 5
|
image_interval = 5
|
||||||
; 1 = print motor telemetry each loop tick, 0 = quiet.
|
; 1 = print motor telemetry each loop tick, 0 = quiet.
|
||||||
debug = 0
|
debug = 0
|
||||||
|
|
@ -71,10 +71,13 @@ profile = standard ; <profile_dir>/standard.xml
|
||||||
rotation_deg = 0
|
rotation_deg = 0
|
||||||
|
|
||||||
; Per-profile timing. Not camera settings - how long the program waits around
|
; Per-profile timing. Not camera settings - how long the program waits around
|
||||||
; them. After the gimbal reports standstill it holds still this long before
|
; them. After the gimbal reports standstill it holds still at least
|
||||||
; keeping a frame, so the image is exposed at rest with the auto-exposure
|
; settle_wait_ms, so the image is exposed at rest with the auto-exposure
|
||||||
; settled; frame_timeout_ms then abandons the waypoint rather than parking on it.
|
; settled. 0 = automatic: wait until the camera's auto-exposure is on its
|
||||||
; With several cameras the scan uses the LONGEST wait across them.
|
; target (or pinned at its limits), at most frame_timeout_ms, then keep the
|
||||||
|
; frame anyway with a warning. frame_timeout_ms also abandons a waypoint whose
|
||||||
|
; camera delivers no frame, rather than parking on it. With several cameras the
|
||||||
|
; scan uses the LONGEST wait across them, or automatic if any camera is on 0.
|
||||||
[Camera.rgb.standard]
|
[Camera.rgb.standard]
|
||||||
settle_wait_ms = 1000
|
settle_wait_ms = 1000
|
||||||
frame_timeout_ms = 2000
|
frame_timeout_ms = 2000
|
||||||
|
|
|
||||||
|
|
@ -94,20 +94,28 @@ corrupted.
|
||||||
## Capture state machine
|
## Capture state machine
|
||||||
|
|
||||||
```
|
```
|
||||||
interval elapsed AND mode auto or pan AND not already moving
|
mode auto or pan AND not already moving (straight after the last frame)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch)
|
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch); pan → next pan heading
|
||||||
│ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's pitch
|
│ MOVE <yaw>,<pitch> │ unreachable targets skipped in the same tick
|
||||||
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving, reset timer
|
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving
|
||||||
└──────────┬───────────┘
|
└──────────┬───────────┘
|
||||||
│ (both axes standstill AND |xenc − target| ≤ tol)
|
│ (both axes standstill AND |xenc − target| ≤ tol)
|
||||||
▼
|
▼
|
||||||
┌──────────────────────┐ the camera keeps streaming throughout; this pause is
|
┌──────────────────────┐ the camera keeps streaming throughout; this pause is
|
||||||
│ wait settle_wait_ms │ what makes the kept frame one exposed at rest
|
│ wait for exposure │ what makes the kept frame one exposed at rest.
|
||||||
|
│ settle_wait_ms, or │ 0: until camera.exposureState() is Settled/AtLimit,
|
||||||
|
│ automatic (0) │ at most frame_timeout_ms (then keep it anyway, warn)
|
||||||
|
└──────────┬───────────┘ move + wait > interval: warn (not on the return to the
|
||||||
|
│ first waypoint, nor the first move after a mode starts)
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ wait out the rest of │ interval counted from the last frame kept
|
||||||
|
│ image_interval │
|
||||||
└──────────┬───────────┘
|
└──────────┬───────────┘
|
||||||
▼
|
▼
|
||||||
┌──────────────────────┐ on success: clear moving, advance the grid / pan position
|
┌──────────────────────┐ on success: clear moving, advance, restart the interval
|
||||||
│ camera.trigger() │ no fresh frame: retry next tick
|
│ camera.trigger() │ no fresh frame: retry next tick
|
||||||
└──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance
|
└──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance
|
||||||
```
|
```
|
||||||
|
|
@ -116,8 +124,9 @@ Triggering only at the settled target replaces the original's trigger-while-movi
|
||||||
[known-issues.md](known-issues.md) #7). The scheduler is unit-tested with mock doubles and an injected clock
|
[known-issues.md](known-issues.md) #7). The scheduler is unit-tested with mock doubles and an injected clock
|
||||||
([tests/test_scheduler.cpp](../tests/test_scheduler.cpp)).
|
([tests/test_scheduler.cpp](../tests/test_scheduler.cpp)).
|
||||||
|
|
||||||
Note the scheduler knows nothing about image quality. It asks for an image and advances when it gets one;
|
Note the scheduler knows nothing about image quality. It asks the camera whether its exposure has settled
|
||||||
everything below is an implementation detail of the camera source. That is what keeps it testable.
|
(`exposureState()`), 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
|
## Image capture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| Section | Key | Type | Default | Meaning |
|
| Section | Key | Type | Default | Meaning |
|
||||||
|---------|-----|------|---------|---------|
|
|---------|-----|------|---------|---------|
|
||||||
| `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads |
|
| `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads |
|
||||||
| `General` | `image_interval` | int > 0 | `5` | Seconds between captures (→ `image_rate = 1/interval`) |
|
| `General` | `image_interval` | number > 0 | `5` | Desired seconds from one picture to the next, decimals allowed (→ `image_rate = 1/interval`). Counted from the last frame kept; the move and the exposure wait come out of it. A waypoint whose move + wait outlasts it warns (not the return to the first waypoint, nor the first move after a mode starts) |
|
||||||
| `General` | `debug` | bool | `false` | Start with debug-level logging |
|
| `General` | `debug` | bool | `false` | Start with debug-level logging |
|
||||||
| `Startup` | `home` | bool | `false` | Home the gimbal at every start (see [Startup](#startup--homing-and-the-start-mode)). A tower in service needs `true`, and then homes **unattended** at every boot |
|
| `Startup` | `home` | bool | `false` | Home the gimbal at every start (see [Startup](#startup--homing-and-the-start-mode)). A tower in service needs `true`, and then homes **unattended** at every boot |
|
||||||
| `Startup` | `mode` | `manual`/`auto` | `manual` | Mode the tower starts in. `auto` is refused, leaving it in manual, if the gimbal is not homed by then. `pan` is by command only |
|
| `Startup` | `mode` | `manual`/`auto` | `manual` | Mode the tower starts in. `auto` is refused, leaving it in manual, if the gimbal is not homed by then. `pan` is by command only |
|
||||||
|
|
@ -43,8 +43,8 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` |
|
| `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` |
|
||||||
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
|
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
|
||||||
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
|
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
|
||||||
| `Camera.<name>.<profile>` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame |
|
| `Camera.<name>.<profile>` | `settle_wait_ms` | int ≥ 0 | `1000` | Minimum hold after standstill for the auto-exposure to settle. **0 = automatic**: wait until the camera's brightness is on its own target (or exposure and gain are pinned at their limits), at most `frame_timeout_ms`, then keep the frame anyway with a warning |
|
||||||
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Abandon the waypoint after this |
|
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Abandon the waypoint when no frame comes this long after it is due; also the cap on an automatic settle wait |
|
||||||
| `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block |
|
| `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block |
|
||||||
| `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings |
|
| `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings |
|
||||||
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
|
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
|
||||||
|
|
@ -426,7 +426,19 @@ and logs a warning naming every setting that moved:
|
||||||
**Timing is per camera and per profile**, in `[Camera.<name>.<profile>]`. These
|
**Timing is per camera and per profile**, in `[Camera.<name>.<profile>]`. These
|
||||||
are not camera settings — they are how long the program waits around them. With
|
are not camera settings — they are how long the program waits around them. With
|
||||||
several cameras the scan uses the **longest** `settle_wait_ms` across the enabled
|
several cameras the scan uses the **longest** `settle_wait_ms` across the enabled
|
||||||
ones, since the gimbal must stand still for all of them.
|
ones, since the gimbal must stand still for all of them — or automatic, if any of
|
||||||
|
them is on 0.
|
||||||
|
|
||||||
|
**Automatic settle wait (`settle_wait_ms = 0`).** From standstill the program
|
||||||
|
watches the frames that follow. The exposure counts as settled when the mean
|
||||||
|
brightness of the last two frames sits within the camera's own
|
||||||
|
`IntensityControllerTarget` ± `IntensityControllerTolerance` (plus a small margin,
|
||||||
|
not yet tuned on the rig), or when the frames are off target with exposure and
|
||||||
|
gain pinned at their auto limits, where waiting cannot help. "Exposure and gain
|
||||||
|
stopped changing" is deliberately not the test: auto-exposure moves in steps and
|
||||||
|
pauses ([camera-capture-measurements.md](camera-capture-measurements.md)). Only 8-bit
|
||||||
|
pixel formats can be measured; with any other format, or a camera without those
|
||||||
|
features, the wait falls back to a fixed 1000 ms and says so once.
|
||||||
|
|
||||||
Every `*.xml` in `profile_dir` is loaded at start-up, so a profile nobody is
|
Every `*.xml` in `profile_dir` is loaded at start-up, so a profile nobody is
|
||||||
using today still fails at launch rather than the first time someone switches to
|
using today still fails at launch rather than the first time someone switches to
|
||||||
|
|
@ -654,7 +666,7 @@ Handled in `Application::Impl::handleCommand`.
|
||||||
| `trace all` / `trace off` | Enable every category / silence all |
|
| `trace all` / `trace off` | Enable every category / silence all |
|
||||||
| `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) |
|
| `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) |
|
||||||
| `snapshot` | One image now, through the scan's settle-and-trigger path (manual mode, homed) |
|
| `snapshot` | One image now, through the scan's settle-and-trigger path (manual mode, homed) |
|
||||||
| `capture [on\|off]` | Continuous capture where the gimbal is aimed: one image per `image_interval` + standstill + `settle_wait_ms`, kept only at rest. Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report |
|
| `capture [on\|off]` | Continuous capture where the gimbal is aimed: one image per `image_interval` since the last, each once the gimbal stands still and the exposure has settled (`settle_wait_ms`). Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report |
|
||||||
| `camera reset` | Reboot the cameras and put them back on their profiles |
|
| `camera reset` | Reboot the cameras and put them back on their profiles |
|
||||||
| `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain |
|
| `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain |
|
||||||
| `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) |
|
| `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) |
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ doctest unit-test suite (`ctest`).
|
||||||
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Home, then run `gimbal heading setup` (yaw, needs no IMU) and `gimbal pitch setup` (pitch, needs the IMU) on the rig. |
|
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Home, then run `gimbal heading setup` (yaw, needs no IMU) and `gimbal pitch setup` (pitch, needs the IMU) on the rig. |
|
||||||
| 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). |
|
| 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 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. **2026-09-24:** tower 121 has still never imaged a scene - a frame grabbed directly and the program's own saved images (2026-09-21) are a smooth grey gradient with no edges, i.e. the lens is covered or grossly out of focus. Tower 111's images from the same afternoon show nothing sharp anywhere either (soft blobs, no edges) - defocus, a cover or a fogged window; unexplained. `test camera` (below) proves the camera and its data path work in both cases; it cannot, by design, judge the picture. **Stream stuck, 2026-09-24:** from 16:10 to 16:19 tower 111's camera delivered no frames at all - not to the program, not to a fresh Vimba session, not at a fixed 10 ms exposure - and it survived program restarts and a USB re-authorisation. The camera's own `DeviceReset` command cleared it. Production captured nothing for those 9 minutes and logged only `no frame yet (stream warming up)`: a session that has NEVER delivered is not restarted by `trigger()`, by design, so a stream stuck from its start stays stuck. It began just after a `test camera` run by an early version that stopped and restarted the stream several times in a few seconds (to write `BlackLevel`, which the camera refuses while streaming); whether that caused it or met 111's known USB stall is not established. `test camera` itself reported it correctly. **Answered the same day:** a stream that never delivers is now restarted once the wait exceeds what its exposure can explain (at least 30 s), and after two fruitless restarts the camera is reset (DeviceReset) and reopened on its profile - both by the program itself (`CameraStall.h`), and on demand with the console command `camera reset` (verified on tower 121: back in ~14 s, frames straight after). `test camera` no longer stops the stream at all: it changes only exposure and gain, which are writable live, and puts them back live. With that, 111 ran it inside the capturing program, passed, and kept capturing. |
|
| 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. **2026-09-24:** tower 121 has still never imaged a scene - a frame grabbed directly and the program's own saved images (2026-09-21) are a smooth grey gradient with no edges, i.e. the lens is covered or grossly out of focus. Tower 111's images from the same afternoon show nothing sharp anywhere either (soft blobs, no edges) - defocus, a cover or a fogged window; unexplained. `test camera` (below) proves the camera and its data path work in both cases; it cannot, by design, judge the picture. **Stream stuck, 2026-09-24:** from 16:10 to 16:19 tower 111's camera delivered no frames at all - not to the program, not to a fresh Vimba session, not at a fixed 10 ms exposure - and it survived program restarts and a USB re-authorisation. The camera's own `DeviceReset` command cleared it. Production captured nothing for those 9 minutes and logged only `no frame yet (stream warming up)`: a session that has NEVER delivered is not restarted by `trigger()`, by design, so a stream stuck from its start stays stuck. It began just after a `test camera` run by an early version that stopped and restarted the stream several times in a few seconds (to write `BlackLevel`, which the camera refuses while streaming); whether that caused it or met 111's known USB stall is not established. `test camera` itself reported it correctly. **Answered the same day:** a stream that never delivers is now restarted once the wait exceeds what its exposure can explain (at least 30 s), and after two fruitless restarts the camera is reset (DeviceReset) and reopened on its profile - both by the program itself (`CameraStall.h`), and on demand with the console command `camera reset` (verified on tower 121: back in ~14 s, frames straight after). `test camera` no longer stops the stream at all: it changes only exposure and gain, which are writable live, and puts them back live. With that, 111 ran it inside the capturing program, passed, and kept capturing. |
|
||||||
| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. **Seen in real data 2026-09-24:** of eight consecutive images tower 111 kept at 8 deg/s, one was 98.6 % clipped white, one 57 % black and two about half clipped - frames kept before the continuous auto-exposure had caught up with the new view. |
|
| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. **Seen in real data 2026-09-24:** of eight consecutive images tower 111 kept at 8 deg/s, one was 98.6 % clipped white, one 57 % black and two about half clipped - frames kept before the continuous auto-exposure had caught up with the new view. **`settle_wait_ms = 0` now waits automatically** for the brightness to reach the camera's target; its margin (`kSettleMarginPct` in [VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp)) and how our sampled byte mean compares with the camera's own measure are **not yet checked on the rig** - the Camera TRACE log prints measured against target at every waypoint. |
|
||||||
| 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. **Solved on tower 121 by the setup kit** (`hardware/lattepanda-iota/preconditions/`): a systemd-tmpfiles drop-in for usbfs and two udev rules, all installed by `install.sh --preconditions`. Verified 2026-09-23 — `usbfs_memory_mb` reads 1000 after **6 days of uptime**, so it survived the boot, and both rules are in `/etc/udev/rules.d/`; and again across a deliberate reboot the same day, when `install-tower --verify` read `usbfs_memory_mb` 1000 and the camera port's `usb3_lpm_permit` 0 (a check `test hardware camera` now makes too, once `[Test] usb3_lpm_path` is set - the installer sets it). Note it is **tmpfiles, not the kernel cmdline** the original entry proposed. **On the Delta fleet**: tower 111 read 16 and `u1_u2` on 2026-09-23, and the Delta kit (`hardware/lattepanda-3-delta/`, the same two files) was applied the same day - 1000 and `0` afterwards, confirmed by `test hardware camera`. Confirmed across a reboot on 2026-09-24 (`install-tower --verify`). **Solved on both boards**; open only for Delta towers the kit has not reached. |
|
| 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. **Solved on tower 121 by the setup kit** (`hardware/lattepanda-iota/preconditions/`): a systemd-tmpfiles drop-in for usbfs and two udev rules, all installed by `install.sh --preconditions`. Verified 2026-09-23 — `usbfs_memory_mb` reads 1000 after **6 days of uptime**, so it survived the boot, and both rules are in `/etc/udev/rules.d/`; and again across a deliberate reboot the same day, when `install-tower --verify` read `usbfs_memory_mb` 1000 and the camera port's `usb3_lpm_permit` 0 (a check `test hardware camera` now makes too, once `[Test] usb3_lpm_path` is set - the installer sets it). Note it is **tmpfiles, not the kernel cmdline** the original entry proposed. **On the Delta fleet**: tower 111 read 16 and `u1_u2` on 2026-09-23, and the Delta kit (`hardware/lattepanda-3-delta/`, the same two files) was applied the same day - 1000 and `0` afterwards, confirmed by `test hardware camera`. Confirmed across a reboot on 2026-09-24 (`install-tower --verify`). **Solved on both boards**; open only for Delta towers the kit has not reached. |
|
||||||
|
|
||||||
## Camera acquisition (USB3) — root cause + approach
|
## Camera acquisition (USB3) — root cause + approach
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ edges included: 75, 81, 87, 93, 99, 105 - six pictures. A span that does not
|
||||||
divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A
|
divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A
|
||||||
span under one picture is the one heading, photographed once per loop. Pitch is
|
span under one picture is the one heading, photographed once per loop. Pitch is
|
||||||
optional and defaults to 0 (level); a yaw-only tower ignores it, with a warning. Pictures are taken at the same pace as auto
|
optional and defaults to 0 (level); a yaw-only tower ignores it, with a warning. Pictures are taken at the same pace as auto
|
||||||
(`image_interval`), and each publishes a CamEvent exactly as a sweep picture
|
(one per `image_interval`, counted from the last picture), and each publishes a CamEvent exactly as a sweep picture
|
||||||
does.
|
does.
|
||||||
|
|
||||||
**Headings are true bearings and can be out of reach.** Yaw travel is under a
|
**Headings are true bearings and can be out of reach.** Yaw travel is under a
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,23 @@ namespace fgc {
|
||||||
//
|
//
|
||||||
// Each tick():
|
// Each tick():
|
||||||
// 1. reads motor telemetry (per-axis encoder counts + state),
|
// 1. reads motor telemetry (per-axis encoder counts + state),
|
||||||
// 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when
|
// 2. runs the capture cycle. The interval is the time from one kept frame to
|
||||||
// the interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next
|
// the next: as soon as a frame is kept it issues an absolute
|
||||||
// target, waits until both axes report standstill at that target, waits a
|
// `MOVE <yaw>,<pitch>` to the next target, waits until both axes report
|
||||||
// further settle_wait_ms so the free-running camera has exposed a frame with
|
// standstill there, waits for the exposure to settle, then waits out what
|
||||||
// the gimbal at rest, then keeps the newest frame. If none is ready it
|
// is left of the interval and keeps the newest frame. If none is ready it
|
||||||
// retries each tick, and gives up on the waypoint after frame_timeout_ms.
|
// retries each tick, and gives up on the waypoint after frame_timeout_ms.
|
||||||
//
|
//
|
||||||
|
// The exposure wait is settle_wait_ms, or with 0 automatic: until the camera
|
||||||
|
// reports its auto-exposure on target or pinned at its limits
|
||||||
|
// (ICameraSource::exposureState), at most frame_timeout_ms - after which the
|
||||||
|
// frame is kept anyway, with a warning. A camera that cannot tell gets
|
||||||
|
// kFallbackSettleMs.
|
||||||
|
//
|
||||||
|
// When the move and the exposure wait alone take longer than the interval,
|
||||||
|
// that waypoint warns - except the return from the last waypoint to the
|
||||||
|
// first, and the first move after a mode starts, which are long by nature.
|
||||||
|
//
|
||||||
// What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front
|
// What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front
|
||||||
// to back, repeating; pan walks panHeadings() left to right at the pan's pitch,
|
// to back, repeating; pan walks panHeadings() left to right at the pan's pitch,
|
||||||
// then jumps back to the left edge; manual captures nothing, bar a snapshot or
|
// then jumps back to the left edge; manual captures nothing, bar a snapshot or
|
||||||
|
|
@ -59,17 +69,18 @@ public:
|
||||||
bool snapshotPending() const { return snapshot_pending_; }
|
bool snapshotPending() const { return snapshot_pending_; }
|
||||||
|
|
||||||
// Continuous capture (`capture on|off`): in manual, keep a frame on the scan's
|
// Continuous capture (`capture on|off`): in manual, keep a frame on the scan's
|
||||||
// own cycle - the interval, then standstill + settle wait - wherever the
|
// own cycle - one per interval since the last, once standstill + the exposure
|
||||||
// operator has aimed. The gimbal stays free to nudge; a frame is only kept at
|
// wait are done - wherever the operator has aimed. The gimbal stays free to
|
||||||
// rest. Ignored outside manual, and cleared by any setMode().
|
// nudge; a frame is only kept at rest. No overrun warnings: the operator may be
|
||||||
|
// jogging. Ignored outside manual, and cleared by any setMode().
|
||||||
void setContinuous(bool on);
|
void setContinuous(bool on);
|
||||||
bool continuous() const { return continuous_; }
|
bool continuous() const { return continuous_; }
|
||||||
|
|
||||||
double imageRate() const { return image_rate_; } // images per second
|
double imageRate() const { return image_rate_; } // images per second
|
||||||
|
|
||||||
// Per-camera-profile timing (see CameraProfile): how long to wait after standstill
|
// Per-camera-profile timing (see CameraProfile): how long to wait after standstill
|
||||||
// before keeping a frame, and how long to keep retrying before abandoning the
|
// for the exposure to settle (0 = automatic), and how long to keep retrying
|
||||||
// waypoint. Called on the control thread, same as tick().
|
// before abandoning the waypoint. Called on the control thread, same as tick().
|
||||||
void setSettleWait(int settle_ms, int timeout_ms);
|
void setSettleWait(int settle_ms, int timeout_ms);
|
||||||
int settleWaitMs() const { return settle_wait_ms_; }
|
int settleWaitMs() const { return settle_wait_ms_; }
|
||||||
|
|
||||||
|
|
@ -85,20 +96,51 @@ public:
|
||||||
long yawTargetCounts() const { return yaw_target_; }
|
long yawTargetCounts() const { return yaw_target_; }
|
||||||
long pitchTargetCounts() const { return pitch_target_; }
|
long pitchTargetCounts() const { return pitch_target_; }
|
||||||
bool moving() const { return moving_; }
|
bool moving() const { return moving_; }
|
||||||
|
// Where the current waypoint is: "moving", "settling" (exposure) or "waiting"
|
||||||
|
// (for the rest of the interval). For the status line.
|
||||||
|
const char* phase() const;
|
||||||
|
// Waypoints that warned for running past the interval since construction.
|
||||||
|
int overruns() const { return overruns_; }
|
||||||
|
|
||||||
|
// The fixed wait used when the settle wait is automatic but the camera
|
||||||
|
// cannot report its exposure.
|
||||||
|
static constexpr int kFallbackSettleMs = 1000;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// One frame's way to the camera, from standstill: when the gimbal came to
|
||||||
|
// rest, when the exposure wait ended, when the trigger fell due (0 = not yet).
|
||||||
|
struct Hold {
|
||||||
|
long long settled_ms = 0;
|
||||||
|
long long ready_ms = 0;
|
||||||
|
long long due_ms = 0;
|
||||||
|
};
|
||||||
|
enum class Dwell { Waiting, Done, TimedOut };
|
||||||
|
enum class Shot { Waiting, Captured, TimedOut };
|
||||||
|
|
||||||
long long elapsedMs() const;
|
long long elapsedMs() const;
|
||||||
void resetTimer();
|
void resetTimer();
|
||||||
void sendMove(); // emit MOVE to (yaw_target_, pitch_target_)
|
void sendMove(); // emit MOVE to (yaw_target_, pitch_target_)
|
||||||
bool settledAt(const MotorTelemetry& t) const;
|
bool settledAt(const MotorTelemetry& t) const;
|
||||||
|
bool startNextMove(); // pick the next target and MOVE; false if none
|
||||||
|
void finishWaypoint();
|
||||||
|
std::string waypointLabel() const; // "waypoint 3/15" / "pan position 2/6"
|
||||||
void serviceSnapshot(const MotorTelemetry& t);
|
void serviceSnapshot(const MotorTelemetry& t);
|
||||||
void serviceContinuous(const MotorTelemetry& t);
|
void serviceContinuous(const MotorTelemetry& t);
|
||||||
|
|
||||||
|
// Standstill seen: begin the exposure wait.
|
||||||
|
void beginHold(Hold& h);
|
||||||
|
// Whether the exposure wait that began at h.settled_ms is over (sets
|
||||||
|
// h.ready_ms). `what` names the frame in the warning when it times out.
|
||||||
|
bool dwellOver(Hold& h, const std::string& what);
|
||||||
|
Dwell dwell(long long settled_ms);
|
||||||
|
// Trigger until a frame comes or frame_timeout_ms passes since it fell due.
|
||||||
|
Shot fire(Hold& h);
|
||||||
|
|
||||||
// The part a snapshot and a continuous-capture frame share: wait for the
|
// The part a snapshot and a continuous-capture frame share: wait for the
|
||||||
// gimbal to stand still, then the settle wait, then trigger until the frame
|
// gimbal to stand still, then the exposure wait, then (continuous only) the
|
||||||
// budget runs out. `settled_ms` is that request's own settle clock.
|
// rest of the interval, then trigger until the frame budget runs out.
|
||||||
enum class Shot { Waiting, Captured, TimedOut };
|
Shot shootAtRest(const MotorTelemetry& t, Hold& h, const std::string& what,
|
||||||
Shot shootAtRest(const MotorTelemetry& t, long long& settled_ms);
|
bool wait_interval);
|
||||||
|
|
||||||
IMotorController& motor_;
|
IMotorController& motor_;
|
||||||
ICameraSource& camera_;
|
ICameraSource& camera_;
|
||||||
|
|
@ -107,7 +149,8 @@ private:
|
||||||
|
|
||||||
std::function<long long()> now_ms_;
|
std::function<long long()> now_ms_;
|
||||||
double image_rate_;
|
double image_rate_;
|
||||||
long long timer_start_ = 0;
|
double interval_ms_; // 1000 / image_rate_; 0 = never
|
||||||
|
long long timer_start_ = 0; // the last frame kept or given up, or the mode start
|
||||||
|
|
||||||
Mode mode_ = Mode::Manual;
|
Mode mode_ = Mode::Manual;
|
||||||
PanSpec pan_;
|
PanSpec pan_;
|
||||||
|
|
@ -115,8 +158,13 @@ private:
|
||||||
std::vector<double> pan_headings_;
|
std::vector<double> pan_headings_;
|
||||||
size_t pan_index_ = 0;
|
size_t pan_index_ = 0;
|
||||||
|
|
||||||
bool moving_ = false; // a MOVE has been issued, awaiting settle
|
bool moving_ = false; // a MOVE has been issued, its frame not yet kept
|
||||||
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet)
|
Hold hold_; // the current waypoint's frame
|
||||||
|
long long move_sent_ms_ = 0;
|
||||||
|
bool first_move_ = true; // no move yet since setMode()
|
||||||
|
bool quiet_overrun_ = false; // this waypoint is a long move by nature
|
||||||
|
int overruns_ = 0;
|
||||||
|
bool unknown_warned_ = false; // the fallback has been announced this mode
|
||||||
int settle_wait_ms_ = 1000;
|
int settle_wait_ms_ = 1000;
|
||||||
int frame_timeout_ms_ = 2000;
|
int frame_timeout_ms_ = 2000;
|
||||||
bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig)
|
bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig)
|
||||||
|
|
@ -126,9 +174,9 @@ private:
|
||||||
bool pan_unreachable_warned_ = false;
|
bool pan_unreachable_warned_ = false;
|
||||||
|
|
||||||
bool snapshot_pending_ = false; // a manual snapshot is armed
|
bool snapshot_pending_ = false; // a manual snapshot is armed
|
||||||
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet)
|
Hold snapshot_hold_;
|
||||||
bool continuous_ = false; // `capture on`
|
bool continuous_ = false; // `capture on`
|
||||||
long long continuous_settled_ms_ = 0;
|
Hold continuous_hold_;
|
||||||
long yaw_target_ = 0; // current target, encoder counts
|
long yaw_target_ = 0; // current target, encoder counts
|
||||||
long pitch_target_ = 0;
|
long pitch_target_ = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ namespace fgc {
|
||||||
|
|
||||||
struct GeneralConfig {
|
struct GeneralConfig {
|
||||||
std::string tower_name = "Unnamed";
|
std::string tower_name = "Unnamed";
|
||||||
int image_interval = 5; // seconds between captures
|
double image_interval = 5; // desired seconds from one picture to the next
|
||||||
bool debug = false; // print motor telemetry each loop tick
|
bool debug = false; // print motor telemetry each loop tick
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -177,10 +177,15 @@ struct SerialConfig {
|
||||||
// camera settings - the camera's own settings live entirely in the profile XML -
|
// camera settings - the camera's own settings live entirely in the profile XML -
|
||||||
// but timing the program must observe around them.
|
// but timing the program must observe around them.
|
||||||
struct CameraProfileTiming {
|
struct CameraProfileTiming {
|
||||||
int settle_wait_ms = 1000; // hold after standstill before keeping a frame
|
int settle_wait_ms = 1000; // hold after standstill for exposure to settle; 0 = automatic
|
||||||
int frame_timeout_ms = 2000; // give up on the waypoint after this
|
int frame_timeout_ms = 2000; // give up on the waypoint after this
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The settle wait several cameras need together: the longest, except that one
|
||||||
|
// camera on automatic (0) makes it automatic, which waits for every camera.
|
||||||
|
// None at all gives the default.
|
||||||
|
int combinedSettleWaitMs(const std::vector<int>& waits);
|
||||||
|
|
||||||
// One camera, from a [Camera.<name>] block. Nothing is implied by position: the
|
// One camera, from a [Camera.<name>] block. Nothing is implied by position: the
|
||||||
// block names the camera, `label` names its output, `driver` picks the backend.
|
// block names the camera, `label` names its output, `driver` picks the backend.
|
||||||
struct CameraSpec {
|
struct CameraSpec {
|
||||||
|
|
@ -212,7 +217,8 @@ struct CameraConfig {
|
||||||
// Enabled cameras only.
|
// Enabled cameras only.
|
||||||
std::vector<const CameraSpec*> active() const;
|
std::vector<const CameraSpec*> active() const;
|
||||||
// The gimbal must stand still long enough for EVERY active camera, so the
|
// The gimbal must stand still long enough for EVERY active camera, so the
|
||||||
// scheduler takes the longest wait rather than the first camera's.
|
// scheduler takes the longest wait rather than the first camera's (0 =
|
||||||
|
// automatic if any camera asks for it; see combinedSettleWaitMs).
|
||||||
int maxSettleWaitMs() const;
|
int maxSettleWaitMs() const;
|
||||||
int maxFrameTimeoutMs() const;
|
int maxFrameTimeoutMs() const;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,16 @@ struct CameraDeviceInfo {
|
||||||
long long restarts = 0; // stream restarts (restart-on-stall)
|
long long restarts = 0; // stream restarts (restart-on-stall)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Whether on-camera auto-exposure has settled, judged on the frames since
|
||||||
|
// beginExposureWatch(). Worst first, so the state of several cameras together
|
||||||
|
// is the lowest of theirs.
|
||||||
|
enum class ExposureState {
|
||||||
|
Unknown, // the source cannot tell (no brightness or no auto-exposure readout)
|
||||||
|
Converging, // still moving towards its target, or too few fresh frames yet
|
||||||
|
AtLimit, // off target with exposure and gain at their limits: waiting cannot help
|
||||||
|
Settled, // brightness on the camera's own target
|
||||||
|
};
|
||||||
|
|
||||||
// Abstraction over the camera array. Implemented by VimbaCameraSource (Allied
|
// Abstraction over the camera array. Implemented by VimbaCameraSource (Allied
|
||||||
// Vision Vimba X) and MockCameraSource (synthetic frames, no hardware).
|
// Vision Vimba X) and MockCameraSource (synthetic frames, no hardware).
|
||||||
//
|
//
|
||||||
|
|
@ -77,6 +87,13 @@ public:
|
||||||
|
|
||||||
virtual void setFrameCallback(FrameCallback cb) = 0;
|
virtual void setFrameCallback(FrameCallback cb) = 0;
|
||||||
|
|
||||||
|
// Automatic settle wait (settle_wait_ms = 0). The scheduler calls
|
||||||
|
// beginExposureWatch() when the gimbal comes to rest; exposureState() then
|
||||||
|
// judges only frames that arrived after it, so frames exposed during the slew
|
||||||
|
// never count. Defaults: Unknown, and the scheduler falls back to a fixed wait.
|
||||||
|
virtual void beginExposureWatch() {}
|
||||||
|
virtual ExposureState exposureState() { return ExposureState::Unknown; }
|
||||||
|
|
||||||
// Number of cameras this source manages.
|
// Number of cameras this source manages.
|
||||||
virtual int cameraCount() const = 0;
|
virtual int cameraCount() const = 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ public:
|
||||||
bool trigger() override;
|
bool trigger() override;
|
||||||
bool setProfile(int camera_index, const CameraProfile& profile) override;
|
bool setProfile(int camera_index, const CameraProfile& profile) override;
|
||||||
void setFrameCallback(FrameCallback cb) override;
|
void setFrameCallback(FrameCallback cb) override;
|
||||||
|
void beginExposureWatch() override;
|
||||||
|
ExposureState exposureState() override;
|
||||||
int cameraCount() const override;
|
int cameraCount() const override;
|
||||||
std::vector<CameraDeviceInfo> deviceInfo() override;
|
std::vector<CameraDeviceInfo> deviceInfo() override;
|
||||||
bool grabFrame(int camera_index, Frame& out, int timeout_ms) override;
|
bool grabFrame(int camera_index, Frame& out, int timeout_ms) override;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ public:
|
||||||
}
|
}
|
||||||
|
|
||||||
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
|
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
|
||||||
|
|
||||||
|
// No auto-exposure to wait for: settled as soon as the gimbal is at rest, so
|
||||||
|
// --mock-camera runs exercise the automatic settle wait.
|
||||||
|
ExposureState exposureState() override { return ExposureState::Settled; }
|
||||||
int cameraCount() const override { return count_; }
|
int cameraCount() const override { return count_; }
|
||||||
|
|
||||||
bool setProfile(int camera_index, const CameraProfile& profile) override {
|
bool setProfile(int camera_index, const CameraProfile& profile) override {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
@ -46,8 +47,40 @@ struct CamStats {
|
||||||
std::atomic<unsigned> last_w{0};
|
std::atomic<unsigned> last_w{0};
|
||||||
std::atomic<unsigned> last_h{0};
|
std::atomic<unsigned> last_h{0};
|
||||||
std::atomic<long long> last_bytes{0};
|
std::atomic<long long> last_bytes{0};
|
||||||
|
|
||||||
|
// Brightness of the newest two measured frames, for the automatic settle
|
||||||
|
// wait. `measured` counts them; `measurable` is false while the stream's
|
||||||
|
// pixel format is one brightness() cannot read.
|
||||||
|
std::mutex bright_mutex;
|
||||||
|
long long measured = 0;
|
||||||
|
double bright_last = 0, bright_prev = 0; // percent of full scale
|
||||||
|
std::atomic<bool> measurable{true};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mean brightness, in percent of full scale, of an 8-bit frame (1 or 3 bytes per
|
||||||
|
// pixel), sampled every 7th pixel of every 7th row: plenty for a mean, and an
|
||||||
|
// odd stride visits every colour site of a Bayer mosaic. -1 for formats it
|
||||||
|
// cannot read (more than 8 bits, packed).
|
||||||
|
double brightness(const VmbUchar_t* buf, VmbUint32_t w, VmbUint32_t h, size_t size,
|
||||||
|
VmbPixelFormatType fmt) {
|
||||||
|
// PFNC: bits 16-23 of the format code are the bits each pixel occupies.
|
||||||
|
const unsigned bits = (static_cast<unsigned>(fmt) >> 16) & 0xFFu;
|
||||||
|
if (bits != 8 && bits != 24) return -1.0;
|
||||||
|
const size_t bpp = bits / 8;
|
||||||
|
if (size < static_cast<size_t>(w) * h * bpp) return -1.0;
|
||||||
|
constexpr VmbUint32_t kStride = 7;
|
||||||
|
unsigned long long sum = 0, n = 0;
|
||||||
|
for (VmbUint32_t y = 0; y < h; y += kStride) {
|
||||||
|
const VmbUchar_t* row = buf + static_cast<size_t>(y) * w * bpp;
|
||||||
|
for (VmbUint32_t x = 0; x < w; x += kStride)
|
||||||
|
for (size_t c = 0; c < bpp; ++c) {
|
||||||
|
sum += row[static_cast<size_t>(x) * bpp + c];
|
||||||
|
++n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n ? 100.0 * static_cast<double>(sum) / (255.0 * static_cast<double>(n)) : -1.0;
|
||||||
|
}
|
||||||
|
|
||||||
// Frame observer for the free-running stream: keeps the most recent COMPLETE
|
// Frame observer for the free-running stream: keeps the most recent COMPLETE
|
||||||
// frame. trigger() (once per scan waypoint) reads it out; the intermediate frames
|
// frame. trigger() (once per scan waypoint) reads it out; the intermediate frames
|
||||||
// just give the on-camera auto-exposure/gain time to converge to the current
|
// just give the on-camera auto-exposure/gain time to converge to the current
|
||||||
|
|
@ -89,12 +122,22 @@ public:
|
||||||
f.cam_id = cam_id_;
|
f.cam_id = cam_id_;
|
||||||
f.timestamp_ms = nowMs();
|
f.timestamp_ms = nowMs();
|
||||||
f.data.assign(buf, buf + sz);
|
f.data.assign(buf, buf + sz);
|
||||||
|
VmbPixelFormatType fmt{};
|
||||||
|
const double mean = pFrame->GetPixelFormat(fmt) == VmbErrorSuccess
|
||||||
|
? brightness(buf, w, h, sz, fmt) : -1.0;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
latest_ = std::move(f);
|
latest_ = std::move(f);
|
||||||
has_ = true;
|
has_ = true;
|
||||||
}
|
}
|
||||||
if (stats_) {
|
if (stats_) {
|
||||||
|
stats_->measurable.store(mean >= 0.0);
|
||||||
|
if (mean >= 0.0) {
|
||||||
|
std::lock_guard<std::mutex> lock(stats_->bright_mutex);
|
||||||
|
stats_->bright_prev = stats_->bright_last;
|
||||||
|
stats_->bright_last = mean;
|
||||||
|
++stats_->measured;
|
||||||
|
}
|
||||||
stats_->delivered.fetch_add(1);
|
stats_->delivered.fetch_add(1);
|
||||||
stats_->last_w.store(w);
|
stats_->last_w.store(w);
|
||||||
stats_->last_h.store(h);
|
stats_->last_h.store(h);
|
||||||
|
|
@ -166,6 +209,39 @@ bool applyProfile(const CameraPtr& cam, const CameraProfile& profile) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How far, in percent of full scale, a frame's brightness may sit outside the
|
||||||
|
// camera's own IntensityControllerTarget +- IntensityControllerTolerance and
|
||||||
|
// still count as settled. Slack for our sampled byte mean not being exactly the
|
||||||
|
// camera's measure. Not yet tuned on the rig: the per-waypoint TRACE line shows
|
||||||
|
// measured against target.
|
||||||
|
constexpr double kSettleMarginPct = 2.5;
|
||||||
|
|
||||||
|
// A camera's auto-exposure setup, read when a watch begins (a profile switch or
|
||||||
|
// `test camera` may have changed it since the last one).
|
||||||
|
struct AutoExposure {
|
||||||
|
bool known = false; // the camera has the features below
|
||||||
|
bool exposure_auto = false; // ExposureAuto is Continuous
|
||||||
|
bool gain_auto = false; // GainAuto is Continuous
|
||||||
|
double target = 0, tolerance = 0; // percent of full scale
|
||||||
|
double exposure_min = 0, exposure_max = 0; // us, the auto range
|
||||||
|
double gain_min = 0, gain_max = 0; // dB, the auto range
|
||||||
|
};
|
||||||
|
|
||||||
|
AutoExposure readAutoExposure(const CameraPtr& cam) {
|
||||||
|
AutoExposure a;
|
||||||
|
a.target = getFloat(cam, "IntensityControllerTarget", -1.0);
|
||||||
|
a.tolerance = getFloat(cam, "IntensityControllerTolerance", -1.0);
|
||||||
|
if (a.target < 0.0 || a.tolerance < 0.0) return a;
|
||||||
|
a.known = true;
|
||||||
|
a.exposure_auto = getString(cam, "ExposureAuto") == "Continuous";
|
||||||
|
a.gain_auto = getString(cam, "GainAuto") == "Continuous";
|
||||||
|
a.exposure_min = getFloat(cam, "ExposureAutoMin");
|
||||||
|
a.exposure_max = getFloat(cam, "ExposureAutoMax");
|
||||||
|
a.gain_min = getFloat(cam, "GainAutoMin");
|
||||||
|
a.gain_max = getFloat(cam, "GainAutoMax");
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
struct VimbaCameraSource::Impl {
|
struct VimbaCameraSource::Impl {
|
||||||
|
|
@ -198,6 +274,10 @@ struct VimbaCameraSource::Impl {
|
||||||
// it, in the order changed - so it can be put back live, without stopping
|
// it, in the order changed - so it can be put back live, without stopping
|
||||||
// the stream (which is what a profile reload has to do).
|
// the stream (which is what a profile reload has to do).
|
||||||
std::vector<std::vector<std::pair<std::string, std::string>>> touched;
|
std::vector<std::vector<std::pair<std::string, std::string>>> touched;
|
||||||
|
// Automatic settle wait: per camera, the setup and the measured-frame count
|
||||||
|
// when the current watch began.
|
||||||
|
std::vector<AutoExposure> watch_ae;
|
||||||
|
std::vector<long long> watch_from;
|
||||||
|
|
||||||
// Begin/end a free-run acquisition session (fresh observers each time). Split out so
|
// Begin/end a free-run acquisition session (fresh observers each time). Split out so
|
||||||
// trigger() can restart the stream when it stalls. Stats persist across restarts.
|
// trigger() can restart the stream when it stalls. Stats persist across restarts.
|
||||||
|
|
@ -494,6 +574,70 @@ bool VimbaCameraSource::trigger() {
|
||||||
|
|
||||||
void VimbaCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = std::move(cb); }
|
void VimbaCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = std::move(cb); }
|
||||||
|
|
||||||
|
void VimbaCameraSource::beginExposureWatch() {
|
||||||
|
const size_t n = impl_->cameras.size();
|
||||||
|
impl_->watch_ae.assign(n, {});
|
||||||
|
impl_->watch_from.assign(n, 0);
|
||||||
|
for (size_t i = 0; i < n; ++i) {
|
||||||
|
impl_->watch_ae[i] = readAutoExposure(impl_->cameras[i]);
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->stats[i]->bright_mutex);
|
||||||
|
impl_->watch_from[i] = impl_->stats[i]->measured;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settled when the last two frames since the watch began sit on the camera's own
|
||||||
|
// target. Comparing exposure and gain between frames is not enough: auto-exposure
|
||||||
|
// moves in steps and pauses, and "unchanged for 3 frames" fired while brightness
|
||||||
|
// was still 17-300 % off (docs/camera-capture-measurements.md). Off target with
|
||||||
|
// exposure and gain pinned at their auto limits is AtLimit: waiting cannot help.
|
||||||
|
ExposureState VimbaCameraSource::exposureState() {
|
||||||
|
if (impl_->watch_ae.size() != impl_->cameras.size() || impl_->cameras.empty())
|
||||||
|
return ExposureState::Unknown;
|
||||||
|
ExposureState worst = ExposureState::Settled;
|
||||||
|
for (size_t i = 0; i < impl_->cameras.size(); ++i) {
|
||||||
|
const AutoExposure& ae = impl_->watch_ae[i];
|
||||||
|
CamStats& st = *impl_->stats[i];
|
||||||
|
if (!ae.known || !st.measurable.load()) return ExposureState::Unknown;
|
||||||
|
|
||||||
|
long long since;
|
||||||
|
double last, prev;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(st.bright_mutex);
|
||||||
|
since = st.measured - impl_->watch_from[i];
|
||||||
|
last = st.bright_last;
|
||||||
|
prev = st.bright_prev;
|
||||||
|
}
|
||||||
|
// Two fresh frames: the first after standstill may have been exposed partly
|
||||||
|
// during the slew.
|
||||||
|
ExposureState s = ExposureState::Converging;
|
||||||
|
if (since >= 2) {
|
||||||
|
const double half = ae.tolerance + kSettleMarginPct;
|
||||||
|
const bool dark = last < ae.target - half && prev < ae.target - half;
|
||||||
|
const bool bright = last > ae.target + half && prev > ae.target + half;
|
||||||
|
const bool in_band =
|
||||||
|
std::abs(last - ae.target) <= half && std::abs(prev - ae.target) <= half;
|
||||||
|
if (in_band || (!ae.exposure_auto && !ae.gain_auto)) {
|
||||||
|
s = ExposureState::Settled;
|
||||||
|
} else if (dark || bright) {
|
||||||
|
const auto& cam = impl_->cameras[i];
|
||||||
|
const double exp = getFloat(cam, "ExposureTime");
|
||||||
|
const double gn = getFloat(cam, "Gain");
|
||||||
|
const bool exp_pinned = !ae.exposure_auto ||
|
||||||
|
(dark ? exp >= ae.exposure_max * 0.98 : exp <= ae.exposure_min * 1.02);
|
||||||
|
const bool gain_pinned = !ae.gain_auto ||
|
||||||
|
(dark ? gn >= ae.gain_max - 0.1 : gn <= ae.gain_min + 0.1);
|
||||||
|
if (exp_pinned && gain_pinned) s = ExposureState::AtLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOG_TRACE_CAT(LogCat::Camera)
|
||||||
|
<< "exposure cam" << i << ": brightness " << last << " % (previous " << prev
|
||||||
|
<< ") vs target " << ae.target << " +- " << ae.tolerance << ", " << since
|
||||||
|
<< " frames since rest";
|
||||||
|
worst = std::min(worst, s);
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
int VimbaCameraSource::cameraCount() const { return static_cast<int>(impl_->cameras.size()); }
|
int VimbaCameraSource::cameraCount() const { return static_cast<int>(impl_->cameras.size()); }
|
||||||
|
|
||||||
std::vector<CameraDeviceInfo> VimbaCameraSource::deviceInfo() {
|
std::vector<CameraDeviceInfo> VimbaCameraSource::deviceInfo() {
|
||||||
|
|
|
||||||
|
|
@ -505,14 +505,15 @@ struct Application::Impl {
|
||||||
// The gimbal must stand still long enough for every camera, so recompute
|
// The gimbal must stand still long enough for every camera, so recompute
|
||||||
// from all of them rather than from the one that just changed.
|
// from all of them rather than from the one that just changed.
|
||||||
if (scheduler) {
|
if (scheduler) {
|
||||||
int settle = 0, timeout = 0;
|
std::vector<int> settles;
|
||||||
|
int timeout = 0;
|
||||||
for (const auto* c : cfg.camera.active()) {
|
for (const auto* c : cfg.camera.active()) {
|
||||||
const auto t = c->timingFor(active_profile.count(c->name) ? active_profile[c->name]
|
const auto t = c->timingFor(active_profile.count(c->name) ? active_profile[c->name]
|
||||||
: c->profile);
|
: c->profile);
|
||||||
settle = std::max(settle, t.settle_wait_ms);
|
settles.push_back(t.settle_wait_ms);
|
||||||
timeout = std::max(timeout, t.frame_timeout_ms);
|
timeout = std::max(timeout, t.frame_timeout_ms);
|
||||||
}
|
}
|
||||||
scheduler->setSettleWait(settle, timeout);
|
scheduler->setSettleWait(combinedSettleWaitMs(settles), timeout);
|
||||||
}
|
}
|
||||||
LOG_INFO << "camera '" << cam_name << "' profile -> " << name;
|
LOG_INFO << "camera '" << cam_name << "' profile -> " << name;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -1015,7 +1016,7 @@ struct Application::Impl {
|
||||||
a.status = "finding endstops…";
|
a.status = "finding endstops…";
|
||||||
} else if (scheduler && scheduler->captureActive()) {
|
} else if (scheduler && scheduler->captureActive()) {
|
||||||
a.active = true;
|
a.active = true;
|
||||||
const char* phase = scheduler->moving() ? "moving" : "dwell";
|
const char* phase = scheduler->phase();
|
||||||
if (scheduler->mode() == Mode::Pan) {
|
if (scheduler->mode() == Mode::Pan) {
|
||||||
a.title = "PAN";
|
a.title = "PAN";
|
||||||
const auto& h = scheduler->panHeadingsNow();
|
const auto& h = scheduler->panHeadingsNow();
|
||||||
|
|
@ -2072,10 +2073,11 @@ struct Application::Impl {
|
||||||
scheduler->setContinuous(true);
|
scheduler->setContinuous(true);
|
||||||
const double interval_s =
|
const double interval_s =
|
||||||
scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0;
|
scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0;
|
||||||
LOG_INFO << "continuous capture on: one image every ~"
|
const int settle = scheduler->settleWaitMs();
|
||||||
<< interval_s + scheduler->settleWaitMs() / 1000.0 << " s ("
|
LOG_INFO << "continuous capture on: one image every " << interval_s
|
||||||
<< interval_s << " s interval + " << scheduler->settleWaitMs()
|
<< " s, each once the gimbal stands still and the exposure has settled ("
|
||||||
<< " ms settle), kept only while the gimbal stands still";
|
<< (settle == 0 ? std::string("automatic") : std::to_string(settle) + " ms")
|
||||||
|
<< ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
// End continuous capture because something else is taking the gimbal or the
|
// End continuous capture because something else is taking the gimbal or the
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <iomanip>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
|
|
@ -21,6 +23,13 @@ long long steadyNowMs() {
|
||||||
// deadband (~200 counts).
|
// deadband (~200 counts).
|
||||||
constexpr long kSettleTolCounts = 600;
|
constexpr long kSettleTolCounts = 600;
|
||||||
|
|
||||||
|
// Seconds with one decimal, for the overrun warning.
|
||||||
|
std::string seconds(double ms) {
|
||||||
|
std::ostringstream o;
|
||||||
|
o << std::fixed << std::setprecision(1) << ms / 1000.0 << " s";
|
||||||
|
return o.str();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera,
|
CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera,
|
||||||
|
|
@ -31,7 +40,8 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer
|
||||||
geometry_(geometry),
|
geometry_(geometry),
|
||||||
grid_(grid),
|
grid_(grid),
|
||||||
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
|
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
|
||||||
image_rate_(image_rate) {
|
image_rate_(image_rate),
|
||||||
|
interval_ms_(image_rate > 0.0 ? 1000.0 / image_rate : 0.0) {
|
||||||
timer_start_ = now_ms_();
|
timer_start_ = now_ms_();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +52,11 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
|
||||||
pan_index_ = 0;
|
pan_index_ = 0;
|
||||||
pan_unreachable_warned_ = false;
|
pan_unreachable_warned_ = false;
|
||||||
moving_ = false; // restart the move/settle cycle cleanly
|
moving_ = false; // restart the move/settle cycle cleanly
|
||||||
settled_at_ms_ = 0;
|
hold_ = {};
|
||||||
|
// The first move goes from wherever the gimbal is, so it may be long: no
|
||||||
|
// overrun warning for it.
|
||||||
|
first_move_ = true;
|
||||||
|
unknown_warned_ = false;
|
||||||
// Drop any armed snapshot: a scan starting means the operator moved on, and
|
// Drop any armed snapshot: a scan starting means the operator moved on, and
|
||||||
// keeping it would fire an image at some unrelated later moment when the scan
|
// keeping it would fire an image at some unrelated later moment when the scan
|
||||||
// stops - attributed to whatever the gimbal happened to be pointing at then.
|
// stops - attributed to whatever the gimbal happened to be pointing at then.
|
||||||
|
|
@ -50,13 +64,14 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
|
||||||
// Continuous capture is a manual-mode activity: any mode change ends it, so
|
// Continuous capture is a manual-mode activity: any mode change ends it, so
|
||||||
// a later return to manual never resumes shooting nobody asked for.
|
// a later return to manual never resumes shooting nobody asked for.
|
||||||
continuous_ = false;
|
continuous_ = false;
|
||||||
continuous_settled_ms_ = 0;
|
continuous_hold_ = {};
|
||||||
|
// The first frame comes no sooner than one interval after the mode starts.
|
||||||
resetTimer();
|
resetTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void CaptureScheduler::setContinuous(bool on) {
|
void CaptureScheduler::setContinuous(bool on) {
|
||||||
continuous_ = on;
|
continuous_ = on;
|
||||||
continuous_settled_ms_ = 0;
|
continuous_hold_ = {};
|
||||||
// The first frame comes one interval after switching on, as the first scan
|
// The first frame comes one interval after switching on, as the first scan
|
||||||
// waypoint does after `mode auto`.
|
// waypoint does after `mode auto`.
|
||||||
resetTimer();
|
resetTimer();
|
||||||
|
|
@ -85,38 +100,105 @@ bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {
|
||||||
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
|
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CaptureScheduler::requestSnapshot() {
|
const char* CaptureScheduler::phase() const {
|
||||||
snapshot_pending_ = true;
|
if (!moving_ || hold_.settled_ms == 0) return "moving";
|
||||||
snapshot_settled_ms_ = 0;
|
return hold_.ready_ms == 0 ? "settling" : "waiting";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for rest, settle, then shoot - the tail of a scan waypoint without the
|
std::string CaptureScheduler::waypointLabel() const {
|
||||||
// move. A snapshot and a continuous-capture frame both come through here, so
|
if (mode_ == Mode::Pan)
|
||||||
// either is exactly what a scan waypoint produces: same file, same CamEvent.
|
return "pan position " + std::to_string(pan_index_ + 1) + "/" +
|
||||||
//
|
std::to_string(pan_headings_.size());
|
||||||
// Standstill only, no target check: the operator aims, so there is no target.
|
return "waypoint " + std::to_string(grid_.index() + 1) + "/" + std::to_string(grid_.size());
|
||||||
// The frame budget is measured from settling, not from the request - a request
|
}
|
||||||
// made mid-slew must not spend the camera's budget on the slew.
|
|
||||||
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t,
|
void CaptureScheduler::requestSnapshot() {
|
||||||
long long& settled_ms) {
|
snapshot_pending_ = true;
|
||||||
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
|
snapshot_hold_ = {};
|
||||||
if (!standstill) {
|
}
|
||||||
settled_ms = 0; // still slewing (e.g. a jog is in flight)
|
|
||||||
return Shot::Waiting;
|
void CaptureScheduler::beginHold(Hold& h) {
|
||||||
|
h = {};
|
||||||
|
h.settled_ms = now_ms_();
|
||||||
|
if (settle_wait_ms_ == 0) camera_.beginExposureWatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureScheduler::Dwell CaptureScheduler::dwell(long long settled_ms) {
|
||||||
|
const long long since = now_ms_() - settled_ms;
|
||||||
|
int fixed = settle_wait_ms_;
|
||||||
|
if (fixed == 0) {
|
||||||
|
switch (camera_.exposureState()) {
|
||||||
|
case ExposureState::Settled:
|
||||||
|
case ExposureState::AtLimit:
|
||||||
|
return Dwell::Done;
|
||||||
|
case ExposureState::Converging:
|
||||||
|
return since >= frame_timeout_ms_ ? Dwell::TimedOut : Dwell::Waiting;
|
||||||
|
case ExposureState::Unknown:
|
||||||
|
if (!unknown_warned_) {
|
||||||
|
unknown_warned_ = true;
|
||||||
|
LOG_WARN << "settle_wait_ms is 0 (automatic) but the camera cannot report "
|
||||||
|
"its exposure; waiting a fixed "
|
||||||
|
<< kFallbackSettleMs << " ms instead";
|
||||||
}
|
}
|
||||||
if (settled_ms == 0) {
|
fixed = kFallbackSettleMs;
|
||||||
settled_ms = now_ms_();
|
break;
|
||||||
return Shot::Waiting;
|
|
||||||
}
|
}
|
||||||
const long long since_settled = now_ms_() - settled_ms;
|
}
|
||||||
if (since_settled < settle_wait_ms_) return Shot::Waiting;
|
return since >= fixed ? Dwell::Done : Dwell::Waiting;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CaptureScheduler::dwellOver(Hold& h, const std::string& what) {
|
||||||
|
if (h.ready_ms != 0) return true;
|
||||||
|
switch (dwell(h.settled_ms)) {
|
||||||
|
case Dwell::Waiting:
|
||||||
|
return false;
|
||||||
|
case Dwell::TimedOut:
|
||||||
|
LOG_WARN << what << ": exposure not settled " << frame_timeout_ms_
|
||||||
|
<< " ms after the gimbal stopped; keeping the frame anyway";
|
||||||
|
break;
|
||||||
|
case Dwell::Done:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h.ready_ms = now_ms_();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureScheduler::Shot CaptureScheduler::fire(Hold& h) {
|
||||||
|
if (h.due_ms == 0) h.due_ms = now_ms_();
|
||||||
if (camera_.trigger()) return Shot::Captured;
|
if (camera_.trigger()) return Shot::Captured;
|
||||||
if (since_settled > settle_wait_ms_ + frame_timeout_ms_) return Shot::TimedOut;
|
if (now_ms_() - h.due_ms >= frame_timeout_ms_) return Shot::TimedOut;
|
||||||
return Shot::Waiting; // no fresh frame yet; retry next tick
|
return Shot::Waiting; // no fresh frame yet; retry next tick
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for rest and the exposure, then shoot - the tail of a scan waypoint
|
||||||
|
// without the move. A snapshot and a continuous-capture frame both come through
|
||||||
|
// here, so either is exactly what a scan waypoint produces: same file, same
|
||||||
|
// CamEvent.
|
||||||
|
//
|
||||||
|
// Standstill only, no target check: the operator aims, so there is no target.
|
||||||
|
// Movement restarts the wait. The frame budget is measured from when the trigger
|
||||||
|
// fell due, not from the request - a request made mid-slew must not spend the
|
||||||
|
// camera's budget on the slew.
|
||||||
|
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t, Hold& h,
|
||||||
|
const std::string& what,
|
||||||
|
bool wait_interval) {
|
||||||
|
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
|
||||||
|
if (!standstill) {
|
||||||
|
h = {}; // still slewing (e.g. a jog is in flight)
|
||||||
|
return Shot::Waiting;
|
||||||
|
}
|
||||||
|
if (h.settled_ms == 0) {
|
||||||
|
beginHold(h);
|
||||||
|
return Shot::Waiting;
|
||||||
|
}
|
||||||
|
if (!dwellOver(h, what)) return Shot::Waiting;
|
||||||
|
if (wait_interval && (interval_ms_ <= 0.0 || elapsedMs() < interval_ms_))
|
||||||
|
return Shot::Waiting;
|
||||||
|
return fire(h);
|
||||||
|
}
|
||||||
|
|
||||||
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
|
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
|
||||||
switch (shootAtRest(t, snapshot_settled_ms_)) {
|
switch (shootAtRest(t, snapshot_hold_, "snapshot", false)) {
|
||||||
case Shot::Waiting:
|
case Shot::Waiting:
|
||||||
return;
|
return;
|
||||||
case Shot::Captured:
|
case Shot::Captured:
|
||||||
|
|
@ -124,20 +206,17 @@ void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
|
||||||
break;
|
break;
|
||||||
case Shot::TimedOut:
|
case Shot::TimedOut:
|
||||||
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
|
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
|
||||||
<< " ms of settling; send 'snapshot' (Space) again";
|
<< " ms; send 'snapshot' (Space) again";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
snapshot_pending_ = false;
|
snapshot_pending_ = false;
|
||||||
|
snapshot_hold_ = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The scan's cycle without the move: wait the interval, then shoot at rest.
|
// The scan's cycle without the move: one frame per interval since the last,
|
||||||
// The timer restarts after each frame kept or given up, exactly as a scan's
|
// once at rest and the exposure has settled.
|
||||||
// does, so the cadence matches what a scan achieves rather than the bare
|
|
||||||
// interval.
|
|
||||||
void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
|
void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
|
||||||
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
|
switch (shootAtRest(t, continuous_hold_, "continuous capture", true)) {
|
||||||
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
|
|
||||||
switch (shootAtRest(t, continuous_settled_ms_)) {
|
|
||||||
case Shot::Waiting:
|
case Shot::Waiting:
|
||||||
return;
|
return;
|
||||||
case Shot::Captured:
|
case Shot::Captured:
|
||||||
|
|
@ -145,10 +224,88 @@ void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
|
||||||
break;
|
break;
|
||||||
case Shot::TimedOut:
|
case Shot::TimedOut:
|
||||||
LOG_WARN << "continuous capture: no frame within " << frame_timeout_ms_
|
LOG_WARN << "continuous capture: no frame within " << frame_timeout_ms_
|
||||||
<< " ms of settling; trying again next interval";
|
<< " ms; trying again next interval";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continuous_settled_ms_ = 0;
|
continuous_hold_ = {};
|
||||||
|
resetTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the next target, skipping unreachable ones in this same tick (a skip
|
||||||
|
// costs no time and does not restart the interval), and MOVE there.
|
||||||
|
bool CaptureScheduler::startNextMove() {
|
||||||
|
// A move that starts a pass again - back from the last waypoint to the first -
|
||||||
|
// is long by nature and the interval is not expected to hold for it; nor for
|
||||||
|
// the first move after a mode starts, from wherever the gimbal was.
|
||||||
|
bool pass_start = false;
|
||||||
|
std::optional<long> yc;
|
||||||
|
if (mode_ == Mode::Auto) {
|
||||||
|
if (grid_.empty()) return false;
|
||||||
|
pass_start = grid_.index() == 0 && grid_.size() > 1;
|
||||||
|
// Yaw travel is under a full turn, so some bearings are unreachable.
|
||||||
|
// Skip such a waypoint rather than clamping to the nearest limit, which
|
||||||
|
// would image a direction nobody asked for and label it with the one
|
||||||
|
// they did. Try each at most once per tick, so a grid with none reachable
|
||||||
|
// just idles.
|
||||||
|
for (size_t tries = 0; tries < grid_.size(); ++tries) {
|
||||||
|
yc = geometry_.yaw.headingToCounts(grid_.current().yaw_deg);
|
||||||
|
if (yc) break;
|
||||||
|
if (!unreachable_warned_) {
|
||||||
|
unreachable_warned_ = true;
|
||||||
|
LOG_WARN << "scan waypoint " << (grid_.index() + 1) << "/" << grid_.size()
|
||||||
|
<< " heading " << grid_.current().yaw_deg
|
||||||
|
<< " deg is outside the reachable yaw arc; skipping it (further "
|
||||||
|
"unreachable waypoints are skipped silently)";
|
||||||
|
}
|
||||||
|
grid_.advance();
|
||||||
|
}
|
||||||
|
if (!yc) return false;
|
||||||
|
const ScanPoint& p = grid_.current();
|
||||||
|
yaw_target_ = *yc;
|
||||||
|
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
|
||||||
|
// One INFO line per move so the operator can follow the scan in the
|
||||||
|
// log without enabling the control wire-trace.
|
||||||
|
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
|
||||||
|
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
|
||||||
|
} else { // pan: the next position, left to right, at the pan's pitch
|
||||||
|
if (pan_headings_.empty()) return false;
|
||||||
|
pass_start = pan_index_ == 0 && pan_headings_.size() > 1;
|
||||||
|
// Skip positions outside the reachable arc, as the sweep does.
|
||||||
|
for (size_t tries = 0; tries < pan_headings_.size(); ++tries) {
|
||||||
|
yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]);
|
||||||
|
if (yc) break;
|
||||||
|
if (!pan_unreachable_warned_) {
|
||||||
|
pan_unreachable_warned_ = true;
|
||||||
|
LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size()
|
||||||
|
<< " heading " << pan_headings_[pan_index_]
|
||||||
|
<< " deg is outside the reachable yaw arc; skipping it (further "
|
||||||
|
"unreachable positions are skipped silently)";
|
||||||
|
}
|
||||||
|
pan_index_ = (pan_index_ + 1) % pan_headings_.size();
|
||||||
|
}
|
||||||
|
if (!yc) return false;
|
||||||
|
yaw_target_ = *yc;
|
||||||
|
pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
|
||||||
|
LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
|
||||||
|
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
|
||||||
|
<< " (deg)";
|
||||||
|
}
|
||||||
|
quiet_overrun_ = first_move_ || pass_start;
|
||||||
|
first_move_ = false;
|
||||||
|
sendMove();
|
||||||
|
moving_ = true;
|
||||||
|
hold_ = {};
|
||||||
|
move_sent_ms_ = now_ms_();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The waypoint's frame is kept or given up: on to the next, and the interval
|
||||||
|
// counts from now.
|
||||||
|
void CaptureScheduler::finishWaypoint() {
|
||||||
|
moving_ = false;
|
||||||
|
hold_ = {};
|
||||||
|
if (mode_ == Mode::Auto) grid_.advance();
|
||||||
|
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
|
||||||
resetTimer();
|
resetTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,108 +325,60 @@ void CaptureScheduler::tick() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Capture cycle: the next scan waypoint (auto) or pan position (pan).
|
// 3. Capture cycle: the next scan waypoint (auto) or pan position (pan).
|
||||||
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
|
// Move straight away; what is left of the interval is waited out at the end.
|
||||||
|
if (interval_ms_ <= 0.0) return;
|
||||||
if (!moving_) {
|
if (!moving_) {
|
||||||
// Issue the next move once the interval has elapsed.
|
startNextMove();
|
||||||
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
|
|
||||||
|
|
||||||
if (mode_ == Mode::Auto) {
|
|
||||||
if (grid_.empty()) return;
|
|
||||||
const ScanPoint& p = grid_.current();
|
|
||||||
// Yaw travel is under a full turn, so some bearings are unreachable.
|
|
||||||
// Skip such a waypoint rather than clamping to the nearest limit,
|
|
||||||
// which would image a direction nobody asked for and label it with
|
|
||||||
// the one they did.
|
|
||||||
auto yc = geometry_.yaw.headingToCounts(p.yaw_deg);
|
|
||||||
if (!yc) {
|
|
||||||
if (!unreachable_warned_) {
|
|
||||||
unreachable_warned_ = true;
|
|
||||||
LOG_WARN << "scan waypoint " << (grid_.index() + 1) << "/" << grid_.size()
|
|
||||||
<< " heading " << p.yaw_deg
|
|
||||||
<< " deg is outside the reachable yaw arc; skipping it (further "
|
|
||||||
"unreachable waypoints are skipped silently)";
|
|
||||||
}
|
|
||||||
grid_.advance();
|
|
||||||
resetTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
yaw_target_ = *yc;
|
|
||||||
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
|
|
||||||
// One INFO line per move so the operator can follow the scan in the
|
|
||||||
// log without enabling the control wire-trace.
|
|
||||||
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
|
|
||||||
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
|
|
||||||
} else { // pan: the next position, left to right, at the pan's pitch
|
|
||||||
if (pan_headings_.empty()) return;
|
|
||||||
// Skip positions outside the reachable arc, as the sweep does; try
|
|
||||||
// each at most once per tick so a pan with none reachable just idles.
|
|
||||||
std::optional<long> yc;
|
|
||||||
for (size_t tries = 0; tries < pan_headings_.size() && !yc; ++tries) {
|
|
||||||
yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]);
|
|
||||||
if (yc) break;
|
|
||||||
if (!pan_unreachable_warned_) {
|
|
||||||
pan_unreachable_warned_ = true;
|
|
||||||
LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size()
|
|
||||||
<< " heading " << pan_headings_[pan_index_]
|
|
||||||
<< " deg is outside the reachable yaw arc; skipping it (further "
|
|
||||||
"unreachable positions are skipped silently)";
|
|
||||||
}
|
|
||||||
pan_index_ = (pan_index_ + 1) % pan_headings_.size();
|
|
||||||
}
|
|
||||||
if (!yc) return;
|
|
||||||
yaw_target_ = *yc;
|
|
||||||
pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
|
|
||||||
LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
|
|
||||||
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
|
|
||||||
<< " (deg)";
|
|
||||||
}
|
|
||||||
sendMove();
|
|
||||||
moving_ = true;
|
|
||||||
settled_at_ms_ = 0;
|
|
||||||
resetTimer();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moving: wait for standstill at the target, then hold still a moment before
|
// Moving: wait for standstill at the target.
|
||||||
// keeping a frame.
|
if (hold_.settled_ms == 0) {
|
||||||
if (settled_at_ms_ == 0) {
|
|
||||||
if (!settledAt(t)) return;
|
if (!settledAt(t)) return;
|
||||||
settled_at_ms_ = now_ms_();
|
beginHold(hold_);
|
||||||
LOG_TRACE_CAT(LogCat::Control)
|
LOG_TRACE_CAT(LogCat::Control)
|
||||||
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; waiting "
|
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc
|
||||||
<< settle_wait_ms_ << " ms before capture";
|
<< "; waiting for the exposure ("
|
||||||
|
<< (settle_wait_ms_ == 0 ? std::string("automatic")
|
||||||
|
: std::to_string(settle_wait_ms_) + " ms")
|
||||||
|
<< ")";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The camera free-runs, so this pause is what makes the kept frame one that was
|
// The camera free-runs, so this wait is what makes the kept frame one that
|
||||||
// exposed with the gimbal at rest (no slew blur) and with the on-camera auto
|
// was exposed with the gimbal at rest (no slew blur) and with the on-camera
|
||||||
// exposure settled on this waypoint's light.
|
// auto exposure settled on this waypoint's light.
|
||||||
const long long since_settled = now_ms_() - settled_at_ms_;
|
if (hold_.ready_ms == 0) {
|
||||||
if (since_settled < settle_wait_ms_) return;
|
if (!dwellOver(hold_, waypointLabel())) return;
|
||||||
|
const long long used = hold_.ready_ms - timer_start_;
|
||||||
if (camera_.trigger()) {
|
if (static_cast<double>(used) > interval_ms_ && !quiet_overrun_) {
|
||||||
if (mode_ == Mode::Auto)
|
++overruns_;
|
||||||
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
|
LOG_WARN << waypointLabel() << ": " << seconds(static_cast<double>(used))
|
||||||
else
|
<< " since the last picture, over the " << seconds(interval_ms_)
|
||||||
LOG_INFO << "captured pan position " << (pan_index_ + 1) << "/"
|
<< " interval (move "
|
||||||
<< pan_headings_.size();
|
<< seconds(static_cast<double>(hold_.settled_ms - move_sent_ms_))
|
||||||
} else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) {
|
<< ", exposure "
|
||||||
return; // no fresh frame yet; retry next tick
|
<< seconds(static_cast<double>(hold_.ready_ms - hold_.settled_ms)) << ")";
|
||||||
} else {
|
}
|
||||||
// Never park on one waypoint: a stalled camera would otherwise stop the
|
|
||||||
// whole sweep rather than costing it a single image.
|
|
||||||
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping "
|
|
||||||
<< (mode_ == Mode::Auto ? "waypoint " : "pan position ")
|
|
||||||
<< (mode_ == Mode::Auto ? grid_.index() + 1 : pan_index_ + 1) << "/"
|
|
||||||
<< (mode_ == Mode::Auto ? grid_.size() : pan_headings_.size());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
moving_ = false;
|
// Then whatever is left of the interval.
|
||||||
settled_at_ms_ = 0;
|
if (static_cast<double>(elapsedMs()) < interval_ms_) return;
|
||||||
if (mode_ == Mode::Auto) grid_.advance();
|
|
||||||
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
|
switch (fire(hold_)) {
|
||||||
resetTimer();
|
case Shot::Waiting:
|
||||||
|
return;
|
||||||
|
case Shot::Captured:
|
||||||
|
LOG_INFO << "captured " << waypointLabel();
|
||||||
|
break;
|
||||||
|
case Shot::TimedOut:
|
||||||
|
// Never park on one waypoint: a stalled camera would otherwise stop
|
||||||
|
// the whole sweep rather than costing it a single image.
|
||||||
|
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms; skipping "
|
||||||
|
<< waypointLabel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
finishWaypoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace fgc
|
} // namespace fgc
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,20 @@ std::vector<const CameraSpec*> CameraConfig::active() const {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
int CameraConfig::maxSettleWaitMs() const {
|
int combinedSettleWaitMs(const std::vector<int>& waits) {
|
||||||
|
if (waits.empty()) return CameraProfileTiming{}.settle_wait_ms;
|
||||||
int worst = 0;
|
int worst = 0;
|
||||||
for (const auto* c : active()) worst = std::max(worst, c->timingFor(c->profile).settle_wait_ms);
|
for (int w : waits) {
|
||||||
return worst > 0 ? worst : CameraProfileTiming{}.settle_wait_ms;
|
if (w == 0) return 0;
|
||||||
|
worst = std::max(worst, w);
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
int CameraConfig::maxSettleWaitMs() const {
|
||||||
|
std::vector<int> waits;
|
||||||
|
for (const auto* c : active()) waits.push_back(c->timingFor(c->profile).settle_wait_ms);
|
||||||
|
return combinedSettleWaitMs(waits);
|
||||||
}
|
}
|
||||||
|
|
||||||
int CameraConfig::maxFrameTimeoutMs() const {
|
int CameraConfig::maxFrameTimeoutMs() const {
|
||||||
|
|
@ -235,7 +245,7 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
AppConfig cfg;
|
AppConfig cfg;
|
||||||
|
|
||||||
cfg.general.tower_name = get(kv, "General.tower_name", cfg.general.tower_name);
|
cfg.general.tower_name = get(kv, "General.tower_name", cfg.general.tower_name);
|
||||||
cfg.general.image_interval = getInt(kv, "General.image_interval", cfg.general.image_interval);
|
cfg.general.image_interval = getDouble(kv, "General.image_interval", cfg.general.image_interval);
|
||||||
cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug);
|
cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug);
|
||||||
|
|
||||||
// [Board] / [Hardware]: normally from hardware.ini, but read from the same
|
// [Board] / [Hardware]: normally from hardware.ini, but read from the same
|
||||||
|
|
@ -537,9 +547,9 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
|
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
|
||||||
|
|
||||||
// ---- validation ----
|
// ---- validation ----
|
||||||
if (cfg.general.image_interval <= 0)
|
if (!(cfg.general.image_interval > 0) || !std::isfinite(cfg.general.image_interval))
|
||||||
throw std::runtime_error("General.image_interval must be > 0 (got " +
|
throw std::runtime_error("General.image_interval must be > 0 (got " +
|
||||||
std::to_string(cfg.general.image_interval) + ")");
|
get(kv, "General.image_interval") + ")");
|
||||||
if (cfg.serial.baud == 0)
|
if (cfg.serial.baud == 0)
|
||||||
throw std::runtime_error("Serial.baud must be > 0");
|
throw std::runtime_error("Serial.baud must be > 0");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,13 @@ const std::vector<HelpSection>& helpCatalog() {
|
||||||
}},
|
}},
|
||||||
{"Capture", "Images and cameras. Manual mode, homed gimbal.", {
|
{"Capture", "Images and cameras. Manual mode, homed gimbal.", {
|
||||||
{"snapshot", "Take one image now (key Space).", {
|
{"snapshot", "Take one image now (key Space).", {
|
||||||
"Waits for standstill and the profile's settle time, as a scan waypoint",
|
"Waits for standstill and the exposure to settle, as a scan waypoint",
|
||||||
"does, so the file and CamEvent are the same as a scan frame's."}},
|
"does, so the file and CamEvent are the same as a scan frame's."}},
|
||||||
{"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", {
|
{"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", {
|
||||||
"One image per scan cycle: [General] image_interval, then standstill and",
|
"One image per [General] image_interval since the last, each once the",
|
||||||
"the profile's settle time. The gimbal can still be nudged; images are",
|
"gimbal stands still and the exposure has settled. The gimbal can still",
|
||||||
"kept only while it stands still. Bare 'capture' reports on/off. Ends on",
|
"be nudged; images are kept only while it stands still. Bare 'capture'",
|
||||||
"any mode change, homing, heading setup or test."}},
|
"reports on/off. Ends on any mode change, homing, heading setup or test."}},
|
||||||
{"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", {
|
{"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", {
|
||||||
"<name> is an XML stem in that camera's profile_dir. The camera may be",
|
"<name> is an XML stem in that camera's profile_dir. The camera may be",
|
||||||
"omitted when only one is configured.",
|
"omitted when only one is configured.",
|
||||||
|
|
|
||||||
|
|
@ -479,8 +479,11 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
||||||
for (const auto& p : v.profiles_available) others += (others.empty() ? "" : ", ") + p;
|
for (const auto& p : v.profiles_available) others += (others.empty() ? "" : ", ") + p;
|
||||||
rows.push_back(row(" available", text(others.empty() ? "\xE2\x80\x94" : others) | dim));
|
rows.push_back(row(" available", text(others.empty() ? "\xE2\x80\x94" : others) | dim));
|
||||||
}
|
}
|
||||||
rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) +
|
rows.push_back(row(" settle", text(v.settle_wait_ms == 0
|
||||||
" ms after standstill before keeping a frame")));
|
? std::string("automatic: until the exposure "
|
||||||
|
"settles after standstill")
|
||||||
|
: std::to_string(v.settle_wait_ms) +
|
||||||
|
" ms after standstill for the exposure")));
|
||||||
rows.push_back(row(" encoding", text("JPEG XL distance " + num(v.jxl_distance, 2) +
|
rows.push_back(row(" encoding", text("JPEG XL distance " + num(v.jxl_distance, 2) +
|
||||||
" effort " + std::to_string(v.jxl_effort))));
|
" effort " + std::to_string(v.jxl_effort))));
|
||||||
rows.push_back(row(" rotation", text(v.rotation_deg == 0
|
rows.push_back(row(" rotation", text(v.rotation_deg == 0
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,17 @@ TEST_CASE("environment overrides file credentials") {
|
||||||
TEST_CASE("ConfigLoader validates input") {
|
TEST_CASE("ConfigLoader validates input") {
|
||||||
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}}));
|
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}}));
|
||||||
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "abc"}}));
|
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "abc"}}));
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "-1"}}));
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "nan"}}));
|
||||||
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}}));
|
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("image_interval takes fractions of a second") {
|
||||||
|
AppConfig c = ConfigLoader::fromMap({{"General.image_interval", "2.5"}});
|
||||||
|
CHECK(c.general.image_interval == doctest::Approx(2.5));
|
||||||
|
CHECK(c.image_rate() == doctest::Approx(0.4));
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("brokerUri builds a Paho serverURI from host + port") {
|
TEST_CASE("brokerUri builds a Paho serverURI from host + port") {
|
||||||
// The port used to be whatever Paho defaulted to when handed a bare address.
|
// The port used to be whatever Paho defaulted to when handed a bare address.
|
||||||
// It is now explicit, so these cases pin the whole resolution table.
|
// It is now explicit, so these cases pin the whole resolution table.
|
||||||
|
|
@ -258,6 +266,23 @@ TEST_CASE("A disabled camera is excluded from the active set and its timing") {
|
||||||
CHECK(c.camera.maxSettleWaitMs() == 1000); // the disabled camera does not slow the scan
|
CHECK(c.camera.maxSettleWaitMs() == 1000); // the disabled camera does not slow the scan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("One camera on the automatic settle wait makes the scan's wait automatic") {
|
||||||
|
AppConfig c = ConfigLoader::fromMap({
|
||||||
|
{"Camera.rgb.id", "DEV_1"},
|
||||||
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
||||||
|
{"Camera.rgb.standard.settle_wait_ms", "0"},
|
||||||
|
{"Camera.nir.id", "DEV_2"},
|
||||||
|
{"Camera.nir.profile_dir", "/tmp/p"},
|
||||||
|
{"Camera.nir.standard.settle_wait_ms", "2500"},
|
||||||
|
});
|
||||||
|
// Automatic waits for every camera's exposure, so it cannot be outvoted by a
|
||||||
|
// fixed wait - nor turned into the 1000 ms default.
|
||||||
|
CHECK(c.camera.maxSettleWaitMs() == 0);
|
||||||
|
CHECK(combinedSettleWaitMs({}) == CameraProfileTiming{}.settle_wait_ms);
|
||||||
|
CHECK(combinedSettleWaitMs({500, 2500}) == 2500);
|
||||||
|
CHECK(combinedSettleWaitMs({500, 0}) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Camera blocks reject what cannot work") {
|
TEST_CASE("Camera blocks reject what cannot work") {
|
||||||
auto base = [](std::map<std::string, std::string> extra) {
|
auto base = [](std::map<std::string, std::string> extra) {
|
||||||
std::map<std::string, std::string> kv = {{"Camera.rgb.id", "DEV_1"},
|
std::map<std::string, std::string> kv = {{"Camera.rgb.id", "DEV_1"},
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ struct FakeMotor : IMotorController {
|
||||||
struct FakeCamera : ICameraSource {
|
struct FakeCamera : ICameraSource {
|
||||||
int triggers = 0;
|
int triggers = 0;
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
|
// Automatic settle wait (settle_wait_ms = 0): what the camera reports, and how
|
||||||
|
// often a watch was begun. Settled by default, so 0 means "no wait" unless a
|
||||||
|
// test says otherwise.
|
||||||
|
ExposureState exposure = ExposureState::Settled;
|
||||||
|
int watches = 0;
|
||||||
|
void beginExposureWatch() override { ++watches; }
|
||||||
|
ExposureState exposureState() override { return exposure; }
|
||||||
void open() override {}
|
void open() override {}
|
||||||
void close() override {}
|
void close() override {}
|
||||||
void start() override {}
|
void start() override {}
|
||||||
|
|
@ -63,36 +70,35 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
|
||||||
|
|
||||||
CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
|
CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
|
||||||
[&] { return clock; });
|
[&] { return clock; });
|
||||||
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
|
sch.setSettleWait(0, 0); // automatic, and the fake is settled: no exposure wait
|
||||||
sch.setMode(Mode::Auto);
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
// The move goes out straight away: MOVE to the first grid point (0,0).
|
||||||
// Before the interval elapses: nothing moves.
|
clock = 10;
|
||||||
clock = 500;
|
|
||||||
sch.tick();
|
|
||||||
CHECK(motor.cmds.empty());
|
|
||||||
|
|
||||||
// After the interval: MOVE to the first grid point (0,0) is issued.
|
|
||||||
clock = 1600;
|
|
||||||
sch.tick();
|
sch.tick();
|
||||||
REQUIRE(motor.cmds.size() == 1);
|
REQUIRE(motor.cmds.size() == 1);
|
||||||
CHECK(motor.cmds[0] == "MOVE 0,0");
|
CHECK(motor.cmds[0] == "MOVE 0,0");
|
||||||
CHECK(sch.moving());
|
CHECK(sch.moving());
|
||||||
CHECK(cam.triggers == 0); // not yet settled
|
CHECK(cam.triggers == 0); // not yet settled
|
||||||
|
|
||||||
// Report standstill at the target -> camera fires, cursor advances.
|
// At rest early: the frame waits for the interval, counted from the mode start.
|
||||||
settleAt(motor, 0, 0);
|
settleAt(motor, 0, 0);
|
||||||
clock = 1700;
|
clock = 100;
|
||||||
sch.tick(); // first tick records standstill
|
sch.tick(); // records standstill
|
||||||
clock = 1710;
|
clock = 200;
|
||||||
sch.tick(); // next one keeps the frame
|
sch.tick(); // exposure settled, interval not over
|
||||||
|
CHECK(cam.triggers == 0);
|
||||||
|
CHECK(std::string(sch.phase()) == "waiting");
|
||||||
|
clock = 1000;
|
||||||
|
sch.tick(); // interval over: the frame is kept, the cursor advances
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
CHECK_FALSE(sch.moving());
|
CHECK_FALSE(sch.moving());
|
||||||
|
|
||||||
// Next interval: MOVE to the second grid point (90 deg -> 900 counts).
|
// And the next move goes out on the next tick: (90 deg -> 900 counts).
|
||||||
clock = 2800;
|
clock = 1010;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
CHECK(motor.cmds.back() == "MOVE 900,0");
|
CHECK(motor.cmds.back() == "MOVE 900,0");
|
||||||
|
CHECK(sch.overruns() == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") {
|
TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") {
|
||||||
|
|
@ -251,11 +257,12 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
|
||||||
FakeCamera cam;
|
FakeCamera cam;
|
||||||
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
||||||
|
|
||||||
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
|
||||||
|
[&] { return clock; });
|
||||||
sch.setSettleWait(1000, 2000);
|
sch.setSettleWait(1000, 2000);
|
||||||
sch.setMode(Mode::Auto);
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
clock = 1600;
|
clock = 10;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
REQUIRE(motor.cmds.size() == 1);
|
REQUIRE(motor.cmds.size() == 1);
|
||||||
|
|
||||||
|
|
@ -264,17 +271,154 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
|
||||||
clock = 2000;
|
clock = 2000;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
CHECK(cam.triggers == 0);
|
CHECK(cam.triggers == 0);
|
||||||
|
CHECK(cam.watches == 0); // a fixed wait never asks the camera
|
||||||
|
CHECK(std::string(sch.phase()) == "settling");
|
||||||
clock = 2900; // 900 ms after settling: still inside the wait
|
clock = 2900; // 900 ms after settling: still inside the wait
|
||||||
sch.tick();
|
sch.tick();
|
||||||
CHECK(cam.triggers == 0);
|
CHECK(cam.triggers == 0);
|
||||||
|
|
||||||
// Past the wait, the newest frame is kept and the sweep advances.
|
// Past the wait, but the interval still has time left: wait it out.
|
||||||
clock = 3100;
|
clock = 3100;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
|
CHECK(cam.triggers == 0);
|
||||||
|
CHECK(std::string(sch.phase()) == "waiting");
|
||||||
|
clock = 5000;
|
||||||
|
sch.tick();
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
CHECK_FALSE(sch.moving());
|
CHECK_FALSE(sch.moving());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the interval is counted from the last frame, so pictures come one interval apart") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 0.5 /*2000 ms*/, testGeometry(), grid,
|
||||||
|
[&] { return clock; });
|
||||||
|
sch.setSettleWait(500, 2000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
std::vector<long long> shots;
|
||||||
|
// 10 ms ticks; the gimbal takes 700 ms to reach each target.
|
||||||
|
long long arrive = -1;
|
||||||
|
size_t moves = 0;
|
||||||
|
for (clock = 0; clock <= 7000; clock += 10) {
|
||||||
|
if (motor.cmds.size() != moves) {
|
||||||
|
moves = motor.cmds.size();
|
||||||
|
arrive = clock + 700;
|
||||||
|
motor.tel.yaw.standstill = false;
|
||||||
|
}
|
||||||
|
if (arrive >= 0 && clock >= arrive)
|
||||||
|
settleAt(motor, sch.yawTargetCounts(), sch.pitchTargetCounts());
|
||||||
|
const int before = cam.triggers;
|
||||||
|
sch.tick();
|
||||||
|
if (cam.triggers != before) shots.push_back(clock);
|
||||||
|
}
|
||||||
|
// Move 700 + settle 500 fit inside 2000, so frames land 2000 ms apart.
|
||||||
|
REQUIRE(shots.size() >= 3);
|
||||||
|
CHECK(shots[0] == 2000);
|
||||||
|
CHECK(shots[1] == 4000);
|
||||||
|
CHECK(shots[2] == 6000);
|
||||||
|
CHECK(sch.overruns() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a waypoint whose move and settle outlast the interval warns, and shoots when ready") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
|
||||||
|
[&] { return clock; });
|
||||||
|
sch.setSettleWait(500, 2000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
// First waypoint: the move from wherever the gimbal was is quiet even when long.
|
||||||
|
clock = 0; sch.tick(); // MOVE 0,0
|
||||||
|
clock = 3000; settleAt(motor, 0, 0); sch.tick();
|
||||||
|
clock = 3500; sch.tick(); // settled + 500: kept at once
|
||||||
|
CHECK(cam.triggers == 1);
|
||||||
|
CHECK(sch.overruns() == 0);
|
||||||
|
|
||||||
|
// Second: 900 ms of travel + 500 settle > 1000 ms interval.
|
||||||
|
clock = 3510; sch.tick(); // MOVE 300,0
|
||||||
|
REQUIRE(motor.cmds.back() == "MOVE 300,0");
|
||||||
|
motor.tel.yaw.standstill = false;
|
||||||
|
clock = 4410; settleAt(motor, 300, 0); sch.tick();
|
||||||
|
clock = 4910; sch.tick(); // 1410 ms since the last frame
|
||||||
|
CHECK(cam.triggers == 2); // no extra wait: shot as soon as ready
|
||||||
|
CHECK(sch.overruns() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("no overrun warning on the return from the last waypoint to the first") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
||||||
|
sch.setSettleWait(0, 2000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
// Waypoint 1 (first move: quiet), waypoint 2 within the interval.
|
||||||
|
clock = 0; sch.tick();
|
||||||
|
settleAt(motor, 0, 0);
|
||||||
|
clock = 100; sch.tick();
|
||||||
|
clock = 1000; sch.tick();
|
||||||
|
CHECK(cam.triggers == 1);
|
||||||
|
clock = 1010; sch.tick(); // MOVE 300,0
|
||||||
|
settleAt(motor, 300, 0);
|
||||||
|
clock = 1100; sch.tick();
|
||||||
|
clock = 1200; sch.tick(); // ready well inside the interval
|
||||||
|
clock = 2010; sch.tick();
|
||||||
|
CHECK(cam.triggers == 2);
|
||||||
|
|
||||||
|
// The long way back to waypoint 1: well over the interval, but quiet.
|
||||||
|
clock = 2020; sch.tick();
|
||||||
|
REQUIRE(motor.cmds.back() == "MOVE 0,0");
|
||||||
|
motor.tel.yaw.standstill = false;
|
||||||
|
clock = 6000; settleAt(motor, 0, 0); sch.tick();
|
||||||
|
clock = 6010; sch.tick();
|
||||||
|
CHECK(cam.triggers == 3);
|
||||||
|
CHECK(sch.overruns() == 0);
|
||||||
|
|
||||||
|
// Waypoint 2 again, also too slow: this one warns.
|
||||||
|
clock = 6020; sch.tick();
|
||||||
|
motor.tel.yaw.standstill = false;
|
||||||
|
clock = 8000; settleAt(motor, 300, 0); sch.tick();
|
||||||
|
clock = 8010; sch.tick();
|
||||||
|
CHECK(cam.triggers == 4);
|
||||||
|
CHECK(sch.overruns() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("an unreachable waypoint is skipped without restarting the interval") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
// 150 deg is past the 90 deg limit.
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {150.0, 0.0}, {30.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
||||||
|
sch.setSettleWait(0, 2000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
clock = 0; sch.tick();
|
||||||
|
settleAt(motor, 0, 0);
|
||||||
|
clock = 100; sch.tick();
|
||||||
|
clock = 1000; sch.tick();
|
||||||
|
REQUIRE(cam.triggers == 1);
|
||||||
|
|
||||||
|
// Straight past waypoint 2 to waypoint 3 in one tick.
|
||||||
|
clock = 1010; sch.tick();
|
||||||
|
CHECK(motor.cmds.back() == "MOVE 300,0");
|
||||||
|
CHECK(grid.index() == 2);
|
||||||
|
settleAt(motor, 300, 0);
|
||||||
|
clock = 1100; sch.tick();
|
||||||
|
clock = 2000; sch.tick(); // one interval after the last frame
|
||||||
|
CHECK(cam.triggers == 2);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
|
TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
|
||||||
long long clock = 0;
|
long long clock = 0;
|
||||||
FakeMotor motor;
|
FakeMotor motor;
|
||||||
|
|
@ -286,25 +430,111 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
|
||||||
sch.setSettleWait(1000, 2000);
|
sch.setSettleWait(1000, 2000);
|
||||||
sch.setMode(Mode::Auto);
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
clock = 1600;
|
clock = 10;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
settleAt(motor, 0, 0);
|
settleAt(motor, 0, 0);
|
||||||
clock = 1700;
|
clock = 100;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
|
|
||||||
// Retries through the timeout window without giving up.
|
// Due at 1100 (settled + 1000 > interval); retries through the timeout window.
|
||||||
clock = 3500; // 1800 ms after settling: past the wait, inside the timeout
|
clock = 1100;
|
||||||
|
sch.tick();
|
||||||
|
clock = 2900; // 1800 ms after falling due: inside the timeout
|
||||||
sch.tick();
|
sch.tick();
|
||||||
CHECK(sch.moving());
|
CHECK(sch.moving());
|
||||||
CHECK(grid.index() == 0);
|
CHECK(grid.index() == 0);
|
||||||
|
|
||||||
// Past wait + timeout, the waypoint is skipped rather than blocking the sweep.
|
// Past the timeout, the waypoint is skipped rather than blocking the sweep.
|
||||||
clock = 4800;
|
clock = 3200;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
CHECK_FALSE(sch.moving());
|
CHECK_FALSE(sch.moving());
|
||||||
CHECK(grid.index() == 1);
|
CHECK(grid.index() == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- automatic settle wait (settle_wait_ms = 0) ----------------------------
|
||||||
|
|
||||||
|
TEST_CASE("automatic settle waits until the camera reports the exposure settled") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
cam.exposure = ExposureState::Converging;
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
||||||
|
sch.setSettleWait(0, 3000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
clock = 0; sch.tick();
|
||||||
|
settleAt(motor, 0, 0);
|
||||||
|
clock = 100; sch.tick();
|
||||||
|
CHECK(cam.watches == 1); // the watch begins at standstill
|
||||||
|
for (clock = 200; clock <= 2000; clock += 100) sch.tick();
|
||||||
|
CHECK(cam.triggers == 0); // still converging
|
||||||
|
cam.exposure = ExposureState::Settled;
|
||||||
|
clock = 2100; sch.tick();
|
||||||
|
CHECK(cam.triggers == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("automatic settle proceeds when the exposure is pinned at its limits") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
cam.exposure = ExposureState::AtLimit;
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
||||||
|
sch.setSettleWait(0, 3000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
clock = 0; sch.tick();
|
||||||
|
settleAt(motor, 0, 0);
|
||||||
|
clock = 100; sch.tick();
|
||||||
|
clock = 1000; sch.tick();
|
||||||
|
CHECK(cam.triggers == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("automatic settle keeps the frame anyway after frame_timeout_ms") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
cam.exposure = ExposureState::Converging; // never settles
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
||||||
|
sch.setSettleWait(0, 3000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
clock = 0; sch.tick();
|
||||||
|
settleAt(motor, 0, 0);
|
||||||
|
clock = 100; sch.tick();
|
||||||
|
clock = 3000; sch.tick();
|
||||||
|
CHECK(cam.triggers == 0);
|
||||||
|
clock = 3100; sch.tick(); // 3000 ms after standstill
|
||||||
|
CHECK(cam.triggers == 1);
|
||||||
|
CHECK(grid.index() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("automatic settle falls back to a fixed wait when the camera cannot tell") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
cam.exposure = ExposureState::Unknown;
|
||||||
|
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
||||||
|
|
||||||
|
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
|
||||||
|
[&] { return clock; });
|
||||||
|
sch.setSettleWait(0, 3000);
|
||||||
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
|
clock = 0; sch.tick();
|
||||||
|
settleAt(motor, 0, 0);
|
||||||
|
clock = 100; sch.tick();
|
||||||
|
clock = 1000; sch.tick();
|
||||||
|
CHECK(std::string(sch.phase()) == "settling");
|
||||||
|
clock = 1100; sch.tick(); // standstill + kFallbackSettleMs
|
||||||
|
CHECK(std::string(sch.phase()) == "waiting");
|
||||||
|
}
|
||||||
|
|
||||||
// --- manual snapshot -------------------------------------------------------
|
// --- manual snapshot -------------------------------------------------------
|
||||||
//
|
//
|
||||||
// The snapshot exists so an operator aiming by hand gets exactly what a scan
|
// The snapshot exists so an operator aiming by hand gets exactly what a scan
|
||||||
|
|
@ -453,11 +683,11 @@ TEST_CASE("a snapshot that times out can simply be asked for again") {
|
||||||
|
|
||||||
// --- continuous capture (`capture on`) -------------------------------------
|
// --- continuous capture (`capture on`) -------------------------------------
|
||||||
//
|
//
|
||||||
// The scan's cycle without the move: interval, then standstill + settle, then
|
// The scan's cycle without the move: standstill + settle, then the rest of the
|
||||||
// one frame, and the timer restarts from there - so the cadence is what a scan
|
// interval since the last frame, then one frame - so the cadence is what a scan
|
||||||
// achieves, and the gimbal stays free to be aimed in between.
|
// achieves, and the gimbal stays free to be aimed in between.
|
||||||
|
|
||||||
TEST_CASE("continuous capture keeps one frame per interval + settle, in manual") {
|
TEST_CASE("continuous capture keeps one frame per interval, in manual") {
|
||||||
long long clock = 0;
|
long long clock = 0;
|
||||||
FakeMotor motor;
|
FakeMotor motor;
|
||||||
FakeCamera cam;
|
FakeCamera cam;
|
||||||
|
|
@ -470,22 +700,21 @@ TEST_CASE("continuous capture keeps one frame per interval + settle, in manual")
|
||||||
sch.setContinuous(true);
|
sch.setContinuous(true);
|
||||||
CHECK(sch.continuous());
|
CHECK(sch.continuous());
|
||||||
|
|
||||||
clock = 900; sch.tick(); // inside the interval
|
clock = 100; sch.tick(); // standstill seen, settle starts
|
||||||
|
clock = 700; sch.tick(); // settled, interval not over
|
||||||
CHECK(cam.triggers == 0);
|
CHECK(cam.triggers == 0);
|
||||||
clock = 1100; sch.tick(); // interval over: standstill seen, settle starts
|
clock = 1000; sch.tick(); // interval over: shoot
|
||||||
CHECK(cam.triggers == 0);
|
|
||||||
clock = 1500; sch.tick(); // still settling
|
|
||||||
CHECK(cam.triggers == 0);
|
|
||||||
clock = 1700; sch.tick(); // settled: shoot
|
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
CHECK(motor.cmds.empty()); // never moves the gimbal
|
CHECK(motor.cmds.empty()); // never moves the gimbal
|
||||||
|
|
||||||
// The timer restarted at the capture, so the next frame is a whole cycle on.
|
// The timer restarted at the capture, so the next frame is one interval on.
|
||||||
clock = 2600; sch.tick();
|
clock = 1100; sch.tick(); // standstill seen
|
||||||
|
clock = 1700; sch.tick(); // settled
|
||||||
|
clock = 1900; sch.tick();
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
clock = 2800; sch.tick(); // interval over, standstill seen
|
clock = 2000; sch.tick();
|
||||||
clock = 3400; sch.tick(); // + settle
|
|
||||||
CHECK(cam.triggers == 2);
|
CHECK(cam.triggers == 2);
|
||||||
|
CHECK(sch.overruns() == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("continuous capture waits for the gimbal to stop before keeping a frame") {
|
TEST_CASE("continuous capture waits for the gimbal to stop before keeping a frame") {
|
||||||
|
|
@ -507,6 +736,7 @@ TEST_CASE("continuous capture waits for the gimbal to stop before keeping a fram
|
||||||
CHECK(cam.triggers == 0);
|
CHECK(cam.triggers == 0);
|
||||||
clock = 5700; sch.tick();
|
clock = 5700; sch.tick();
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
|
CHECK(sch.overruns() == 0); // no waypoints, no overrun warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("continuous capture gives up on a cycle without a frame and carries on") {
|
TEST_CASE("continuous capture gives up on a cycle without a frame and carries on") {
|
||||||
|
|
@ -520,18 +750,18 @@ TEST_CASE("continuous capture gives up on a cycle without a frame and carries on
|
||||||
settleAt(motor, 0, 0);
|
settleAt(motor, 0, 0);
|
||||||
sch.setContinuous(true);
|
sch.setContinuous(true);
|
||||||
|
|
||||||
clock = 1100; sch.tick(); // standstill seen
|
clock = 100; sch.tick(); // standstill seen
|
||||||
clock = 1200; sch.tick(); // tries, no frame
|
clock = 1000; sch.tick(); // due: tries, no frame
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
clock = 2200; sch.tick(); // past the budget: give up, timer restarts
|
clock = 2000; sch.tick(); // past the budget: give up, timer restarts
|
||||||
const int after_giving_up = cam.triggers;
|
const int after_giving_up = cam.triggers;
|
||||||
clock = 2500; sch.tick(); // new cycle not due yet
|
clock = 2500; sch.tick(); // standstill seen; next frame not due yet
|
||||||
|
clock = 2600; sch.tick();
|
||||||
CHECK(cam.triggers == after_giving_up);
|
CHECK(cam.triggers == after_giving_up);
|
||||||
CHECK(sch.continuous()); // still on
|
CHECK(sch.continuous()); // still on
|
||||||
|
|
||||||
cam.ok = true;
|
cam.ok = true;
|
||||||
clock = 3300; sch.tick(); // next cycle: standstill seen
|
clock = 3000; sch.tick(); // one interval after giving up
|
||||||
clock = 3400; sch.tick();
|
|
||||||
CHECK(cam.triggers == after_giving_up + 1);
|
CHECK(cam.triggers == after_giving_up + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -575,3 +805,23 @@ TEST_CASE("a snapshot during continuous capture fires once, then capture carries
|
||||||
clock = 1200; sch.tick();
|
clock = 1200; sch.tick();
|
||||||
CHECK(cam.triggers == 2);
|
CHECK(cam.triggers == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a snapshot honours the automatic settle wait") {
|
||||||
|
long long clock = 0;
|
||||||
|
FakeMotor motor;
|
||||||
|
FakeCamera cam;
|
||||||
|
cam.exposure = ExposureState::Converging;
|
||||||
|
ScanGrid grid({{0.0, 0.0}});
|
||||||
|
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
||||||
|
sch.setSettleWait(0, 5000);
|
||||||
|
settleAt(motor, 0, 0);
|
||||||
|
|
||||||
|
sch.requestSnapshot();
|
||||||
|
clock = 10; sch.tick(); // standstill: the watch begins
|
||||||
|
CHECK(cam.watches == 1);
|
||||||
|
clock = 2000; sch.tick();
|
||||||
|
CHECK(cam.triggers == 0);
|
||||||
|
cam.exposure = ExposureState::Settled;
|
||||||
|
clock = 2100; sch.tick();
|
||||||
|
CHECK(cam.triggers == 1);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue