Compare commits
No commits in common. "70a30132bfa59102875ac68b04753b3b545f0fd7" and "d5b037a24117a1cdd976da38f69bd52a878ec85b" have entirely different histories.
70a30132bf
...
d5b037a241
|
|
@ -93,8 +93,6 @@ add_library(fgc_core STATIC
|
||||||
src/core/Hash.cpp
|
src/core/Hash.cpp
|
||||||
src/core/ChannelHistory.cpp
|
src/core/ChannelHistory.cpp
|
||||||
src/core/ChannelTopics.cpp
|
src/core/ChannelTopics.cpp
|
||||||
src/core/RawImage.cpp
|
|
||||||
src/core/SettleRule.cpp
|
|
||||||
src/sensors/I2cBus.cpp
|
src/sensors/I2cBus.cpp
|
||||||
src/sensors/Sht41Protocol.cpp
|
src/sensors/Sht41Protocol.cpp
|
||||||
src/sensors/McuEnvSensor.cpp
|
src/sensors/McuEnvSensor.cpp
|
||||||
|
|
@ -111,18 +109,6 @@ target_link_libraries(fgc_core PUBLIC Threads::Threads)
|
||||||
# PUBLIC so the application and the tests both see the same build identity.
|
# PUBLIC so the application and the tests both see the same build identity.
|
||||||
target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}")
|
target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# fgc_imaging: turning camera frames into saved images (de-Bayer, JPEG XL). Its
|
|
||||||
# own library so the image tests can link OpenCV and libjxl without the rest of
|
|
||||||
# the application; fgc_core stays free of both.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
add_library(fgc_imaging STATIC
|
|
||||||
src/camera/JpegXlEncoder.cpp
|
|
||||||
src/camera/ImagePipeline.cpp
|
|
||||||
)
|
|
||||||
target_include_directories(fgc_imaging PUBLIC ${OpenCV_INCLUDE_DIRS})
|
|
||||||
target_link_libraries(fgc_imaging PUBLIC fgc_core ${OpenCV_LIBS} PkgConfig::JXL)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Application executable. Camera image pipeline + serial controller always
|
# Application executable. Camera image pipeline + serial controller always
|
||||||
# build (OpenCV/libjxl/Boost.Asio are available everywhere); MQTT and Vimba
|
# build (OpenCV/libjxl/Boost.Asio are available everywhere); MQTT and Vimba
|
||||||
|
|
@ -131,6 +117,8 @@ target_link_libraries(fgc_imaging PUBLIC fgc_core ${OpenCV_LIBS} PkgConfig::JXL)
|
||||||
set(FGC_SOURCES
|
set(FGC_SOURCES
|
||||||
main.cpp
|
main.cpp
|
||||||
src/core/Application.cpp
|
src/core/Application.cpp
|
||||||
|
src/camera/JpegXlEncoder.cpp
|
||||||
|
src/camera/ImagePipeline.cpp
|
||||||
src/serial/SerialMotorController.cpp
|
src/serial/SerialMotorController.cpp
|
||||||
src/serial/MtiImuSource.cpp
|
src/serial/MtiImuSource.cpp
|
||||||
src/sensors/Sht41EnvSensor.cpp
|
src/sensors/Sht41EnvSensor.cpp
|
||||||
|
|
@ -147,7 +135,6 @@ target_include_directories(fire_gimbal_control PRIVATE ${CMAKE_SOURCE_DIR})
|
||||||
|
|
||||||
target_link_libraries(fire_gimbal_control PRIVATE
|
target_link_libraries(fire_gimbal_control PRIVATE
|
||||||
fgc_core
|
fgc_core
|
||||||
fgc_imaging
|
|
||||||
${OpenCV_LIBS}
|
${OpenCV_LIBS}
|
||||||
Boost::headers
|
Boost::headers
|
||||||
Boost::program_options
|
Boost::program_options
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,8 @@ flowchart TD
|
||||||
A([next waypoint]) --> B["move yaw and pitch<br/>to the waypoint"]
|
A([next waypoint]) --> B["move yaw and pitch<br/>to the waypoint"]
|
||||||
B --> C{"both axes<br/>stopped?"}
|
B --> C{"both axes<br/>stopped?"}
|
||||||
C -- no --> C
|
C -- no --> C
|
||||||
C -- yes --> D["hold still for settle_wait_ms<br/>(and, with settle_rule = brightness,<br/>until the brightness has settled)"]
|
C -- yes --> D["hold still for settle_wait_ms<br/>(1 s by day, 2.5 s at night)"]
|
||||||
D --> W["wait out what is left of<br/>image_interval since the last picture"]
|
D --> E["keep the newest frame<br/>the camera delivered"]
|
||||||
W --> E["keep the newest frame<br/>the camera delivered"]
|
|
||||||
E --> F["encode to JPEG XL<br/>save to the output folder"]
|
E --> F["encode to JPEG XL<br/>save to the output folder"]
|
||||||
F --> G["publish a CamEvent<br/>(camera, heading, pitch, time)"]
|
F --> G["publish a CamEvent<br/>(camera, heading, pitch, time)"]
|
||||||
G --> A
|
G --> A
|
||||||
|
|
|
||||||
|
|
@ -40,33 +40,28 @@ overwritten whenever the XML changes.
|
||||||
|
|
||||||
## Profiles this rig expects
|
## Profiles this rig expects
|
||||||
|
|
||||||
`config.ini` ships referencing `standard` and `night`. Both are **raw capture**
|
`config.ini` ships referencing `standard` and `night`. They were previously
|
||||||
(docs/raw12-pipeline-measurements.md): full resolution, binning 1, packed 12-bit
|
hand-written; these are the values they used, as a starting point when
|
||||||
Bayer, converted to colour on the tower and saved as 12-bit JPEG XL.
|
recreating them in the Viewer. Everything not listed was identical.
|
||||||
`scripts/export_camera_profile.py` builds them on the tower; these are its recipe.
|
|
||||||
|
|
||||||
| Setting | `standard` | `night` |
|
| Setting | `standard` | `night` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `BinningHorizontal` / `Vertical` | 1 | 1 |
|
| `AcquisitionFrameRate` | 10 | 1 |
|
||||||
| `Width` / `Height` | sensor maximum | sensor maximum |
|
| `SensorBitDepth` | Bpp8 | Bpp12 |
|
||||||
| `PixelFormat` | `BayerRG12p` | `BayerRG12p` |
|
| `ExposureAutoMin` | 200 | 1000 |
|
||||||
| `DeviceLinkThroughputLimit` | 250000000 | 250000000 |
|
| `ExposureAutoMax` | 20000 | 500000 |
|
||||||
| `AcquisitionFrameRate` | camera maximum (9.86 on the 1800 U-1620c) | camera maximum (exposure-limited at night) |
|
|
||||||
| `ChunkModeActive` + `ChunkEnable` | ExposureTime, Gain, BalanceRatioRed/Blue, Timestamp | same |
|
|
||||||
| `BalanceWhiteAuto` | Off (fixed ratios) | Off |
|
|
||||||
| `ExposureAutoMin` / `Max` | 200 / 20000 µs | 1000 / 500000 µs |
|
|
||||||
| `GainAutoMax` | 24 | 48 |
|
| `GainAutoMax` | 24 | 48 |
|
||||||
| `IntensityAutoPrecedence` | MinimizeNoise | MinimizeNoise |
|
| `IntensityAutoPrecedence` | Minimizeexposuretime | Minimizenoise |
|
||||||
| `IntensityControllerRate` | 100 | 100 |
|
| `DeviceIndicatorMode` | Active | Off |
|
||||||
| `DeviceIndicatorMode` / `Luminance` | Active / 10 | Inactive / 0 |
|
| `DeviceIndicatorLuminance` | 10 | 0 |
|
||||||
|
|
||||||
The chunk data is what `settle_rule = brightness` reads (each item has to be
|
Both used 2×2 binning at `RGB8` (2256×2256, 15.3 MB/frame) and a 250 MB/s
|
||||||
enabled before `ChunkModeActive`, which then locks them). A profile left on
|
`DeviceLinkThroughputLimit`. **That binning is not optional at 10 Hz**: a
|
||||||
`RGB8` still works: the program saves it as before.
|
full-resolution RGB8 frame is 61 MB, which caps out near 4 fps under that limit.
|
||||||
|
|
||||||
Earlier profiles used 2×2 binning at RGB8. On the 1620c that gave **no** extra
|
The full previous definitions are in git history at
|
||||||
frame rate (the camera budgets the link against the unbinned frame), which is one
|
the hand-written `config/camera_modes/rgb/{standard,night}.ini`, removed in the
|
||||||
reason they were replaced. Their values are in git history.
|
same change that made the XML the only thing applied.
|
||||||
|
|
||||||
`settle_wait_ms` / `frame_timeout_ms` / `settle_rule` are *not* camera settings and are not in
|
`settle_wait_ms` / `frame_timeout_ms` are *not* camera settings and are not in
|
||||||
the XML — they live in `config.ini` under `[Camera.rgb.<profile>]`.
|
the XML — they live in `config.ini` under `[Camera.rgb.<profile>]`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
; Desired seconds from one picture to the next (decimals allowed, e.g. 2.5).
|
; Seconds between captures.
|
||||||
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
|
||||||
|
|
@ -50,10 +50,6 @@ mqtt_pw = CHANGE_ME
|
||||||
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless; effort 1..9.
|
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless; effort 1..9.
|
||||||
jxl_distance = 0.8
|
jxl_distance = 0.8
|
||||||
jxl_effort = 4
|
jxl_effort = 4
|
||||||
; Raw profiles (PixelFormat BayerRG8 / BayerRG12p in the profile XML): the tower
|
|
||||||
; converts the sensor mosaic to colour itself. edge_aware (sharper; ~30 ms for
|
|
||||||
; 16 MP) or bilinear. A 12-bit raw frame is saved as a 12-bit file.
|
|
||||||
demosaic = edge_aware
|
|
||||||
|
|
||||||
; --- One [Camera.<name>] block per camera -----------------------------------
|
; --- One [Camera.<name>] block per camera -----------------------------------
|
||||||
; Nothing is implied by position: the block names the camera, `label` names its
|
; Nothing is implied by position: the block names the camera, `label` names its
|
||||||
|
|
@ -75,20 +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 settle_wait_ms, then
|
; them. After the gimbal reports standstill it holds still this long before
|
||||||
; keeps a frame according to settle_rule:
|
; keeping a frame, so the image is exposed at rest with the auto-exposure
|
||||||
; fixed the newest frame, straight away
|
; settled; frame_timeout_ms then abandons the waypoint rather than parking on it.
|
||||||
; brightness the first frame once brightness is in the camera's target band
|
|
||||||
; and steady for 3 frames (right 21 of 24 times in the tower tests,
|
|
||||||
; docs/raw12-pipeline-measurements.md); settle_wait_ms is then just
|
|
||||||
; the mechanical minimum
|
|
||||||
; frame_timeout_ms bounds the wait after settle_wait_ms; past it the newest frame
|
|
||||||
; is kept anyway (or the waypoint skipped if the camera delivers nothing).
|
|
||||||
; With several cameras the scan uses the LONGEST wait across them.
|
; With several cameras the scan uses the LONGEST wait across them.
|
||||||
[Camera.rgb.standard]
|
[Camera.rgb.standard]
|
||||||
settle_wait_ms = 500
|
settle_wait_ms = 1000
|
||||||
frame_timeout_ms = 7000
|
frame_timeout_ms = 2000
|
||||||
settle_rule = brightness
|
|
||||||
|
|
||||||
[Camera.rgb.night]
|
[Camera.rgb.night]
|
||||||
settle_wait_ms = 2500
|
settle_wait_ms = 2500
|
||||||
|
|
|
||||||
|
|
@ -58,15 +58,6 @@ cameras = rgb
|
||||||
; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts
|
; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts
|
||||||
; its span into as many pictures as this takes. 6 until the lens is measured.
|
; its span into as many pictures as this takes. 6 until the lens is measured.
|
||||||
pan_step_deg = 6
|
pan_step_deg = 6
|
||||||
; Image saving: how this computer shares its 4 cores between saved frames.
|
|
||||||
; Measured with a 16 MP 12-bit frame (docs/raw12-pipeline-measurements.md): the
|
|
||||||
; IOTA (N150) keeps up with one waypoint every 0.7 s with 1 worker; the 3 Delta
|
|
||||||
; (N5105) needs 2 workers for that. jxl_threads is libjxl's threads per frame;
|
|
||||||
; 0 or absent = 4 / save_workers. save_queue_max is how many frames may wait
|
|
||||||
; or be saving before the scan holds at its waypoint (each raw frame ~24 MB).
|
|
||||||
save_workers = 1
|
|
||||||
;jxl_threads = 0
|
|
||||||
save_queue_max = 4
|
|
||||||
;
|
;
|
||||||
; What happens when this does not match the rig:
|
; What happens when this does not match the rig:
|
||||||
; declared but absent -> warned about by name, and with [Features]
|
; declared but absent -> warned about by name, and with [Features]
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,8 @@ corrupted.
|
||||||
pan's pitch, converted to counts via `Geometry`.
|
pan's pitch, converted to counts via `Geometry`.
|
||||||
- manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the
|
- manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the
|
||||||
move and wait only for standstill.
|
move and wait only for standstill.
|
||||||
- Once **both axes report standstill at the target**, wait `settle_wait_ms` (and, with
|
- Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest
|
||||||
`settle_rule = brightness`, until the brightness has settled), then whatever is left of
|
frame and advance the grid or pan position.
|
||||||
`image_interval` since the last frame kept; keep the newest frame and move straight on to the next
|
|
||||||
grid or pan position - unless saving is behind, when the scan holds until it catches up.
|
|
||||||
5. **Acquisition** — the camera free-runs at the active profile's `AcquisitionFrameRate` with its own
|
5. **Acquisition** — the camera free-runs at the active profile's `AcquisitionFrameRate` with its own
|
||||||
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
|
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
|
||||||
capture* below.
|
capture* below.
|
||||||
|
|
@ -96,29 +94,20 @@ corrupted.
|
||||||
## Capture state machine
|
## Capture state machine
|
||||||
|
|
||||||
```
|
```
|
||||||
mode auto or pan AND not already moving (straight after the last frame)
|
interval elapsed AND mode auto or pan AND not already moving
|
||||||
AND room to save (else hold here until saving catches up)
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch); pan → next pan heading
|
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch)
|
||||||
│ MOVE <yaw>,<pitch> │ unreachable targets skipped in the same tick
|
│ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's pitch
|
||||||
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving
|
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving, reset timer
|
||||||
└──────────┬───────────┘
|
└──────────┬───────────┘
|
||||||
│ (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 settle_wait_ms │ what makes the kept frame one exposed at rest
|
||||||
│ then the keep rule │ settle_rule = brightness: until brightness is in band
|
|
||||||
│ if on │ and steady, at most frame_timeout_ms (then keep, 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, restart the interval
|
┌──────────────────────┐ on success: clear moving, advance the grid / pan position
|
||||||
│ 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
|
||||||
```
|
```
|
||||||
|
|
@ -143,27 +132,15 @@ At each waypoint the scheduler therefore:
|
||||||
|
|
||||||
1. moves, and waits for both axes to report standstill at the target;
|
1. moves, and waits for both axes to report standstill at the target;
|
||||||
2. waits a further `settle_wait_ms` (1 s in `standard`, 2.5 s in `night`);
|
2. waits a further `settle_wait_ms` (1 s in `standard`, 2.5 s in `night`);
|
||||||
3. with `settle_rule = brightness`, waits on until brightness is in the camera's
|
3. keeps the newest complete frame.
|
||||||
target band and steady for 3 frames
|
|
||||||
([configuration.md](configuration.md#which-frame-a-waypoint-keeps-settle_rule));
|
|
||||||
4. waits out what is left of `image_interval`, counted from the last frame kept, and
|
|
||||||
keeps the newest complete frame.
|
|
||||||
|
|
||||||
Saving runs in background workers and never blocks this loop; if more than
|
|
||||||
`save_queue_max` frames are waiting, the scan holds at the waypoint until there is
|
|
||||||
room. Raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower
|
|
||||||
and saved at their own bit depth ([raw12-pipeline-measurements.md](raw12-pipeline-measurements.md)).
|
|
||||||
|
|
||||||
The wait is the whole mechanism. It guarantees the kept frame was exposed with the gimbal **at rest** (no
|
The wait is the whole mechanism. It guarantees the kept frame was exposed with the gimbal **at rest** (no
|
||||||
slew blur) and after the auto-exposure has seen this waypoint's light for several frames. It also sets the
|
slew blur) and after the auto-exposure has seen this waypoint's light for several frames. It also sets the
|
||||||
floor on a profile's frame rate: a profile must produce comfortably more than one frame inside its own settle wait,
|
floor on a profile's frame rate: a profile must produce comfortably more than one frame inside its own settle wait,
|
||||||
which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s.
|
which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s.
|
||||||
|
|
||||||
If no fresh frame arrives within `frame_timeout_ms` of the frame falling due, the waypoint is logged and
|
If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A
|
||||||
skipped. A stalled camera costs single images, never the whole sweep. A merely slow camera is not stalled:
|
stalled camera costs single images, never the whole sweep.
|
||||||
with multi-second exposures the next frame may not be due yet, so the stream is only restarted once nothing
|
|
||||||
newer has come for twice the longer of the exposure and the frame period, plus a second (at least 2 s;
|
|
||||||
`CameraStall.h`). Restarting earlier throws the exposure in progress away.
|
|
||||||
|
|
||||||
### Camera profiles
|
### Camera profiles
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` | 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` | `image_interval` | int > 0 | `5` | Seconds between captures (→ `image_rate = 1/interval`) |
|
||||||
| `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 |
|
||||||
|
|
@ -44,14 +44,9 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| `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` | Hold after standstill before keeping a frame |
|
||||||
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | With `settle_rule = brightness`, the longest wait for the brightness after `settle_wait_ms`; then keep the newest frame anyway. Also how long to wait for a frame once one is due before skipping the waypoint |
|
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Abandon the waypoint after this |
|
||||||
| `Camera.<name>.<profile>` | `settle_rule` | `fixed` \| `brightness` | `fixed` | `fixed`: keep the newest frame once `settle_wait_ms` has passed. `brightness`: keep the first frame whose brightness is in the camera's target band and steady for 3 frames ([keep rule](#which-frame-a-waypoint-keeps-settle_rule)) |
|
|
||||||
| `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. Distance 0 is truly lossless |
|
| `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings |
|
||||||
| `Camera` / `Camera.<name>` | `demosaic` | `edge_aware` \| `bilinear` | `edge_aware` | How raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower |
|
|
||||||
| `Hardware` (hardware.ini) | `save_workers` | int 1..4 | `1` | Frames saved at once. 1 on the IOTA, 2 on the 3 Delta ([measurements](raw12-pipeline-measurements.md)) |
|
|
||||||
| `Hardware` (hardware.ini) | `jxl_threads` | int 0..8 | `0` = 4 / `save_workers` | libjxl threads per saved frame |
|
|
||||||
| `Hardware` (hardware.ini) | `save_queue_max` | int 1..32 | `4` | Frames waiting or being saved before the scan holds at its waypoint (a raw 16 MP frame is ~24 MB) |
|
|
||||||
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
|
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
|
||||||
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
|
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
|
||||||
| `Features` | `enable_camera` | bool | `true` | (reserved) |
|
| `Features` | `enable_camera` | bool | `true` | (reserved) |
|
||||||
|
|
@ -437,43 +432,6 @@ 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
|
||||||
it.
|
it.
|
||||||
|
|
||||||
#### Raw profiles
|
|
||||||
|
|
||||||
A profile whose XML sets `PixelFormat = BayerRG12p` (or `BayerRG8`) sends the
|
|
||||||
camera's raw sensor mosaic. The tower converts it to colour (`[Camera] demosaic`)
|
|
||||||
and saves it at the frame's own bit depth: a 12-bit raw frame becomes a 12-bit
|
|
||||||
JPEG XL file. `RGB8` profiles are saved exactly as before. At the 250 MB/s link
|
|
||||||
cap the 1800 U-1620c delivers 9.86 fps in `BayerRG12p` against 4.95 in RGB8, so
|
|
||||||
auto-exposure settles in about half the time; the tower-side cost is the same as
|
|
||||||
saving RGB8 ([measurements](raw12-pipeline-measurements.md)).
|
|
||||||
`scripts/export_camera_profile.py` builds such profiles; it also turns on the
|
|
||||||
per-frame data (chunk mode) the keep rule reads.
|
|
||||||
|
|
||||||
#### Which frame a waypoint keeps: `settle_rule`
|
|
||||||
|
|
||||||
With `settle_rule = brightness`, after `settle_wait_ms` (now the mechanical
|
|
||||||
minimum) the scan watches every frame the camera delivers and keeps the first one
|
|
||||||
once the last three are all inside the camera's own auto-exposure target band
|
|
||||||
(`IntensityControllerTarget ± IntensityControllerTolerance`, read from the camera)
|
|
||||||
and their brightness changed by at most 2% from frame to frame. A steady frame
|
|
||||||
outside the band also counts when exposure and gain sit at the limits that would
|
|
||||||
move it (waiting cannot help). Past `frame_timeout_ms` the newest frame is kept
|
|
||||||
anyway, with a warning. On the 24 recorded exposure steps this picked a good frame
|
|
||||||
21 times; "exposure and gain unchanged" never did, because auto-exposure pauses
|
|
||||||
mid-way. A camera that reports no frame data falls back to the fixed wait. The
|
|
||||||
rule applies to scan and pan waypoints; a snapshot or continuous capture keeps
|
|
||||||
the fixed wait. Its time counts towards `image_interval`, like the move's.
|
|
||||||
|
|
||||||
#### When saving falls behind
|
|
||||||
|
|
||||||
Saving runs in background workers (`[Hardware] save_workers`, `jxl_threads`). If
|
|
||||||
more than `save_queue_max` frames are waiting or being saved, the scan **holds at
|
|
||||||
its waypoint** until there is room (logged once), so no picture is lost and memory
|
|
||||||
stays bounded. That waypoint does not also warn about running past
|
|
||||||
`image_interval`. Continuous capture holds the same way; a requested snapshot is
|
|
||||||
never held. Measured: the IOTA keeps up
|
|
||||||
with a frame every 0.7 s on one worker; the 3 Delta needs two.
|
|
||||||
|
|
||||||
### `[Env]` — SHT41 ambient temperature/humidity sensor
|
### `[Env]` — SHT41 ambient temperature/humidity sensor
|
||||||
|
|
||||||
Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own
|
Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own
|
||||||
|
|
@ -696,7 +654,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` since the last, each once the gimbal has stood still for `settle_wait_ms`. 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` + standstill + `settle_wait_ms`, kept only at rest. 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) |
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ 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. **2026-09-30:** tower 121's camera is indoors, facing a window, still badly out of focus, gain at its 24 dB maximum ([camera-capture-measurements.md](camera-capture-measurements.md)). **Slow, not stuck, 2026-10-04:** at dusk tower 111 ran multi-second exposures, one frame every 7-9 s, and the program restarted the stream whenever a picture fell due before the next frame had come - throwing that exposure away, so the next frame came later still. A stream that has delivered is now only restarted once nothing newer has come for twice the longer of the exposure and the frame period, plus a second (`CameraStall.h`); not yet checked on the tower. |
|
| 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. **2026-09-30:** tower 121's camera is indoors, facing a window, still badly out of focus, gain at its 24 dB maximum ([camera-capture-measurements.md](camera-capture-measurements.md)). |
|
||||||
| 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. **Measured 2026-09-30 (tower 121, still camera, forced ×16 brightness steps):** auto-exposure took 1.4–1.8 s to come within ±10% at 4.95 fps, so 1 s is too short for a large step; from a dark step it first overshoots to its maximum exposure, 3.4–4.1× too bright. Exposure and gain holding still for a few frames does not mean it has finished ([camera-capture-measurements.md](camera-capture-measurements.md)). **2026-10-05:** `settle_rule = brightness` keeps the frame once brightness is in the camera's band and steady (21 of 24 recorded steps right), and raw profiles double the frame rate it works with; to be confirmed with real slews ([raw12-pipeline-measurements.md](raw12-pipeline-measurements.md)). |
|
| 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. **Measured 2026-09-30 (tower 121, still camera, forced ×16 brightness steps):** auto-exposure took 1.4–1.8 s to come within ±10% at 4.95 fps, so 1 s is too short for a large step; from a dark step it first overshoots to its maximum exposure, 3.4–4.1× too bright. Exposure and gain holding still for a few frames does not mean it has finished ([camera-capture-measurements.md](camera-capture-measurements.md)). |
|
||||||
| 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. **2026-09-30:** with the kit applied, tower 121 streamed full resolution in every format at 250 and 450 MB/s for 60 s with nothing lost; 2 incomplete frames in 1,574 at 450 MB/s while encoding. **2026-10-01:** tower 111 (Delta kit applied) likewise, at 200, 250 and 450 MB/s, also while encoding, with nothing lost ([camera-capture-measurements.md](camera-capture-measurements.md)). |
|
| 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. **2026-09-30:** with the kit applied, tower 121 streamed full resolution in every format at 250 and 450 MB/s for 60 s with nothing lost; 2 incomplete frames in 1,574 at 450 MB/s while encoding. **2026-10-01:** tower 111 (Delta kit applied) likewise, at 200, 250 and 450 MB/s, also while encoding, with nothing lost ([camera-capture-measurements.md](camera-capture-measurements.md)). |
|
||||||
| 20 | **`jxl_distance = 0` is not lossless** | `JpegXlEncoder::encodeToFile` always sets `uses_original_profile = false`; for distance 0 libjxl prints `Set uses_original_profile=true for lossless encoding`, still reports success, and encodes through its lossy colour space. Measured 2026-09-30: decoded pixels differ by up to 1 (raw mosaic) or 2 (RGB8) levels in 1.5% / 7.3% of values ([camera-capture-measurements.md](camera-capture-measurements.md)). **Fixed 2026-10-05:** the encoder sets `uses_original_profile` for distance 0, and `tests/test_jpegxl.cpp` checks 8-bit and 12-bit lossless decode exactly; 8-bit lossy output is byte-identical to before (72 of 72 compared). |
|
| 20 | **`jxl_distance = 0` is not lossless** | `JpegXlEncoder::encodeToFile` always sets `uses_original_profile = false`; for distance 0 libjxl prints `Set uses_original_profile=true for lossless encoding`, still reports success, and encodes through its lossy colour space. Measured 2026-09-30: decoded pixels differ by up to 1 (raw mosaic) or 2 (RGB8) levels in 1.5% / 7.3% of values ([camera-capture-measurements.md](camera-capture-measurements.md)). Fix: set `uses_original_profile = true` when distance is 0. |
|
||||||
|
|
||||||
## Camera acquisition (USB3) — root cause + approach
|
## Camera acquisition (USB3) — root cause + approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
|
||||||
| [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site |
|
| [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site |
|
||||||
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber |
|
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber |
|
||||||
| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash |
|
| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash |
|
||||||
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces: move, standstill, settle wait (+ `SettleRule`), the rest of the interval since the last frame, keep; holds while saving is behind; injectable clock; `setGeometry` adopts a recalibration |
|
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration |
|
||||||
| [include/fgc/SettleRule.h](../include/fgc/SettleRule.h), [src/core/SettleRule.cpp](../src/core/SettleRule.cpp) | The `settle_rule = brightness` keep rule, pure: fed one frame's brightness, exposure and gain at a time, says when the image has settled (in the camera's target band and steady for 3 frames) or is pinned at the auto-exposure limits |
|
|
||||||
| [include/fgc/RawImage.h](../include/fgc/RawImage.h), [src/core/RawImage.cpp](../src/core/RawImage.cpp) | Pixel layouts the program accepts (`Mono8`, `RGB8`, `BayerRG8`, `BayerRG12p`), their sizes and bit depths, `unpack12p`, and `sampleBrightness` - the cheap per-frame sample the keep rule reads |
|
|
||||||
| [include/fgc/CameraStall.h](../include/fgc/CameraStall.h), [src/core/CameraStall.cpp](../src/core/CameraStall.cpp) | When a camera stream delivers nothing new: wait, restart the stream, or reset the camera. Pure policy, scaled by the exposure and frame period so a slow frame is not taken for a stall |
|
|
||||||
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
|
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
|
||||||
| [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) |
|
| [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) |
|
||||||
| [include/fgc/ui/HeadlessUi.h](../include/fgc/ui/HeadlessUi.h), [src/ui/HeadlessUi.cpp](../src/ui/HeadlessUi.cpp) | Default line console: stdin → command sink; logs via the default stdout/stderr writer |
|
| [include/fgc/ui/HeadlessUi.h](../include/fgc/ui/HeadlessUi.h), [src/ui/HeadlessUi.cpp](../src/ui/HeadlessUi.cpp) | Default line console: stdin → command sink; logs via the default stdout/stderr writer |
|
||||||
|
|
@ -65,8 +62,8 @@ Per-file reference for the refactored tree, plus the shared data structures.
|
||||||
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
|
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
|
||||||
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
|
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
|
||||||
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
|
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
|
||||||
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file, 8-bit or up to 16-bit values (a 12-bit frame keeps its depth); distance 0 is lossless | always |
|
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always |
|
||||||
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → de-Bayer (raw profiles) → rotate → encode → write → CamEvent (`save_workers` background workers; `canAccept()` is the scan's save gate). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
|
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
|
||||||
|
|
||||||
## Mock / null implementations
|
## Mock / null implementations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
(one per `image_interval`, counted from the last picture), and each publishes a CamEvent exactly as a sweep picture
|
(`image_interval`), 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
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,7 @@
|
||||||
# Implementation plan — raw capture, colour on the tower, 12-bit JPEG XL
|
# Implementation plan — raw capture, colour on the tower, 12-bit JPEG XL
|
||||||
|
|
||||||
Based on [raw12-pipeline-measurements.md](raw12-pipeline-measurements.md) (towers 121 and 111,
|
Based on [raw12-pipeline-measurements.md](raw12-pipeline-measurements.md) (towers 121 and 111,
|
||||||
2026-10-02/04).
|
2026-10-02/04). Not started; decisions marked **[decide]** are the user's.
|
||||||
|
|
||||||
## Status (branch `raw12-capture`, 2026-10-05)
|
|
||||||
|
|
||||||
Decisions taken: `BayerRG12p` for both profiles; colour JPEG XL only (no raw-planes save); white
|
|
||||||
balance stays fixed; a full save queue **holds the scan** (no lost waypoints).
|
|
||||||
|
|
||||||
| Phase | State |
|
|
||||||
|---|---|
|
|
||||||
| 1 Encoder | **done**: 12-bit input, true lossless (#20), threads from config; 8-bit lossy byte-identical to before (72 of 72) |
|
|
||||||
| 2 Frames | **done**: pixel layout from the camera, chunk data (exposure, gain, white balance), brightness sample per frame. Not done: keeping frames in the SDK buffer instead of copying (optional) |
|
|
||||||
| 3 Raw core | **done**: `RawImage` (layouts, `unpack12p`, brightness sample) |
|
|
||||||
| 4 Pipeline | **done**: de-Bayer (edge-aware/bilinear), save at the frame's bit depth, N workers, `canAccept()`; scan holds when full |
|
|
||||||
| 5 Keep rule | **done** for scan waypoints (`SettleRule`, `settle_rule = brightness`); snapshots and continuous capture keep the fixed wait |
|
|
||||||
| 6 Config | **done**: `[Camera] demosaic`; `[Hardware] save_workers`, `jxl_threads`, `save_queue_max` (kit writes 1 / 2 per board); per-profile `settle_rule` |
|
|
||||||
| 7 Profiles | **recipe done** (`scripts/export_camera_profile.py`, dry-run checked on the 1620c); profiles still to be exported on each tower |
|
|
||||||
| 8 Docs | **done** (configuration, architecture, known issues, profile README). Downstream 12-bit decode still to check |
|
|
||||||
|
|
||||||
Tests: 457, of which 36 new, covering the encoder, the pipeline, raw handling, the keep rule (including
|
|
||||||
a replay of the 24 recorded steps), the scheduler and config. Builds with the
|
|
||||||
camera SDK on tower 121.
|
|
||||||
|
|
||||||
Still to do before switching a tower: export the new profiles on the tower; check the ground
|
|
||||||
station and detection read 12-bit JPEG XL; a daylight run in auto mode on each tower
|
|
||||||
(verification below); tune `settle_wait_ms` (500 ms is a guess) and `frame_timeout_ms` (7 s from
|
|
||||||
the worst measured step).
|
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|
@ -51,7 +26,7 @@ exactly as today.
|
||||||
## Processing budget the design must fit
|
## Processing budget the design must fit
|
||||||
|
|
||||||
Measured on the **identical frame** on both computers (5328×3040, real scene; 4 threads; see the
|
Measured on the **identical frame** on both computers (5328×3040, real scene; 4 threads; see the
|
||||||
report's [Section 3.5](raw12-pipeline-measurements.md#35-processing-time-per-step)).
|
report's [Where the time goes](raw12-pipeline-measurements.md#where-the-time-goes)).
|
||||||
|
|
||||||
### Time per saved frame, step by step
|
### Time per saved frame, step by step
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,193 +0,0 @@
|
||||||
# Context for discussing `raw12-pipeline-measurements.md`
|
|
||||||
|
|
||||||
This file gives an AI assistant the background needed to discuss the report
|
|
||||||
[raw12-pipeline-measurements.md](raw12-pipeline-measurements.md): what was asked, how the tests were
|
|
||||||
run, what the numbers mean, what was decided, which points have been questioned, and what is still
|
|
||||||
open. Read the report itself for the full tables and figures; this file explains around them.
|
|
||||||
State as of 2026-10-05, branch `raw12-capture`.
|
|
||||||
|
|
||||||
## 1. The system in one paragraph
|
|
||||||
|
|
||||||
Fire-watch towers scan waypoints with a gimbal-mounted Allied Vision Alvium camera and keep one
|
|
||||||
picture per waypoint, saved as JPEG XL on the tower's computer. After the gimbal stops, the camera's
|
|
||||||
continuous auto-exposure needs frames to adapt to the new view, so the scan waits ("settling"). The
|
|
||||||
tower program is C++ (`fgc`), using Vimba X (VmbCPP), OpenCV and libjxl. Two computers are in use:
|
|
||||||
LattePanda IOTA (Intel N150, tower 121) and LattePanda 3 Delta (Celeron N5105, tower 111).
|
|
||||||
|
|
||||||
## 2. The question and how it evolved
|
|
||||||
|
|
||||||
- Origin: speeding up the per-waypoint capture. Today the camera sends RGB8 (colour made in the
|
|
||||||
camera, 48.6 MB per full frame), which limits the frame rate over USB to 4.95 fps at the agreed
|
|
||||||
250 MB/s link cap, so exposure settles slowly.
|
|
||||||
- Proposal tested: camera sends raw `BayerRG12p` (24.3 MB, 9.86 fps), the tower unpacks,
|
|
||||||
de-Bayers (converts to colour) and saves 12-bit colour JPEG XL.
|
|
||||||
- An earlier campaign ([camera-capture-measurements.md](camera-capture-measurements.md)) used
|
|
||||||
blurred dim scenes; its compression numbers were unrepresentative. This campaign used a focused,
|
|
||||||
real scene (spruce, birch, leaves, building, ~13% clipped sky).
|
|
||||||
- The report went through several rounds of requested edits: example images per compression type,
|
|
||||||
methodology detail (how settings were forced), processing on one identical frame on both towers,
|
|
||||||
an abstract, a pixel-format description, an academic structure (numbered sections, figure and
|
|
||||||
table captions, units in every header, references), captions that explain every term, an
|
|
||||||
explanation of raw mosaic vs raw planes, a more careful treatment of what 12 bits add, and a fuller
|
|
||||||
explanation of the keep rules.
|
|
||||||
|
|
||||||
## 3. User decisions (fixed constraints, do not re-litigate)
|
|
||||||
|
|
||||||
- Full resolution always; **no binning**; **one quality setting** (no switching between a fast low
|
|
||||||
quality mode and a slow high quality one); continuous acquisition.
|
|
||||||
- **No exposure memory** across passes or from neighbouring waypoints.
|
|
||||||
- USB link cap **250 MB/s** (`DeviceLinkThroughputLimit`), conservative.
|
|
||||||
- **`BayerRG12p` everywhere**, day and night profiles.
|
|
||||||
- Save **12-bit colour JPEG XL only**; no raw-planes option in the program for now.
|
|
||||||
- **Fixed white balance** ratios kept in the profiles (auto white balance was measured but not adopted).
|
|
||||||
- When saving falls behind, **pause the scan** (save gate) rather than drop frames.
|
|
||||||
- Writing style for the report: academic structure, captions that explain their terms, units given
|
|
||||||
wherever possible, plain language. Commit messages: one plain-language line, no trailers.
|
|
||||||
|
|
||||||
## 4. Test setup essentials
|
|
||||||
|
|
||||||
- Same camera for both towers: Alvium 1800 U-1620c `DEV_1AB22C0B7D4E`, 5328 × 3040, firmware 14.3.
|
|
||||||
Moved from 121 to 111 so only the computer differs. Tower 111's own camera (1800 U-1240c, 12 MP,
|
|
||||||
4024 × 3036) was not tested; its frames are 25% smaller, so the Delta results are conservative.
|
|
||||||
- Dates: tower 121 on 2 Oct 2026 (sunny afternoon); tower 111 on 4 Oct (afternoon into evening);
|
|
||||||
shared-frame processing on both towers simultaneously on 4 Oct 23:03–23:32.
|
|
||||||
- The tower program was stopped during all tests.
|
|
||||||
- Storage write speed: IOTA 218 MB/s, Delta 76 MB/s (eMMC). Libraries: 121 libjxl 0.11.1 /
|
|
||||||
OpenCV 4.10; 111 libjxl 0.11.0 / OpenCV 4.5.4.
|
|
||||||
- Tools in `tools/camera-bench/` (camera_bench.py with vmbpy; raw12_bench.cpp; run scripts;
|
|
||||||
plot_results.py, plot_common.py, make_figures.py, compare_examples.py). Results in
|
|
||||||
`results-2026-10-02-tower121/`, `results-2026-10-04-tower111/`, `results-common-tower121/111/`.
|
|
||||||
Full-size example `.jxl` files in `tools/camera-bench/examples-*` (git-ignored, ~115 MB per set).
|
|
||||||
The recorded auto-exposure steps are also in `tests/data/ae_steps.csv` (used by a unit test).
|
|
||||||
|
|
||||||
## 5. Methods that are easy to misunderstand
|
|
||||||
|
|
||||||
- **Forcing settings:** feature writes over USB while streaming. `ExposureAuto`/`GainAuto` set to
|
|
||||||
`Off` freezes current values, then `ExposureTime`/`Gain` are written; for the 2 s "force" phase the
|
|
||||||
camera delivers 10–30 frames at the forced values; then auto is set back to `Continuous` (time 0).
|
|
||||||
Dark step: exposure E₀/16 with 0 dB gain. Bright step: exposure at the 20 ms cap plus gain up to
|
|
||||||
+24.1 dB (×16). Bright start is fully clipped (97–100%), so the camera cannot see how far over it is.
|
|
||||||
- **Brightness** = mean of a sparse sample of the frame buffer on 0–255 (12-bit: high byte).
|
|
||||||
**Settled** = stays within ±10% of the median brightness of the run's last 2 s.
|
|
||||||
- **Frames in transit:** the first 1–5 frames after release still carry the forced values.
|
|
||||||
- **Shared frame:** tower 121's real frame rebuilt bit-exactly from its lossless 12-bit file and
|
|
||||||
copied to both towers (identical MD5). Its 8-bit raw version is the 12-bit value >> 4; its "RGB8"
|
|
||||||
version is the tower's own edge-aware conversion scaled to 8 bits, so on the shared frame the RGB8
|
|
||||||
file size equals raw 8-bit's (2.66 MB). The camera's real RGB8 files were 3.7–4.0 MB.
|
|
||||||
- **Synthetic frame:** "dead leaves" texture plus noise (ISO 19567-2 style), sharper than any real
|
|
||||||
scene; worst case for the encoder; used for pile-up and heat.
|
|
||||||
- **Quality** is against the exact lossless 12-bit colour image of the same frame: PSNR on the
|
|
||||||
12-bit scale over 3 channels; SSIM on luma. JPEG XL is perceptual, so PSNR is moderate while SSIM
|
|
||||||
is high.
|
|
||||||
- Camera `BayerRG` = OpenCV `COLOR_BayerBG2RGB` (verified). The camera applies white balance to raw.
|
|
||||||
- 12-bit JPEG XL from 16-bit words needs bit depth "from codestream", or values get rescaled;
|
|
||||||
lossless needs the original colour profile. An earlier mistake on both points was found and the
|
|
||||||
12-bit numbers corrected before the final report.
|
|
||||||
|
|
||||||
## 6. Key results (numbers to quote)
|
|
||||||
|
|
||||||
Frame rate at 250 MB/s (fps): RGB8 4.95, BayerRG8 14.75, BayerRG10p 11.81, BayerRG12p 9.86,
|
|
||||||
BayerRG12 7.41. 60 s streams: zero lost or incomplete frames in every format on both towers, also
|
|
||||||
while saving continuously. On 111 the camera shares the USB 3 controller with a 5G modem, a USB
|
|
||||||
network adapter and the idle second camera.
|
|
||||||
|
|
||||||
Auto-exposure settling after ×16 steps: ~25–35 frames, so time scales with 1/frame rate.
|
|
||||||
BayerRG8 1.8–2.7 s; BayerRG12p 2.2–6.6 s (typically ≈ 3 s; long runs contain mid-way pauses);
|
|
||||||
RGB8 4.6–6.6 s. With exposure at its 20 ms cap the camera adjusts gain by 1–2 dB per frame and
|
|
||||||
sometimes pauses up to 2.8 s while still far too bright.
|
|
||||||
|
|
||||||
Keep rules (24 runs = 3 formats × 2 directions × 2 repeats × 2 towers; good = within ±10% of
|
|
||||||
final level):
|
|
||||||
- "Exposure and gain unchanged 3 frames": 0/24. Replay detail (added 2026-10-05 from
|
|
||||||
`tests/data/ae_steps.csv`): 16 runs fired on the first frames (0.2–0.5 s, frames in transit, image
|
|
||||||
black 9–13 or white 253–255 vs ≈ 120); 8 fired during mid-way pauses at brightness 33–227.
|
|
||||||
- "In band 2 frames": 12/24 (passes through the band ~15% above final level when descending).
|
|
||||||
- "In band and steady (≤ 2%/frame) 3 frames": 21/24 (3 slightly early on descents); median time
|
|
||||||
when correct 3.61 s (121) / 5.45 s (111). Implemented as `settle_rule = brightness`.
|
|
||||||
- Band = `IntensityControllerTarget` ± `Tolerance` = 50 ± 5% → 114.75–140.25 on 0–255.
|
|
||||||
|
|
||||||
Auto white balance: recovers in 2–4 frames (0.2–0.8 s), stable ±0.8–3.1%. Exposure, not white
|
|
||||||
balance, sets the wait. Auto ratios ≈ red 2.36–2.46, blue 1.87–1.97 vs fixed profile 2.87 / 2.0.
|
|
||||||
|
|
||||||
Processing on the shared frame (4 threads; IOTA / Delta, ms): unpack 10 / 17; de-Bayer 12-bit
|
|
||||||
bilinear 17 / 22, edge-aware 28 / 33; encode 12-bit d1.0 e3 480 / 765; today's RGB8 encode
|
|
||||||
d0.8 e4 566 / 867. Totals: 12-bit edge-aware d1.0 e3 **518 / 820 ms, 2.65 MB**; today RGB8
|
|
||||||
567 / 872 ms, 3.27 MB; raw 8-bit → colour 468 / 741 ms, 2.66 MB. Delta ≈ 1.6× slower.
|
|
||||||
Unpack + de-Bayer = 7% of a save; encode ≈ 90%. Effort 1–4 within ~15%; effort 3 smallest;
|
|
||||||
4 threads fastest; on the IOTA 3 threads is oddly slower than 2.
|
|
||||||
|
|
||||||
Distance sweep (12-bit colour, shared frame, MB): lossless 44.8, d0.3 7.19, d0.5 5.15, d0.8 3.16,
|
|
||||||
d1.0 2.65, d1.5 1.63, d2.0 1.32. Quality at d1.0: PSNR 36.6 dB, SSIM 0.973. Raw planes d0.5:
|
|
||||||
6.0 MB, PSNR 47.2 dB. Lossless: raw planes 17.6, raw mosaic 20.9, colour 47.4 MB.
|
|
||||||
|
|
||||||
Throughput (saves/s back to back, shared frame): 1 worker × 4 threads IOTA 1.79, Delta 1.19;
|
|
||||||
2 × 2: 2.15 / 1.53. On the shared frame the IOTA keeps up with one frame per 0.7 s with one worker;
|
|
||||||
the Delta needs two. Pile-up details on the synthetic frame are in Table 18.
|
|
||||||
|
|
||||||
Heat (10 min each): one frame every 2 s → IOTA 53 °C mean / 5.1 W, Delta 66 °C / 5.7 W. Back to
|
|
||||||
back (2 × 2): IOTA 80 °C, 12.5 W, loses 16% throughput as it heats; Delta 78 °C, at its 8 W limit,
|
|
||||||
steady.
|
|
||||||
|
|
||||||
Storage: 12-bit colour d1.0 ≈ 2.65–2.75 MB/frame → 114–119 GB/day at one frame every 2 s. Towers
|
|
||||||
have 11–22 GB free, so images must leave the tower continuously.
|
|
||||||
|
|
||||||
## 7. The 12-bit question (discussed at length; current position)
|
|
||||||
|
|
||||||
- Noise floor (12-bit DN, green pixels): at ≈ 700 DN 26 (0 dB) / 42 (15 dB); at ≈ 1500 35 / 58;
|
|
||||||
at ≈ 3000 37 / 80. One 8-bit step = 16 DN; rounding error RMS = 16/√12 = 4.6 DN.
|
|
||||||
- Errors add in quadrature: √(26² + 4.6²) = 26.4 → rounding raises noise by at most 1.5% in the
|
|
||||||
measured daylight scene (0.6% at 15 dB, < 0.2% in bright areas).
|
|
||||||
- Averaging: while noise > one 8-bit step, rounding error is random (dither) and averages down at
|
|
||||||
the same √N rate as noise, so 8-bit averages resolve sub-step differences as well as 12-bit. A
|
|
||||||
simulation (not in the report, by user request) showed a 3 DN difference over 100 × 100 pixels
|
|
||||||
recovered exactly by both at noise 26, but only 0.2 of 3 by 8-bit at noise 2.
|
|
||||||
- 12 bits always contain at least as much information; at noise 26 the dropped bits carry about
|
|
||||||
0.02 bits/pixel of scene information.
|
|
||||||
- Compression dominates: PSNR 36.6 dB at d1.0 ≈ 61 DN RMS error (13× the rounding error); d0.3
|
|
||||||
≈ 40 DN; raw planes d0.5 ≈ 18 DN. Much of that is discarded noise, and JPEG XL keeps relatively
|
|
||||||
more precision in dark tones; shadow-only error was not measured.
|
|
||||||
- Where 12 bits should matter (Table 17b): dark areas at low gain where noise < one 8-bit step
|
|
||||||
(below ≈ 300 DN at 0 dB, ≈ 100 DN at 15 dB), i.e. high-contrast scenes such as shaded forest or
|
|
||||||
backlit ridges under a bright sky at dawn or dusk; thin smoke over dark terrain is the relevant
|
|
||||||
fire-detection case. At night with high gain, noise is amplified above the step again, so little
|
|
||||||
benefit. Strong shadow lifting (≥ 3 stops) untested; 2 stops showed no difference.
|
|
||||||
- None of the dark cases were measured (the darkest area in the scene was ≈ 700 DN; a dusk attempt
|
|
||||||
on 121 needed 24 dB and was discarded). Proposed test: a dawn/dusk frame with dark terrain under a
|
|
||||||
bright sky, compared as in Figures 12 and 13.
|
|
||||||
- 12-bit vs 8-bit raw costs little in saving (same size, ~10% more time) but the camera runs at
|
|
||||||
9.86 vs 14.75 fps, ~1 s slower settling in daylight. The main gain over today is frame rate, not
|
|
||||||
bit depth.
|
|
||||||
|
|
||||||
## 8. Limitations to keep in mind
|
|
||||||
|
|
||||||
One scene, two afternoons, main runs at ≈ 15 dB gain plus one 0 dB frame (124 ms exposure, cap
|
|
||||||
raised to 200 ms); no night data. Steps forced on a still camera, not real slews; real
|
|
||||||
waypoint-to-waypoint changes are usually smaller. Tower program stopped, so its own load is not
|
|
||||||
included. Detection quality not measured (PSNR/SSIM stand in). Slew time (≈ 1–1.5 s) and
|
|
||||||
mechanical settling (≈ 0.2 s) in the pace table are estimates.
|
|
||||||
|
|
||||||
## 9. Implementation status (branch `raw12-capture`)
|
|
||||||
|
|
||||||
Implemented and unit-tested (457 tests pass locally): 12-bit JPEG XL encoding with the bit-depth fix
|
|
||||||
and true lossless (known issue #20 fixed); frames carry pixel layout, chunk exposure/gain/white
|
|
||||||
balance and a brightness sample; `ImagePipeline` unpacks and de-Bayers (edge-aware default),
|
|
||||||
N workers, bounded queue; scheduler save gate pauses the scan when the queue is full; `SettleRule`
|
|
||||||
(`settle_rule = brightness`) with a time limit and an "at limit" exit; config keys `save_workers`,
|
|
||||||
`jxl_threads`, `save_queue_max`, `demosaic`, `settle_rule`; profile export script recipe for raw
|
|
||||||
`standard` and `night` profiles. Plan: [raw12-pipeline-implementation-plan.md](raw12-pipeline-implementation-plan.md).
|
|
||||||
|
|
||||||
Not done yet: exporting the new camera profiles on a tower (the 1620c is currently on tower 111
|
|
||||||
and must be moved back to 121 first); field verification with real slews; checking that downstream
|
|
||||||
consumers decode 12-bit files; the dawn/dusk 12-bit test. The branch is deployed on tower 111,
|
|
||||||
whose boot service still passes old `--init/--start` flags and needs `scripts/install-tower`.
|
|
||||||
|
|
||||||
## 10. Report structure (for orientation)
|
|
||||||
|
|
||||||
Abstract; Units and abbreviations; 1 Introduction (1.1 question, 1.2 pixel formats incl. raw
|
|
||||||
mosaic vs raw planes, Figures 1a/1b, Table 1; 1.3 earlier work); 2 Materials and methods (2.1–2.12,
|
|
||||||
Tables 2–8, Figure 2); 3 Results (3.1 frame rate, 3.2 auto-exposure, 3.3 keep rules, 3.4 white
|
|
||||||
balance, 3.5 processing per step, 3.6 effort and threads, 3.7 quality setting, 3.8 example images,
|
|
||||||
3.9 information content of 12-bit data with Tables 17a/17b, 3.10 throughput, 3.11 heat,
|
|
||||||
3.12 storage; Figures 3–17, Tables 9–20); 4 Discussion (summary Table 21, pace Table 22,
|
|
||||||
limitations); 5 Conclusions and six recommendations; References [1]–[7]; Appendix A raw data and
|
|
||||||
tools; Appendix B per-tower processing times. Figures live in `docs/images/raw12/`.
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
#
|
#
|
||||||
# The facts a kit must set before sourcing:
|
# The facts a kit must set before sourcing:
|
||||||
# KIT_BOARD lattepanda-iota | lattepanda-3-delta (matches probe_board)
|
# KIT_BOARD lattepanda-iota | lattepanda-3-delta (matches probe_board)
|
||||||
# KIT_SAVE_WORKERS image-saving workers for this board (1 IOTA, 2 3 Delta)
|
|
||||||
# KIT_FW_ENV the PlatformIO env for this board's MCU
|
# KIT_FW_ENV the PlatformIO env for this board's MCU
|
||||||
# KIT_MCU_VID USB vendor of the motor MCU (2e8a RP2040, 2341 Arduino)
|
# KIT_MCU_VID USB vendor of the motor MCU (2e8a RP2040, 2341 Arduino)
|
||||||
# KIT_DEFAULT_AXES what this board's towers are usually built with
|
# KIT_DEFAULT_AXES what this board's towers are usually built with
|
||||||
|
|
@ -393,11 +392,6 @@ imu = $IMU
|
||||||
env = $ENV_SENSOR
|
env = $ENV_SENSOR
|
||||||
cooling = $COOLING
|
cooling = $COOLING
|
||||||
cameras = $CAMERAS
|
cameras = $CAMERAS
|
||||||
; Image saving on this computer: frames encoded at once (libjxl threads per
|
|
||||||
; frame default to 4 / save_workers), and frames that may wait before the
|
|
||||||
; scan holds. Measured for this board in docs/raw12-pipeline-measurements.md.
|
|
||||||
save_workers = ${KIT_SAVE_WORKERS:-1}
|
|
||||||
save_queue_max = 4
|
|
||||||
|
|
||||||
[Serial]
|
[Serial]
|
||||||
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem
|
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ KIT_BOARD=lattepanda-3-delta
|
||||||
KIT_FW_ENV=leonardo
|
KIT_FW_ENV=leonardo
|
||||||
KIT_MCU_VID=2341
|
KIT_MCU_VID=2341
|
||||||
KIT_DEFAULT_AXES=yaw,pitch
|
KIT_DEFAULT_AXES=yaw,pitch
|
||||||
# Image saving: the N5105 needs two workers x 2 threads to keep up (docs/raw12-pipeline-measurements.md).
|
|
||||||
KIT_SAVE_WORKERS=2
|
|
||||||
KIT_IMU_DEVICE=/dev/ttyS0
|
KIT_IMU_DEVICE=/dev/ttyS0
|
||||||
KIT_IMU_NOTE="On the Delta the MTi is on the RS-232 header: a hardware UART, not a USB node."
|
KIT_IMU_NOTE="On the Delta the MTi is on the RS-232 header: a hardware UART, not a USB node."
|
||||||
KIT_ENV_DEVICE=/dev/i2c-2
|
KIT_ENV_DEVICE=/dev/i2c-2
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ KIT_BOARD=lattepanda-iota
|
||||||
KIT_FW_ENV=iota_rp2040
|
KIT_FW_ENV=iota_rp2040
|
||||||
KIT_MCU_VID=2e8a
|
KIT_MCU_VID=2e8a
|
||||||
KIT_DEFAULT_AXES=yaw
|
KIT_DEFAULT_AXES=yaw
|
||||||
# Image saving: the N150 keeps up with one worker x 4 threads (docs/raw12-pipeline-measurements.md).
|
|
||||||
KIT_SAVE_WORKERS=1
|
|
||||||
KIT_IMU_DEVICE=/dev/ttyS0
|
KIT_IMU_DEVICE=/dev/ttyS0
|
||||||
KIT_IMU_NOTE="On the IOTA the only PCH serial on the header is COM1; confirm it enumerates first."
|
KIT_IMU_NOTE="On the IOTA the only PCH serial on the header is COM1; confirm it enumerates first."
|
||||||
# No PCH-side I2C reaches the header, so an ambient sensor needs a USB-I2C bridge
|
# No PCH-side I2C reaches the header, so an ambient sensor needs a USB-I2C bridge
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,6 @@ long long firstFrameLimitMs(double exposure_us);
|
||||||
// `failed_restarts` counts restarts since the last frame that arrived.
|
// `failed_restarts` counts restarts since the last frame that arrived.
|
||||||
StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_restarts);
|
StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_restarts);
|
||||||
|
|
||||||
// How long a stream that has delivered may go without a newer frame before it
|
|
||||||
// counts as stalled: twice the longer of the exposure and the frame period, plus
|
|
||||||
// a second, and never under 2 s. A slow frame is not a stall - tower 111 runs
|
|
||||||
// multi-second exposures, one frame every 7-9 s, and restarting the stream the
|
|
||||||
// moment a capture fell due before the next frame threw that exposure away, so
|
|
||||||
// the next frame came later still (2026-10-04).
|
|
||||||
long long staleFrameLimitMs(double exposure_us, double frame_period_ms);
|
|
||||||
|
|
||||||
// No frame newer than the last one kept, the newest having arrived
|
|
||||||
// `since_newest_ms` ago: wait for the next one, or restart the stream.
|
|
||||||
StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms);
|
|
||||||
|
|
||||||
// A stream that delivered before but has gone stale, and a restart has just
|
// A stream that delivered before but has gone stale, and a restart has just
|
||||||
// failed to bring a fresh frame. Restart again, or reset once restarts have
|
// failed to bring a fresh frame. Restart again, or reset once restarts have
|
||||||
// stopped working.
|
// stopped working.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
#include "fgc/IMotorController.h"
|
#include "fgc/IMotorController.h"
|
||||||
#include "fgc/OperatingMode.h"
|
#include "fgc/OperatingMode.h"
|
||||||
#include "fgc/ScanGrid.h"
|
#include "fgc/ScanGrid.h"
|
||||||
#include "fgc/SettleRule.h"
|
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
@ -18,22 +17,13 @@ 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. The interval is the time from one kept frame to
|
// 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when
|
||||||
// the next: as soon as a frame is kept it issues an absolute
|
// the interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next
|
||||||
// `MOVE <yaw>,<pitch>` to the next target, waits until both axes report
|
// target, waits until both axes report standstill at that target, waits a
|
||||||
// standstill there, waits for the exposure to settle, then waits out what
|
// further settle_wait_ms so the free-running camera has exposed a frame with
|
||||||
// is left of the interval and keeps the newest frame. If none is ready it
|
// the gimbal at rest, then 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; with the keep rule on (setSettleRule)
|
|
||||||
// a scan waypoint then also waits until the camera's frames say the image
|
|
||||||
// has settled (SettleRule), at most frame_timeout_ms more - after which the
|
|
||||||
// newest frame is kept anyway, with a warning.
|
|
||||||
//
|
|
||||||
// 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
|
||||||
|
|
@ -69,38 +59,20 @@ 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 - one per interval since the last, once standstill + the exposure
|
// own cycle - the interval, then standstill + settle wait - wherever the
|
||||||
// wait are done - wherever the operator has aimed. The gimbal stays free to
|
// operator has aimed. The gimbal stays free to nudge; a frame is only kept at
|
||||||
// nudge; a frame is only kept at rest. No overrun warnings: the operator may be
|
// rest. Ignored outside manual, and cleared by any setMode().
|
||||||
// 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
|
||||||
|
|
||||||
// Is there room to save another frame (ImagePipeline::canAccept)? Asked
|
|
||||||
// before moving to the next waypoint and before keeping a continuous-capture
|
|
||||||
// frame. While it says no, the scan holds where it is, so a scan that
|
|
||||||
// outruns saving slows down instead of losing pictures or filling memory.
|
|
||||||
// A requested snapshot is never held. Unset: always room.
|
|
||||||
void setSaveGate(std::function<bool()> can_save) { can_save_ = std::move(can_save); }
|
|
||||||
// True while the scan is held for saving to catch up.
|
|
||||||
bool holdingForSave() const { return holding_; }
|
|
||||||
|
|
||||||
// Per-camera-profile timing (see CameraProfile): how long to wait after standstill
|
// Per-camera-profile timing (see CameraProfile): how long to wait after standstill
|
||||||
// for the exposure to settle, and how long to keep retrying before abandoning
|
// before keeping a frame, and how long to keep retrying before abandoning the
|
||||||
// the waypoint. Called on the control thread, same as tick().
|
// 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_; }
|
||||||
|
|
||||||
// The keep rule for scan waypoints (profile setting settle_rule). Off: keep
|
|
||||||
// the newest frame once settle_wait_ms has passed, as always. On: after
|
|
||||||
// settle_wait_ms (now the mechanical minimum), wait until the camera's
|
|
||||||
// frames say the image has settled (SettleRule), within frame_timeout_ms; a
|
|
||||||
// camera that reports no frame data falls back to the fixed wait.
|
|
||||||
void setSettleRule(bool on, const SettleRule::Params& params = {});
|
|
||||||
bool settleRuleOn() const { return use_rule_; }
|
|
||||||
|
|
||||||
// Replace the degrees<->counts maps (e.g. after `gimbal heading fit`) so subsequent
|
// Replace the degrees<->counts maps (e.g. after `gimbal heading fit`) so subsequent
|
||||||
// scan waypoints target with the calibrated conversion. Called on the control
|
// scan waypoints target with the calibrated conversion. Called on the control
|
||||||
// thread, same as tick(), so no locking is needed.
|
// thread, same as tick(), so no locking is needed.
|
||||||
|
|
@ -113,50 +85,20 @@ 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_; }
|
|
||||||
|
|
||||||
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 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): settle_wait_ms, then with `keep_rule` the keep rule too.
|
|
||||||
bool dwellOver(Hold& h, bool keep_rule);
|
|
||||||
// The keep rule, fed the frames since it last looked: true once it has
|
|
||||||
// decided - settled, at the exposure limits, timed out (with a warning), or
|
|
||||||
// no frame data to judge.
|
|
||||||
bool ruleDecided(long long since_settled);
|
|
||||||
// The save gate, with one log line when a hold starts and one when it ends.
|
|
||||||
bool roomToSave();
|
|
||||||
// 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 exposure wait, then (continuous only) the
|
// gimbal to stand still, then the settle wait, then trigger until the frame
|
||||||
// rest of the interval, then trigger until the frame budget runs out.
|
// budget runs out. `settled_ms` is that request's own settle clock.
|
||||||
Shot shootAtRest(const MotorTelemetry& t, Hold& h, bool wait_interval);
|
enum class Shot { Waiting, Captured, TimedOut };
|
||||||
|
Shot shootAtRest(const MotorTelemetry& t, long long& settled_ms);
|
||||||
|
|
||||||
IMotorController& motor_;
|
IMotorController& motor_;
|
||||||
ICameraSource& camera_;
|
ICameraSource& camera_;
|
||||||
|
|
@ -164,11 +106,8 @@ private:
|
||||||
ScanGrid& grid_;
|
ScanGrid& grid_;
|
||||||
|
|
||||||
std::function<long long()> now_ms_;
|
std::function<long long()> now_ms_;
|
||||||
std::function<bool()> can_save_;
|
|
||||||
bool holding_ = false;
|
|
||||||
double image_rate_;
|
double image_rate_;
|
||||||
double interval_ms_; // 1000 / image_rate_; 0 = never
|
long long timer_start_ = 0;
|
||||||
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_;
|
||||||
|
|
@ -176,17 +115,8 @@ 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, its frame not yet kept
|
bool moving_ = false; // a MOVE has been issued, awaiting settle
|
||||||
Hold hold_; // the current waypoint's frame
|
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet)
|
||||||
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 save_held_ = false; // the next move waited for saving to catch up
|
|
||||||
bool use_rule_ = false; // keep by SettleRule, not after a fixed wait
|
|
||||||
SettleRule rule_;
|
|
||||||
bool rule_started_ = false; // this waypoint's rule has its first frame number
|
|
||||||
uint64_t rule_seq_ = 0; // last frame fed to the rule
|
|
||||||
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)
|
||||||
|
|
@ -196,9 +126,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
|
||||||
Hold snapshot_hold_;
|
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet)
|
||||||
bool continuous_ = false; // `capture on`
|
bool continuous_ = false; // `capture on`
|
||||||
Hold continuous_hold_;
|
long long continuous_settled_ms_ = 0;
|
||||||
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";
|
||||||
double image_interval = 5; // desired seconds from one picture to the next
|
int image_interval = 5; // seconds between captures
|
||||||
bool debug = false; // print motor telemetry each loop tick
|
bool debug = false; // print motor telemetry each loop tick
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -49,13 +49,6 @@ struct HardwareConfig {
|
||||||
// a span is cut into as many pictures as it takes (OperatingMode.h).
|
// a span is cut into as many pictures as it takes (OperatingMode.h).
|
||||||
double pan_step_deg = 6.0;
|
double pan_step_deg = 6.0;
|
||||||
|
|
||||||
// Saving images - a property of the computer, not of what the tower does.
|
|
||||||
// Measured (docs/raw12-pipeline-measurements.md): the IOTA keeps up with one
|
|
||||||
// worker x 4 threads, the 3 Delta needs two x 2.
|
|
||||||
int save_workers = 1; // frames encoded at once
|
|
||||||
int jxl_threads = 0; // libjxl threads per frame; 0 = 4 / save_workers
|
|
||||||
int save_queue_max = 4; // frames waiting or saving before the scan holds
|
|
||||||
|
|
||||||
// Was a hardware.ini actually read? False on every tower that has not been
|
// Was a hardware.ini actually read? False on every tower that has not been
|
||||||
// migrated yet, and the switch that keeps this change invisible on rollout.
|
// migrated yet, and the switch that keeps this change invisible on rollout.
|
||||||
bool declared = false;
|
bool declared = false;
|
||||||
|
|
@ -186,11 +179,6 @@ struct SerialConfig {
|
||||||
struct CameraProfileTiming {
|
struct CameraProfileTiming {
|
||||||
int settle_wait_ms = 1000; // hold after standstill before keeping a frame
|
int settle_wait_ms = 1000; // hold after standstill before keeping a frame
|
||||||
int frame_timeout_ms = 2000; // give up on the waypoint after this
|
int frame_timeout_ms = 2000; // give up on the waypoint after this
|
||||||
// How to tell the frame is good to keep, after settle_wait_ms:
|
|
||||||
// fixed keep the newest frame as soon as settle_wait_ms has passed
|
|
||||||
// brightness wait until brightness is in the camera's target band and
|
|
||||||
// steady for 3 frames (SettleRule.h), up to frame_timeout_ms
|
|
||||||
std::string settle_rule = "fixed";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -208,8 +196,6 @@ struct CameraSpec {
|
||||||
// Encoding, defaulted from [Camera] and overridable per camera.
|
// Encoding, defaulted from [Camera] and overridable per camera.
|
||||||
double jxl_distance = 0.8;
|
double jxl_distance = 0.8;
|
||||||
int jxl_effort = 4;
|
int jxl_effort = 4;
|
||||||
// De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear".
|
|
||||||
std::string demosaic = "edge_aware";
|
|
||||||
|
|
||||||
std::map<std::string, CameraProfileTiming> timing; // by profile name
|
std::map<std::string, CameraProfileTiming> timing; // by profile name
|
||||||
|
|
||||||
|
|
@ -222,7 +208,6 @@ struct CameraConfig {
|
||||||
// [Camera] defaults, inherited by each block unless it overrides them.
|
// [Camera] defaults, inherited by each block unless it overrides them.
|
||||||
double jxl_distance = 0.8;
|
double jxl_distance = 0.8;
|
||||||
int jxl_effort = 4;
|
int jxl_effort = 4;
|
||||||
std::string demosaic = "edge_aware";
|
|
||||||
|
|
||||||
// Enabled cameras only.
|
// Enabled cameras only.
|
||||||
std::vector<const CameraSpec*> active() const;
|
std::vector<const CameraSpec*> active() const;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "fgc/CameraProfile.h"
|
#include "fgc/CameraProfile.h"
|
||||||
#include "fgc/RawImage.h"
|
|
||||||
#include "fgc/SettleRule.h"
|
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
@ -13,25 +11,12 @@ namespace fgc {
|
||||||
|
|
||||||
// One captured frame, owning a copy of its pixel buffer (was image_store_8bit).
|
// One captured frame, owning a copy of its pixel buffer (was image_store_8bit).
|
||||||
struct Frame {
|
struct Frame {
|
||||||
std::vector<uint8_t> data; // as delivered: BayerRG12p stays packed
|
std::vector<uint8_t> data;
|
||||||
uint32_t width = 0;
|
uint32_t width = 0;
|
||||||
uint32_t height = 0;
|
uint32_t height = 0;
|
||||||
int channels = 0; // values per pixel as delivered: 3 (RGB) or 1 (mono, Bayer mosaic)
|
int channels = 0; // 1 (mono) or 3 (RGB)
|
||||||
// How `data` is laid out. Unknown only for a format the program does not
|
|
||||||
// handle; `channels` then says what it holds, as before layouts existed.
|
|
||||||
PixelLayout layout = PixelLayout::Unknown;
|
|
||||||
long long timestamp_ms = 0; // Unix epoch ms
|
long long timestamp_ms = 0; // Unix epoch ms
|
||||||
int cam_id = 0; // index into the configured camera list
|
int cam_id = 0; // index into the configured camera list
|
||||||
|
|
||||||
// What the camera says it exposed this frame with (its per-frame chunk
|
|
||||||
// data). Negative when the profile does not turn chunk data on.
|
|
||||||
double exposure_us = -1.0;
|
|
||||||
double gain_db = -1.0;
|
|
||||||
double wb_red = -1.0; // BalanceRatio, red against green
|
|
||||||
double wb_blue = -1.0;
|
|
||||||
// Sampled from `data` on arrival (see RawImage.h); invalid when the layout
|
|
||||||
// is Unknown.
|
|
||||||
BrightnessSample brightness;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live per-camera identity, sensor telemetry, and acquisition stats, for the
|
// Live per-camera identity, sensor telemetry, and acquisition stats, for the
|
||||||
|
|
@ -92,20 +77,6 @@ public:
|
||||||
|
|
||||||
virtual void setFrameCallback(FrameCallback cb) = 0;
|
virtual void setFrameCallback(FrameCallback cb) = 0;
|
||||||
|
|
||||||
// ---- For the keep rule (SettleRule.h) ---------------------------------------
|
|
||||||
// The most recent frames camera 0 delivered, with their brightness, exposure
|
|
||||||
// and gain. Sequence numbers count up for the session; framesSince(n) returns
|
|
||||||
// the frames after n, oldest first (only the last few dozen are kept).
|
|
||||||
// frameSeq() is the newest sequence number. Default: no frames, and the scan
|
|
||||||
// keeps its fixed settle wait.
|
|
||||||
virtual uint64_t frameSeq() { return 0; }
|
|
||||||
virtual std::vector<FrameStats> framesSince(uint64_t /*after*/) { return {}; }
|
|
||||||
// A numeric camera feature (IntensityControllerTarget, ExposureAutoMax...).
|
|
||||||
// False if the camera has no such feature.
|
|
||||||
virtual bool readFeature(int /*camera_index*/, const std::string& /*name*/, double& /*value*/) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number of cameras this source manages.
|
// Number of cameras this source manages.
|
||||||
virtual int cameraCount() const = 0;
|
virtual int cameraCount() const = 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,9 @@ struct Orientation {
|
||||||
|
|
||||||
// Consumes captured frames and turns them into stored artifacts: rotate by
|
// Consumes captured frames and turns them into stored artifacts: rotate by
|
||||||
// Camera.rotation_deg, encode to JPEG XL, write to
|
// Camera.rotation_deg, encode to JPEG XL, write to
|
||||||
// <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Raw frames
|
// <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Runs a
|
||||||
// (BayerRG8, BayerRG12p) are de-Bayered first and saved at their own bit depth,
|
// background worker so encoding never blocks acquisition. Split out of the old
|
||||||
// so a 12-bit frame becomes a 12-bit file. Runs background workers so encoding
|
// VimbaHandler::SaveImage so any camera source (real or mock) can feed it.
|
||||||
// never blocks acquisition. Split out of the old VimbaHandler::SaveImage so any
|
|
||||||
// camera source (real or mock) can feed it.
|
|
||||||
class ImagePipeline {
|
class ImagePipeline {
|
||||||
public:
|
public:
|
||||||
// Per-camera output settings. Label, rotation and encoding are all properties
|
// Per-camera output settings. Label, rotation and encoding are all properties
|
||||||
|
|
@ -38,23 +36,12 @@ public:
|
||||||
int rotation_deg = 0; // clockwise; 0/90/180/270
|
int rotation_deg = 0; // clockwise; 0/90/180/270
|
||||||
double jxl_distance = 0.8; // 0 = lossless
|
double jxl_distance = 0.8; // 0 = lossless
|
||||||
int jxl_effort = 4;
|
int jxl_effort = 4;
|
||||||
// De-Bayering for raw frames: edge-aware (sharper, ~2x the time, still
|
|
||||||
// ~30 ms for 16 MP) or bilinear.
|
|
||||||
bool edge_aware = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Params {
|
struct Params {
|
||||||
std::string output_dir;
|
std::string output_dir;
|
||||||
std::vector<CameraOutput> cameras; // by camera index, in open order
|
std::vector<CameraOutput> cameras; // by camera index, in open order
|
||||||
std::string tower;
|
std::string tower;
|
||||||
// How saving shares the CPU (hardware.ini): frames encoded at once, and
|
|
||||||
// libjxl threads per frame. Measured: one worker x 4 threads on the IOTA,
|
|
||||||
// two x 2 on the 3 Delta (docs/raw12-pipeline-measurements.md).
|
|
||||||
int workers = 1;
|
|
||||||
int jxl_threads = 4;
|
|
||||||
// Frames waiting or being saved beyond which canAccept() says no, so the
|
|
||||||
// scan holds instead of piling frames up in memory (24 MB raw each).
|
|
||||||
int queue_max = 4;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
|
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
|
||||||
|
|
@ -77,14 +64,6 @@ public:
|
||||||
// different pose than the pixels.
|
// different pose than the pixels.
|
||||||
void submit(const Frame& frame);
|
void submit(const Frame& frame);
|
||||||
|
|
||||||
// Is there room for another frame? False once queue_max frames are waiting
|
|
||||||
// or being saved. The scheduler asks before moving to the next waypoint, so
|
|
||||||
// a scan that outruns the save rate pauses rather than losing pictures.
|
|
||||||
// submit() itself never refuses: a frame already captured is always saved.
|
|
||||||
bool canAccept() const;
|
|
||||||
// Frames waiting or being saved right now.
|
|
||||||
size_t pending() const;
|
|
||||||
|
|
||||||
// Last CamEvent published (camera label, heading/pitch, timestamp), for the
|
// Last CamEvent published (camera label, heading/pitch, timestamp), for the
|
||||||
// UI's camera panel. Thread-safe; nullopt until the first capture is saved.
|
// UI's camera panel. Thread-safe; nullopt until the first capture is saved.
|
||||||
std::optional<CamEvent> lastEvent() const;
|
std::optional<CamEvent> lastEvent() const;
|
||||||
|
|
@ -102,8 +81,6 @@ private:
|
||||||
|
|
||||||
void run();
|
void run();
|
||||||
void process(const QueuedFrame& queued);
|
void process(const QueuedFrame& queued);
|
||||||
// Encode and write one image; false (logged) on failure.
|
|
||||||
bool save(const Frame& frame, const CameraOutput& out, const std::string& file);
|
|
||||||
// Output settings for a camera index, or sane defaults if the index is out of
|
// Output settings for a camera index, or sane defaults if the index is out of
|
||||||
// range (a source delivering more cameras than were configured).
|
// range (a source delivering more cameras than were configured).
|
||||||
const CameraOutput& outputFor(int cam_id) const;
|
const CameraOutput& outputFor(int cam_id) const;
|
||||||
|
|
@ -113,11 +90,10 @@ private:
|
||||||
Params params_;
|
Params params_;
|
||||||
|
|
||||||
std::queue<QueuedFrame> queue_;
|
std::queue<QueuedFrame> queue_;
|
||||||
mutable std::mutex mutex_;
|
std::mutex mutex_;
|
||||||
std::condition_variable cv_;
|
std::condition_variable cv_;
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
std::vector<std::thread> workers_;
|
std::thread worker_;
|
||||||
size_t in_flight_ = 0; // frames a worker is saving (under mutex_)
|
|
||||||
|
|
||||||
mutable std::mutex last_event_mutex_;
|
mutable std::mutex last_event_mutex_;
|
||||||
std::optional<CamEvent> last_event_;
|
std::optional<CamEvent> last_event_;
|
||||||
|
|
|
||||||
|
|
@ -5,34 +5,16 @@
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
// Thin wrapper around libjxl for encoding an interleaved image buffer to a JPEG
|
// Thin wrapper around libjxl for encoding an 8-bit interleaved image buffer
|
||||||
// XL file. Replaces the header-only JPEG_XL.h class.
|
// to a JPEG XL file. Replaces the header-only JPEG_XL.h class.
|
||||||
class JpegXlEncoder {
|
class JpegXlEncoder {
|
||||||
public:
|
public:
|
||||||
// Encode `img` (1 or 3 interleaved channels) to `path`.
|
// Encode `img` (width*height*channels bytes, 8-bit, 1 or 3 channels) to
|
||||||
//
|
// `path`. `distance` is the JPEG XL frame distance (0 = lossless, higher =
|
||||||
// `bits` is the bit depth of the values handed in, and is what the file
|
// lossier); `effort` is the libjxl effort level. Returns true on success.
|
||||||
// records:
|
static bool encodeToFile(const std::string& path, const uint8_t* img, int width,
|
||||||
// 8 one byte per value (RGB8, Mono8, BayerRG8);
|
int height, int channels, float distance, int effort,
|
||||||
// 9..16 one native-endian 16-bit word per value holding 0..2^bits-1, e.g.
|
int threads = 3);
|
||||||
// 12 for BayerRG12p after unpacking. libjxl must be told the values
|
|
||||||
// use only `bits` bits, or it reads them as 0..65535 and rescales -
|
|
||||||
// and a "lossless" file then keeps only the top bits.
|
|
||||||
//
|
|
||||||
// `distance` is the JPEG XL frame distance: 0 = mathematically lossless,
|
|
||||||
// higher = lossier (1.0 is meant to be visually lossless). `effort` is
|
|
||||||
// libjxl's 1..9. `threads` is libjxl's worker count; 0 encodes on the
|
|
||||||
// calling thread. Returns false (and logs) on any failure; nothing is left
|
|
||||||
// at `path` unless the encode succeeded.
|
|
||||||
static bool encodeToFile(const std::string& path, const void* img, int width, int height,
|
|
||||||
int channels, int bits, float distance, int effort, int threads);
|
|
||||||
|
|
||||||
// 8-bit shorthand, as the program has always called it.
|
|
||||||
static bool encodeToFile(const std::string& path, const uint8_t* img, int width, int height,
|
|
||||||
int channels, float distance, int effort, int threads = 3) {
|
|
||||||
return encodeToFile(path, static_cast<const void*>(img), width, height, channels, 8,
|
|
||||||
distance, effort, threads);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace fgc
|
} // namespace fgc
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
// How a camera frame's bytes are laid out. The camera profile decides which one
|
|
||||||
// arrives (PixelFormat in the XML); the program accepts these and refuses others.
|
|
||||||
//
|
|
||||||
// Mono8 one byte per pixel, grey
|
|
||||||
// RGB8 three bytes per pixel, R G B - de-Bayered on the camera
|
|
||||||
// BayerRG8 one byte per pixel, the raw sensor mosaic:
|
|
||||||
// R G R G ...
|
|
||||||
// G B G B ...
|
|
||||||
// BayerRG12p the same mosaic at 12 bits, packed: two pixels in three bytes
|
|
||||||
// (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8,
|
|
||||||
// p1 = b1 >> 4 | b2 << 4)
|
|
||||||
//
|
|
||||||
// OpenCV names Bayer patterns by the second row, so the camera's BayerRG is
|
|
||||||
// OpenCV's COLOR_BayerBG2* (measured 2026-09-30 against the camera's own RGB8).
|
|
||||||
enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p };
|
|
||||||
|
|
||||||
// The GenICam name ("BayerRG12p"), or "Unknown".
|
|
||||||
const char* layoutName(PixelLayout layout);
|
|
||||||
// From a GenICam name; Unknown for anything the program does not handle.
|
|
||||||
PixelLayout layoutFromName(const std::string& name);
|
|
||||||
|
|
||||||
// True for the raw mosaic layouts, which need de-Bayering before saving as colour.
|
|
||||||
bool layoutIsBayer(PixelLayout layout);
|
|
||||||
// Bits per value as delivered: 8, or 12 for BayerRG12p. 0 for Unknown.
|
|
||||||
int layoutBits(PixelLayout layout);
|
|
||||||
// Bytes one frame of width x height takes in this layout (0 for Unknown).
|
|
||||||
size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height);
|
|
||||||
|
|
||||||
// Unpack `pixels` BayerRG12p values from `in` into one 16-bit word each
|
|
||||||
// (0..4095). `in` holds ceil(pixels * 3 / 2) bytes; an odd last pixel uses the
|
|
||||||
// low half of its pair, as GenICam specifies.
|
|
||||||
void unpack12p(const uint8_t* in, uint16_t* out, size_t pixels);
|
|
||||||
|
|
||||||
// Brightness of a frame from a sparse sample of its pixels: every `stride`-th
|
|
||||||
// pixel, so all colour sites of a mosaic are visited. Cheap enough to run on
|
|
||||||
// every frame the camera delivers (a few thousand samples).
|
|
||||||
// mean on a 0..255 scale whatever the bit depth (12-bit values / 16)
|
|
||||||
// clipped share of samples at or near white (>= 250 on that scale)
|
|
||||||
struct BrightnessSample {
|
|
||||||
double mean = -1.0;
|
|
||||||
double clipped = -1.0;
|
|
||||||
size_t samples = 0;
|
|
||||||
bool valid() const { return samples > 0; }
|
|
||||||
};
|
|
||||||
BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayout layout,
|
|
||||||
uint32_t width, uint32_t height, size_t stride = 997);
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <deque>
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
// What the scheduler needs to know about one delivered frame to judge whether
|
|
||||||
// the camera's auto-exposure has settled: its place in the stream, its
|
|
||||||
// brightness (sampled from the pixels, 0..255, negative if unknown) and the
|
|
||||||
// exposure and gain it was taken with (chunk data, negative if unknown).
|
|
||||||
struct FrameStats {
|
|
||||||
uint64_t seq = 0;
|
|
||||||
double brightness = -1.0;
|
|
||||||
double exposure_us = -1.0;
|
|
||||||
double gain_db = -1.0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// When has the image settled after the gimbal stops? Measured on 24 forced
|
|
||||||
// exposure steps (docs/raw12-pipeline-measurements.md, section 3.3 "Keep rules"):
|
|
||||||
//
|
|
||||||
// "exposure and gain unchanged for 3 frames" right 0 of 24 times
|
|
||||||
// "brightness in the camera's target band, 2 frames" right 12 of 24
|
|
||||||
// "in the band AND steady (<= 2% change) for 3 frames" right 21 of 24 <- this
|
|
||||||
//
|
|
||||||
// Auto-exposure moves in steps and pauses mid-way, so values that hold still
|
|
||||||
// prove nothing; and coming down from too bright, brightness enters the band
|
|
||||||
// ~15% above where it settles, so "in band" alone fires early. The band is the
|
|
||||||
// camera's own: IntensityControllerTarget +- IntensityControllerTolerance (in
|
|
||||||
// percent of full scale), 50 +- 5 by default.
|
|
||||||
//
|
|
||||||
// A second way to finish: brightness is out of the band but steady, and exposure
|
|
||||||
// and gain are at the limits that would move it towards the band. Waiting cannot
|
|
||||||
// help then (a scene too dark or too bright for the camera's limits).
|
|
||||||
class SettleRule {
|
|
||||||
public:
|
|
||||||
struct Params {
|
|
||||||
double target_pct = 50.0; // IntensityControllerTarget
|
|
||||||
double tolerance_pct = 5.0; // IntensityControllerTolerance
|
|
||||||
int frames = 3; // consecutive frames that must agree
|
|
||||||
double steady = 0.02; // largest frame-to-frame brightness change
|
|
||||||
// Auto-exposure limits; negative = unknown, and the limit test is off.
|
|
||||||
double exposure_min_us = -1.0;
|
|
||||||
double exposure_max_us = -1.0;
|
|
||||||
double gain_min_db = -1.0;
|
|
||||||
double gain_max_db = -1.0;
|
|
||||||
};
|
|
||||||
|
|
||||||
enum class Verdict { Waiting, Settled, AtLimit };
|
|
||||||
|
|
||||||
SettleRule() = default;
|
|
||||||
explicit SettleRule(const Params& p) : p_(p) {}
|
|
||||||
|
|
||||||
void setParams(const Params& p) { p_ = p; reset(); }
|
|
||||||
const Params& params() const { return p_; }
|
|
||||||
|
|
||||||
// Start over (the gimbal stopped at a new waypoint).
|
|
||||||
void reset() { window_.clear(); }
|
|
||||||
|
|
||||||
// Feed the next frame, in arrival order. A frame without a brightness
|
|
||||||
// breaks the run of agreeing frames.
|
|
||||||
Verdict add(const FrameStats& f);
|
|
||||||
|
|
||||||
// The band on the 0..255 scale frames are sampled on.
|
|
||||||
double bandLow() const { return (p_.target_pct - p_.tolerance_pct) * 2.55; }
|
|
||||||
double bandHigh() const { return (p_.target_pct + p_.tolerance_pct) * 2.55; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool steady() const;
|
|
||||||
bool atLimit() const;
|
|
||||||
|
|
||||||
Params p_;
|
|
||||||
std::deque<FrameStats> window_; // the last p_.frames frames
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
|
|
@ -38,9 +38,6 @@ public:
|
||||||
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;
|
||||||
bool setFeature(int camera_index, const std::string& name, const std::string& value) override;
|
bool setFeature(int camera_index, const std::string& name, const std::string& value) override;
|
||||||
uint64_t frameSeq() override;
|
|
||||||
std::vector<FrameStats> framesSince(uint64_t after) override;
|
|
||||||
bool readFeature(int camera_index, const std::string& name, double& value) override;
|
|
||||||
bool featureRange(int camera_index, const std::string& name, double& min, double& max) override;
|
bool featureRange(int camera_index, const std::string& name, double& min, double& max) override;
|
||||||
bool restoreProfile(int camera_index) override;
|
bool restoreProfile(int camera_index) override;
|
||||||
bool resetCamera(int camera_index) override;
|
bool resetCamera(int camera_index) override;
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,14 @@
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
// Synthetic camera for development without hardware. On trigger() it generates
|
// Synthetic camera for development without hardware. On trigger() it generates
|
||||||
// a small gradient frame (varying with time so successive captures differ) and
|
// a small RGB gradient frame (varying with time so successive captures differ)
|
||||||
// delivers it to the frame callback - as RGB8 by default, or as the raw mosaic
|
// and delivers it to the frame callback. setProfile() only records the profile
|
||||||
// (BayerRG8, BayerRG12p) a raw-capture profile would deliver. setProfile() only
|
// name - there is no sensor to configure - which is enough to exercise profile
|
||||||
// records the profile name - there is no sensor to configure - which is enough
|
// switching end to end with --mock-camera.
|
||||||
// to exercise profile switching end to end with --mock-camera.
|
|
||||||
class MockCameraSource : public ICameraSource {
|
class MockCameraSource : public ICameraSource {
|
||||||
public:
|
public:
|
||||||
explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480,
|
explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480)
|
||||||
PixelLayout layout = PixelLayout::RGB8)
|
: count_(count), width_(width), height_(height) {}
|
||||||
: count_(count), width_(width), height_(height), layout_(layout) {}
|
|
||||||
|
|
||||||
void open() override { LOG_INFO << "[mock] camera opened (" << count_ << ")"; }
|
void open() override { LOG_INFO << "[mock] camera opened (" << count_ << ")"; }
|
||||||
void close() override {}
|
void close() override {}
|
||||||
|
|
@ -40,28 +38,6 @@ public:
|
||||||
}
|
}
|
||||||
|
|
||||||
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
|
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
|
||||||
|
|
||||||
// A free-running stream whose auto-exposure has settled: every call to
|
|
||||||
// framesSince() "delivers" one more frame at the camera's target brightness
|
|
||||||
// (50% of full scale), so the keep rule settles after a few scheduler ticks.
|
|
||||||
uint64_t frameSeq() override { return stream_seq_; }
|
|
||||||
std::vector<FrameStats> framesSince(uint64_t after) override {
|
|
||||||
++stream_seq_;
|
|
||||||
std::vector<FrameStats> out;
|
|
||||||
for (uint64_t s = after + 1; s <= stream_seq_; ++s)
|
|
||||||
out.push_back(FrameStats{s, 127.5, 10000.0, 6.0});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
bool readFeature(int camera_index, const std::string& name, double& value) override {
|
|
||||||
if (camera_index < 0 || camera_index >= count_) return false;
|
|
||||||
if (name == "IntensityControllerTarget") { value = 50.0; return true; }
|
|
||||||
if (name == "IntensityControllerTolerance") { value = 5.0; return true; }
|
|
||||||
if (name == "ExposureAutoMin") { value = 82.0; return true; }
|
|
||||||
if (name == "ExposureAutoMax") { value = 20000.0; return true; }
|
|
||||||
if (name == "GainAutoMin") { value = 0.0; return true; }
|
|
||||||
if (name == "GainAutoMax") { value = 24.0; return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
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 {
|
||||||
|
|
@ -125,7 +101,7 @@ public:
|
||||||
d.streaming = started_;
|
d.streaming = started_;
|
||||||
d.width = width_;
|
d.width = width_;
|
||||||
d.height = height_;
|
d.height = height_;
|
||||||
d.payload_bytes = static_cast<long long>(layoutFrameBytes(layout_, width_, height_));
|
d.payload_bytes = static_cast<long long>(width_) * height_ * 3;
|
||||||
d.actual_fps = 1.0;
|
d.actual_fps = 1.0;
|
||||||
d.frames_delivered = (i == 0) ? frames_ : 0; // only cam0 produces frames
|
d.frames_delivered = (i == 0) ? frames_ : 0; // only cam0 produces frames
|
||||||
out.push_back(std::move(d));
|
out.push_back(std::move(d));
|
||||||
|
|
@ -176,46 +152,12 @@ private:
|
||||||
f.data[i + 2] = clamp8(level + tex * 0.6);
|
f.data[i + 2] = clamp8(level + tex * 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.layout = PixelLayout::RGB8;
|
|
||||||
if (layoutIsBayer(layout_)) f = mosaic(f);
|
|
||||||
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, f.width, f.height);
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The RGB frame as the sensor would see it: each pixel keeps only the colour
|
|
||||||
// of its filter (BayerRG: R G / G B), 8-bit or packed 12-bit.
|
|
||||||
Frame mosaic(const Frame& rgb) const {
|
|
||||||
Frame f = rgb;
|
|
||||||
f.layout = layout_;
|
|
||||||
f.channels = 1;
|
|
||||||
const size_t n = static_cast<size_t>(width_) * height_;
|
|
||||||
std::vector<uint16_t> m(n);
|
|
||||||
for (uint32_t y = 0; y < height_; ++y)
|
|
||||||
for (uint32_t x = 0; x < width_; ++x) {
|
|
||||||
const int c = (y % 2 == 0) ? (x % 2 == 0 ? 0 : 1) : (x % 2 == 0 ? 1 : 2);
|
|
||||||
m[static_cast<size_t>(y) * width_ + x] =
|
|
||||||
rgb.data[(static_cast<size_t>(y) * width_ + x) * 3 + c];
|
|
||||||
}
|
|
||||||
f.data.assign(layoutFrameBytes(layout_, width_, height_), 0);
|
|
||||||
if (layout_ == PixelLayout::BayerRG8) {
|
|
||||||
for (size_t i = 0; i < n; ++i) f.data[i] = static_cast<uint8_t>(m[i]);
|
|
||||||
} else { // BayerRG12p: the 8-bit value << 4, packed two pixels in three bytes
|
|
||||||
for (size_t i = 0, o = 0; i < n; i += 2, o += 3) {
|
|
||||||
const uint16_t p0 = static_cast<uint16_t>(m[i] << 4);
|
|
||||||
const uint16_t p1 = i + 1 < n ? static_cast<uint16_t>(m[i + 1] << 4) : 0;
|
|
||||||
f.data[o] = static_cast<uint8_t>(p0 & 0xFF);
|
|
||||||
f.data[o + 1] = static_cast<uint8_t>(((p0 >> 8) & 0x0F) | ((p1 & 0x0F) << 4));
|
|
||||||
if (o + 2 < f.data.size()) f.data[o + 2] = static_cast<uint8_t>(p1 >> 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
int count_;
|
int count_;
|
||||||
uint32_t width_;
|
uint32_t width_;
|
||||||
uint32_t height_;
|
uint32_t height_;
|
||||||
PixelLayout layout_;
|
|
||||||
uint64_t stream_seq_ = 1;
|
|
||||||
bool started_ = false;
|
bool started_ = false;
|
||||||
long long frames_ = 0;
|
long long frames_ = 0;
|
||||||
std::string profile_;
|
std::string profile_;
|
||||||
|
|
|
||||||
|
|
@ -47,62 +47,44 @@ except ImportError:
|
||||||
# read-only - until it is off. A feature the camera does not have is reported
|
# read-only - until it is off. A feature the camera does not have is reported
|
||||||
# and skipped, not fatal: the fleet runs several Alvium generations and their
|
# and skipped, not fatal: the fleet runs several Alvium generations and their
|
||||||
# feature vocabularies differ.
|
# feature vocabularies differ.
|
||||||
# Raw capture (docs/raw12-pipeline-measurements.md): full resolution, packed
|
|
||||||
# 12-bit Bayer, de-Bayered on the tower. At the 250 MB/s link cap that is
|
|
||||||
# 9.86 fps on the 16 MP 1800 U-1620c (RGB8: 4.95), so auto-exposure settles in
|
|
||||||
# about half the time, with no lost frames measured on either computer.
|
|
||||||
# Binning goes to 1 vertical-first (the horizontal minimum depends on it), and
|
|
||||||
# Width/Height ask for more than any sensor so the clamp lands on the maximum.
|
|
||||||
# The per-frame chunk data is what the keep rule reads; each item must be
|
|
||||||
# enabled before chunk mode itself, which locks them.
|
|
||||||
_RAW_COMMON = [
|
|
||||||
("TriggerSelector", "FrameStart"),
|
|
||||||
("TriggerMode", "Off"),
|
|
||||||
("BinningVertical", 1),
|
|
||||||
("BinningHorizontal", 1),
|
|
||||||
("OffsetX", 0),
|
|
||||||
("OffsetY", 0),
|
|
||||||
("Width", 100000),
|
|
||||||
("Height", 100000),
|
|
||||||
("PixelFormat", "BayerRG12p"),
|
|
||||||
("DeviceLinkThroughputLimit", 250000000),
|
|
||||||
("AcquisitionFrameRateEnable", True),
|
|
||||||
("AcquisitionFrameRate", 1000.0), # clamped to the camera's maximum
|
|
||||||
("ChunkModeActive", False),
|
|
||||||
("ChunkSelector", "ExposureTime"),
|
|
||||||
("ChunkEnable", True),
|
|
||||||
("ChunkSelector", "Gain"),
|
|
||||||
("ChunkEnable", True),
|
|
||||||
("ChunkSelector", "BalanceRatioRed"),
|
|
||||||
("ChunkEnable", True),
|
|
||||||
("ChunkSelector", "BalanceRatioBlue"),
|
|
||||||
("ChunkEnable", True),
|
|
||||||
("ChunkSelector", "Timestamp"),
|
|
||||||
("ChunkEnable", True),
|
|
||||||
("ChunkModeActive", True),
|
|
||||||
("BalanceWhiteAuto", "Off"), # fixed ratios, as before
|
|
||||||
("ExposureAuto", "Continuous"),
|
|
||||||
("GainAuto", "Continuous"),
|
|
||||||
("IntensityControllerRate", 100), # fastest
|
|
||||||
]
|
|
||||||
|
|
||||||
RECIPES = {
|
RECIPES = {
|
||||||
# Daylight. Exposure capped at 20 ms; the gimbal is at rest when a frame is
|
# Daylight. 2x2 binning is not optional at 10 Hz: unbinned RGB8 on a 16 MP
|
||||||
# kept, so noise matters more than motion blur.
|
# sensor is a 48 MB frame, which is both over the USB ceiling in
|
||||||
"standard": _RAW_COMMON + [
|
# known-issues #19 and far too slow to reach 10 fps under any throughput
|
||||||
|
# limit this camera offers.
|
||||||
|
"standard": [
|
||||||
|
("TriggerSelector", "FrameStart"),
|
||||||
|
("TriggerMode", "Off"),
|
||||||
|
("BinningHorizontal", 2),
|
||||||
|
("BinningVertical", 2),
|
||||||
|
("PixelFormat", "RGB8"),
|
||||||
|
("DeviceLinkThroughputLimit", 250000000),
|
||||||
|
("AcquisitionFrameRateEnable", True),
|
||||||
|
("AcquisitionFrameRate", 10.0),
|
||||||
|
("ExposureAuto", "Continuous"),
|
||||||
("ExposureAutoMin", 200.0),
|
("ExposureAutoMin", 200.0),
|
||||||
("ExposureAutoMax", 20000.0),
|
("ExposureAutoMax", 20000.0),
|
||||||
|
("GainAuto", "Continuous"),
|
||||||
("GainAutoMax", 24.0),
|
("GainAutoMax", 24.0),
|
||||||
("IntensityAutoPrecedence", "MinimizeNoise"),
|
("IntensityAutoPrecedence", "MinimizeBlur"),
|
||||||
("DeviceIndicatorMode", "Active"),
|
("DeviceIndicatorMode", "Active"),
|
||||||
("DeviceIndicatorLuminance", 10),
|
("DeviceIndicatorLuminance", 10),
|
||||||
],
|
],
|
||||||
# Night: long exposures (the frame rate then follows the exposure), more gain
|
# Night: 1 Hz, long exposures, more gain allowed, noise over motion blur,
|
||||||
# allowed, and the status LED off so it cannot contaminate a long exposure.
|
# and the status LED off so it cannot contaminate a long exposure.
|
||||||
# 12 bits matter most here: deep shadows are where they carry information.
|
"night": [
|
||||||
"night": _RAW_COMMON + [
|
("TriggerSelector", "FrameStart"),
|
||||||
|
("TriggerMode", "Off"),
|
||||||
|
("BinningHorizontal", 2),
|
||||||
|
("BinningVertical", 2),
|
||||||
|
("PixelFormat", "RGB8"),
|
||||||
|
("DeviceLinkThroughputLimit", 250000000),
|
||||||
|
("AcquisitionFrameRateEnable", True),
|
||||||
|
("AcquisitionFrameRate", 1.0),
|
||||||
|
("ExposureAuto", "Continuous"),
|
||||||
("ExposureAutoMin", 1000.0),
|
("ExposureAutoMin", 1000.0),
|
||||||
("ExposureAutoMax", 500000.0),
|
("ExposureAutoMax", 500000.0),
|
||||||
|
("GainAuto", "Continuous"),
|
||||||
("GainAutoMax", 48.0),
|
("GainAutoMax", 48.0),
|
||||||
("IntensityAutoPrecedence", "MinimizeNoise"),
|
("IntensityAutoPrecedence", "MinimizeNoise"),
|
||||||
("DeviceIndicatorMode", "Inactive"),
|
("DeviceIndicatorMode", "Inactive"),
|
||||||
|
|
@ -198,11 +180,13 @@ def main():
|
||||||
w = cam.get_feature_by_name("Width").get()
|
w = cam.get_feature_by_name("Width").get()
|
||||||
h = cam.get_feature_by_name("Height").get()
|
h = cam.get_feature_by_name("Height").get()
|
||||||
fmt = cam.get_feature_by_name("PixelFormat").get()
|
fmt = cam.get_feature_by_name("PixelFormat").get()
|
||||||
bpp = {"RGB8": 3, "BGR8": 3, "BayerRG12p": 1.5, "BayerRG12": 2}.get(str(fmt), 1)
|
bpp = 3 if str(fmt) in ("RGB8", "BGR8") else 1
|
||||||
rate = cam.get_feature_by_name("AcquisitionFrameRate").get()
|
rate = cam.get_feature_by_name("AcquisitionFrameRate").get()
|
||||||
mb = w * h * bpp / 1e6
|
mb = w * h * bpp / 1e6
|
||||||
print(f"\nresult : {w}x{h} {fmt} {mb:.1f} MB/frame "
|
print(f"\nresult : {w}x{h} {fmt} {mb:.1f} MB/frame "
|
||||||
f"{rate:.2f} fps = {mb * rate:.0f} MB/s")
|
f"{rate:.2f} fps = {mb * rate:.0f} MB/s")
|
||||||
|
if mb > 20:
|
||||||
|
print("WARNING : over the ~20 MB/frame ceiling in known-issues #19")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
#include "fgc/JpegXlEncoder.h"
|
#include "fgc/JpegXlEncoder.h"
|
||||||
#include "fgc/Logger.h"
|
#include "fgc/Logger.h"
|
||||||
#include "fgc/RawImage.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
|
||||||
#include <opencv2/core.hpp>
|
#include <opencv2/core.hpp>
|
||||||
|
|
@ -37,26 +35,13 @@ ImagePipeline::~ImagePipeline() { stop(); }
|
||||||
|
|
||||||
void ImagePipeline::start() {
|
void ImagePipeline::start() {
|
||||||
if (running_.exchange(true)) return;
|
if (running_.exchange(true)) return;
|
||||||
const int n = std::max(1, params_.workers);
|
worker_ = std::thread(&ImagePipeline::run, this);
|
||||||
for (int i = 0; i < n; ++i) workers_.emplace_back(&ImagePipeline::run, this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImagePipeline::stop() {
|
void ImagePipeline::stop() {
|
||||||
if (!running_.exchange(false)) return;
|
if (!running_.exchange(false)) return;
|
||||||
cv_.notify_all();
|
cv_.notify_all();
|
||||||
for (auto& w : workers_)
|
if (worker_.joinable()) worker_.join();
|
||||||
if (w.joinable()) w.join();
|
|
||||||
workers_.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ImagePipeline::canAccept() const {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
return queue_.size() + in_flight_ < static_cast<size_t>(std::max(1, params_.queue_max));
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t ImagePipeline::pending() const {
|
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
return queue_.size() + in_flight_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ImagePipeline::submit(const Frame& frame) {
|
void ImagePipeline::submit(const Frame& frame) {
|
||||||
|
|
@ -80,11 +65,8 @@ void ImagePipeline::run() {
|
||||||
if (!running_ && queue_.empty()) break;
|
if (!running_ && queue_.empty()) break;
|
||||||
queued = std::move(queue_.front());
|
queued = std::move(queue_.front());
|
||||||
queue_.pop();
|
queue_.pop();
|
||||||
++in_flight_;
|
|
||||||
}
|
}
|
||||||
process(queued);
|
process(queued);
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
|
||||||
--in_flight_;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,18 +81,22 @@ const ImagePipeline::CameraOutput& ImagePipeline::outputFor(int cam_id) const {
|
||||||
|
|
||||||
void ImagePipeline::process(const QueuedFrame& queued) {
|
void ImagePipeline::process(const QueuedFrame& queued) {
|
||||||
const Frame& frame = queued.frame;
|
const Frame& frame = queued.frame;
|
||||||
const bool known = frame.layout != PixelLayout::Unknown;
|
if (frame.channels != 1 && frame.channels != 3) {
|
||||||
if (!known && frame.channels != 1 && frame.channels != 3) {
|
|
||||||
LOG_WARN << "Dropping frame with unsupported channel count " << frame.channels;
|
LOG_WARN << "Dropping frame with unsupported channel count " << frame.channels;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (known && frame.data.size() < layoutFrameBytes(frame.layout, frame.width, frame.height)) {
|
|
||||||
LOG_WARN << "Dropping " << layoutName(frame.layout) << " frame: " << frame.data.size()
|
|
||||||
<< " bytes is short of " << frame.width << 'x' << frame.height;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CameraOutput& out = outputFor(frame.cam_id);
|
const CameraOutput& out = outputFor(frame.cam_id);
|
||||||
|
|
||||||
|
const int type = frame.channels == 1 ? CV_8UC1 : CV_8UC3;
|
||||||
|
cv::Mat src(frame.height, frame.width, type, const_cast<uint8_t*>(frame.data.data()));
|
||||||
|
cv::Mat rotated;
|
||||||
|
int code = 0;
|
||||||
|
bool rotate = rotateCode(out.rotation_deg, code);
|
||||||
|
if (rotate) cv::rotate(src, rotated, code);
|
||||||
|
// src only wraps frame.data, so an unrotated frame costs no copy at all.
|
||||||
|
const cv::Mat& img = rotate ? rotated : src;
|
||||||
|
|
||||||
const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id))
|
const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id))
|
||||||
: out.label;
|
: out.label;
|
||||||
const fs::path dir = fs::path(params_.output_dir) / label;
|
const fs::path dir = fs::path(params_.output_dir) / label;
|
||||||
|
|
@ -121,7 +107,12 @@ void ImagePipeline::process(const QueuedFrame& queued) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
|
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
|
||||||
if (!save(frame, out, file.string())) return;
|
|
||||||
|
if (!JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows,
|
||||||
|
frame.channels, out.jxl_distance, out.jxl_effort)) {
|
||||||
|
LOG_ERROR << "Failed to encode " << file.string();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sampled at submit(), not here: by this point the encode above has run and
|
// Sampled at submit(), not here: by this point the encode above has run and
|
||||||
// the gimbal may already be slewing to the next waypoint.
|
// the gimbal may already be slewing to the next waypoint.
|
||||||
|
|
@ -139,50 +130,7 @@ void ImagePipeline::process(const QueuedFrame& queued) {
|
||||||
}
|
}
|
||||||
|
|
||||||
saved_.fetch_add(1);
|
saved_.fetch_add(1);
|
||||||
LOG_DEBUG << "Saved " << file.string() << " (" << frame.width << 'x' << frame.height << ' '
|
LOG_DEBUG << "Saved " << file.string() << " (" << img.cols << "x" << img.rows << ")";
|
||||||
<< (known ? layoutName(frame.layout) : "legacy") << ')';
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file) {
|
|
||||||
int code = 0;
|
|
||||||
bool rotate = rotateCode(out.rotation_deg, code);
|
|
||||||
const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width);
|
|
||||||
auto* data = const_cast<uint8_t*>(frame.data.data());
|
|
||||||
|
|
||||||
cv::Mat img; // what gets encoded: 8-bit (CV_8U) or 12-bit values in CV_16U
|
|
||||||
int bits = 8;
|
|
||||||
if (frame.layout == PixelLayout::BayerRG8 || frame.layout == PixelLayout::BayerRG12p) {
|
|
||||||
cv::Mat mosaic;
|
|
||||||
if (frame.layout == PixelLayout::BayerRG8) {
|
|
||||||
mosaic = cv::Mat(h, w, CV_8UC1, data);
|
|
||||||
} else {
|
|
||||||
mosaic.create(h, w, CV_16UC1);
|
|
||||||
unpack12p(frame.data.data(), reinterpret_cast<uint16_t*>(mosaic.data),
|
|
||||||
static_cast<size_t>(w) * h);
|
|
||||||
bits = 12;
|
|
||||||
}
|
|
||||||
// The camera's BayerRG is OpenCV's BayerBG (see RawImage.h).
|
|
||||||
cv::cvtColor(mosaic, img,
|
|
||||||
out.edge_aware ? cv::COLOR_BayerBG2RGB_EA : cv::COLOR_BayerBG2RGB);
|
|
||||||
} else {
|
|
||||||
// RGB8, Mono8, or a legacy frame described by its channel count. The
|
|
||||||
// Mat only wraps frame.data, so an unrotated frame costs no copy.
|
|
||||||
img = cv::Mat(h, w, frame.channels == 3 ? CV_8UC3 : CV_8UC1, data);
|
|
||||||
}
|
|
||||||
if (rotate) {
|
|
||||||
cv::Mat rotated;
|
|
||||||
cv::rotate(img, rotated, code);
|
|
||||||
img = rotated;
|
|
||||||
}
|
|
||||||
if (!img.isContinuous()) img = img.clone();
|
|
||||||
|
|
||||||
if (!JpegXlEncoder::encodeToFile(file, img.data, img.cols, img.rows, img.channels(), bits,
|
|
||||||
static_cast<float>(out.jxl_distance), out.jxl_effort,
|
|
||||||
params_.jxl_threads)) {
|
|
||||||
LOG_ERROR << "Failed to encode " << file;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<CamEvent> ImagePipeline::lastEvent() const {
|
std::optional<CamEvent> ImagePipeline::lastEvent() const {
|
||||||
|
|
|
||||||
|
|
@ -12,43 +12,28 @@
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
bool JpegXlEncoder::encodeToFile(const std::string& path, const void* img, int width, int height,
|
bool JpegXlEncoder::encodeToFile(const std::string& path, const uint8_t* img, int width,
|
||||||
int channels, int bits, float distance, int effort,
|
int height, int channels, float distance, int effort,
|
||||||
int threads) {
|
int threads) {
|
||||||
if (!img || width <= 0 || height <= 0 || (channels != 1 && channels != 3) || bits < 8 ||
|
auto enc = JxlEncoderMake(nullptr);
|
||||||
bits > 16 || distance < 0.0f) {
|
auto runner = JxlThreadParallelRunnerMake(nullptr, threads);
|
||||||
LOG_ERROR << "JpegXlEncoder: bad arguments (" << width << 'x' << height << ", "
|
if (JXL_ENC_SUCCESS !=
|
||||||
<< channels << " channels, " << bits << " bits, distance " << distance << ')';
|
JxlEncoderSetParallelRunner(enc.get(), JxlThreadParallelRunner, runner.get())) {
|
||||||
|
LOG_ERROR << "JxlEncoderSetParallelRunner failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const bool lossless = distance == 0.0f;
|
|
||||||
const bool wide = bits > 8;
|
|
||||||
|
|
||||||
auto enc = JxlEncoderMake(nullptr);
|
JxlPixelFormat pixel_format = {static_cast<uint32_t>(channels), JXL_TYPE_UINT8,
|
||||||
JxlThreadParallelRunnerPtr runner;
|
JXL_NATIVE_ENDIAN, 0};
|
||||||
if (threads > 0) {
|
|
||||||
runner = JxlThreadParallelRunnerMake(nullptr, static_cast<size_t>(threads));
|
|
||||||
if (JXL_ENC_SUCCESS !=
|
|
||||||
JxlEncoderSetParallelRunner(enc.get(), JxlThreadParallelRunner, runner.get())) {
|
|
||||||
LOG_ERROR << "JxlEncoderSetParallelRunner failed";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
JxlPixelFormat pixel_format = {static_cast<uint32_t>(channels),
|
|
||||||
wide ? JXL_TYPE_UINT16 : JXL_TYPE_UINT8, JXL_NATIVE_ENDIAN, 0};
|
|
||||||
|
|
||||||
JxlBasicInfo basic_info;
|
JxlBasicInfo basic_info;
|
||||||
JxlEncoderInitBasicInfo(&basic_info);
|
JxlEncoderInitBasicInfo(&basic_info);
|
||||||
basic_info.xsize = width;
|
basic_info.xsize = width;
|
||||||
basic_info.ysize = height;
|
basic_info.ysize = height;
|
||||||
basic_info.alpha_bits = 0;
|
basic_info.alpha_bits = 0;
|
||||||
basic_info.bits_per_sample = static_cast<uint32_t>(bits);
|
basic_info.bits_per_sample = 8;
|
||||||
basic_info.num_color_channels = channels;
|
basic_info.num_color_channels = channels;
|
||||||
// Lossless requires the original colour space; with it off, libjxl encodes
|
basic_info.uses_original_profile = JXL_FALSE;
|
||||||
// through its lossy XYB space, reports success, and the pixels come back
|
|
||||||
// changed (known-issues #20). Lossy keeps XYB, as the program always has.
|
|
||||||
basic_info.uses_original_profile = lossless ? JXL_TRUE : JXL_FALSE;
|
|
||||||
if (JXL_ENC_SUCCESS != JxlEncoderSetBasicInfo(enc.get(), &basic_info)) {
|
if (JXL_ENC_SUCCESS != JxlEncoderSetBasicInfo(enc.get(), &basic_info)) {
|
||||||
LOG_ERROR << "JxlEncoderSetBasicInfo failed";
|
LOG_ERROR << "JxlEncoderSetBasicInfo failed";
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -64,26 +49,17 @@ bool JpegXlEncoder::encodeToFile(const std::string& path, const void* img, int w
|
||||||
JxlEncoderFrameSettings* fs = JxlEncoderFrameSettingsCreate(enc.get(), nullptr);
|
JxlEncoderFrameSettings* fs = JxlEncoderFrameSettingsCreate(enc.get(), nullptr);
|
||||||
JxlEncoderFrameSettingsSetOption(fs, JXL_ENC_FRAME_SETTING_EFFORT, effort);
|
JxlEncoderFrameSettingsSetOption(fs, JXL_ENC_FRAME_SETTING_EFFORT, effort);
|
||||||
JxlEncoderFrameSettingsSetOption(fs, JXL_ENC_FRAME_SETTING_DECODING_SPEED, 0);
|
JxlEncoderFrameSettingsSetOption(fs, JXL_ENC_FRAME_SETTING_DECODING_SPEED, 0);
|
||||||
if (lossless) {
|
if (distance == 0.0f) {
|
||||||
JxlEncoderSetFrameDistance(fs, 0);
|
JxlEncoderSetFrameDistance(fs, 0);
|
||||||
JxlEncoderSetFrameLossless(fs, JXL_TRUE);
|
JxlEncoderSetFrameLossless(fs, JXL_TRUE);
|
||||||
} else {
|
} else {
|
||||||
JxlEncoderSetFrameLossless(fs, JXL_FALSE);
|
JxlEncoderSetFrameLossless(fs, JXL_FALSE);
|
||||||
JxlEncoderSetFrameDistance(fs, distance);
|
JxlEncoderSetFrameDistance(fs, distance);
|
||||||
}
|
}
|
||||||
if (wide) {
|
|
||||||
// The 16-bit words hold `bits`-bit values: read them at the header's
|
|
||||||
// depth, not as the full 0..65535 range.
|
|
||||||
JxlBitDepth depth = {JXL_BIT_DEPTH_FROM_CODESTREAM, 0, 0};
|
|
||||||
if (JXL_ENC_SUCCESS != JxlEncoderSetFrameBitDepth(fs, &depth)) {
|
|
||||||
LOG_ERROR << "JxlEncoderSetFrameBitDepth failed";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const size_t byte_size =
|
const size_t byte_size = static_cast<size_t>(width) * height * channels;
|
||||||
static_cast<size_t>(width) * height * channels * (wide ? 2 : 1);
|
if (JXL_ENC_SUCCESS !=
|
||||||
if (JXL_ENC_SUCCESS != JxlEncoderAddImageFrame(fs, &pixel_format, img, byte_size)) {
|
JxlEncoderAddImageFrame(fs, &pixel_format, img, byte_size)) {
|
||||||
LOG_ERROR << "JxlEncoderAddImageFrame failed";
|
LOG_ERROR << "JxlEncoderAddImageFrame failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -115,10 +91,7 @@ bool JpegXlEncoder::encodeToFile(const std::string& path, const void* img, int w
|
||||||
}
|
}
|
||||||
bool ok = std::fwrite(compressed.data(), 1, compressed.size(), file) == compressed.size();
|
bool ok = std::fwrite(compressed.data(), 1, compressed.size(), file) == compressed.size();
|
||||||
if (std::fclose(file) != 0) ok = false;
|
if (std::fclose(file) != 0) ok = false;
|
||||||
if (!ok) {
|
if (!ok) LOG_ERROR << "Could not write " << path;
|
||||||
LOG_ERROR << "Could not write " << path;
|
|
||||||
std::remove(path.c_str());
|
|
||||||
}
|
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <deque>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
@ -41,17 +40,6 @@ long long nowMs() {
|
||||||
// Cumulative per-camera acquisition stats. Lives in Impl (NOT the observer) so it
|
// Cumulative per-camera acquisition stats. Lives in Impl (NOT the observer) so it
|
||||||
// survives the restart-on-stall, which recreates the observer each session.
|
// survives the restart-on-stall, which recreates the observer each session.
|
||||||
struct CamStats {
|
struct CamStats {
|
||||||
// The last frames' brightness, exposure and gain, for the keep rule.
|
|
||||||
std::mutex recent_mutex;
|
|
||||||
std::deque<FrameStats> recent;
|
|
||||||
uint64_t seq = 0;
|
|
||||||
void pushRecent(const Frame& f) {
|
|
||||||
std::lock_guard<std::mutex> lock(recent_mutex);
|
|
||||||
recent.push_back(FrameStats{++seq, f.brightness.valid() ? f.brightness.mean : -1.0,
|
|
||||||
f.exposure_us, f.gain_db});
|
|
||||||
while (recent.size() > 64) recent.pop_front();
|
|
||||||
}
|
|
||||||
|
|
||||||
std::atomic<long long> delivered{0};
|
std::atomic<long long> delivered{0};
|
||||||
std::atomic<long long> dropped{0};
|
std::atomic<long long> dropped{0};
|
||||||
std::atomic<long long> restarts{0};
|
std::atomic<long long> restarts{0};
|
||||||
|
|
@ -60,17 +48,6 @@ struct CamStats {
|
||||||
std::atomic<long long> last_bytes{0};
|
std::atomic<long long> last_bytes{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
// The program's name for a delivered pixel format; Unknown for anything else.
|
|
||||||
PixelLayout layoutOf(VmbPixelFormatType f) {
|
|
||||||
switch (f) {
|
|
||||||
case VmbPixelFormatMono8: return PixelLayout::Mono8;
|
|
||||||
case VmbPixelFormatRgb8: return PixelLayout::RGB8;
|
|
||||||
case VmbPixelFormatBayerRG8: return PixelLayout::BayerRG8;
|
|
||||||
case VmbPixelFormatBayerRG12p: return PixelLayout::BayerRG12p;
|
|
||||||
default: return PixelLayout::Unknown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -108,30 +85,10 @@ public:
|
||||||
Frame f;
|
Frame f;
|
||||||
f.width = w;
|
f.width = w;
|
||||||
f.height = h;
|
f.height = h;
|
||||||
|
f.channels = static_cast<int>(sz / (static_cast<size_t>(w) * h));
|
||||||
f.cam_id = cam_id_;
|
f.cam_id = cam_id_;
|
||||||
f.timestamp_ms = nowMs();
|
f.timestamp_ms = nowMs();
|
||||||
VmbPixelFormatType pf = VmbPixelFormatLast;
|
f.data.assign(buf, buf + sz);
|
||||||
if (pFrame->GetPixelFormat(pf) == VmbErrorSuccess) f.layout = layoutOf(pf);
|
|
||||||
size_t bytes = sz;
|
|
||||||
if (f.layout != PixelLayout::Unknown) {
|
|
||||||
// The buffer can carry chunk data after the image; keep the image.
|
|
||||||
bytes = std::min<size_t>(sz, layoutFrameBytes(f.layout, w, h));
|
|
||||||
f.channels = f.layout == PixelLayout::RGB8 ? 3 : 1;
|
|
||||||
} else {
|
|
||||||
// A format the program does not handle: say what it holds, as
|
|
||||||
// before layouts existed, and let the pipeline refuse it.
|
|
||||||
f.channels = static_cast<int>(sz / (static_cast<size_t>(w) * h));
|
|
||||||
if (!warned_layout_) {
|
|
||||||
warned_layout_ = true;
|
|
||||||
LOG_WARN << "camera cam" << cam_id_ << ": pixel format 0x" << std::hex
|
|
||||||
<< pf << std::dec << " is not one the program handles "
|
|
||||||
<< "(Mono8, RGB8, BayerRG8, BayerRG12p)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.data.assign(buf, buf + bytes);
|
|
||||||
readChunks(pFrame, f);
|
|
||||||
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, w, h);
|
|
||||||
if (stats_) stats_->pushRecent(f);
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
latest_ = std::move(f);
|
latest_ = std::move(f);
|
||||||
|
|
@ -157,47 +114,19 @@ public:
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Exposure, gain and white balance as the camera reports them for this
|
|
||||||
// frame. Only when the profile turns chunk mode on; after the first frame
|
|
||||||
// without chunk data, stop asking for the rest of the session.
|
|
||||||
void readChunks(const FramePtr& frame, Frame& f) {
|
|
||||||
if (!chunks_available_) return;
|
|
||||||
const VmbErrorType rc = frame->AccessChunkData([&f](ChunkFeatureContainerPtr& c) {
|
|
||||||
auto num = [&c](const char* name, double& out) {
|
|
||||||
FeaturePtr feat;
|
|
||||||
double v = 0;
|
|
||||||
if (c->GetFeatureByName(name, feat) == VmbErrorSuccess &&
|
|
||||||
feat->GetValue(v) == VmbErrorSuccess)
|
|
||||||
out = v;
|
|
||||||
};
|
|
||||||
num("ChunkExposureTime", f.exposure_us);
|
|
||||||
num("ChunkGain", f.gain_db);
|
|
||||||
num("ChunkBalanceRatioRed", f.wb_red);
|
|
||||||
num("ChunkBalanceRatioBlue", f.wb_blue);
|
|
||||||
return VmbErrorSuccess;
|
|
||||||
});
|
|
||||||
if (rc != VmbErrorSuccess) chunks_available_ = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int cam_id_;
|
int cam_id_;
|
||||||
CamStats* stats_;
|
CamStats* stats_;
|
||||||
bool warned_layout_ = false;
|
|
||||||
bool chunks_available_ = true;
|
|
||||||
std::mutex mutex_;
|
std::mutex mutex_;
|
||||||
Frame latest_;
|
Frame latest_;
|
||||||
bool has_ = false;
|
bool has_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read a numeric/string feature, returning `fallback`/"" if absent (tolerant, for
|
// Read a float/string feature, returning `fallback`/"" if absent (tolerant, for telemetry).
|
||||||
// telemetry). Numeric takes an integer feature too: some are Int on one model and
|
|
||||||
// Float on another (IntensityControllerTolerance is Int on the Alvium 1800 U-1240c).
|
|
||||||
double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) {
|
double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) {
|
||||||
FeaturePtr f;
|
FeaturePtr f;
|
||||||
if (SP_ACCESS(cam)->GetFeatureByName(name, f) != VmbErrorSuccess) return fallback;
|
double v = fallback;
|
||||||
double v = fallback;
|
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess)
|
||||||
if (f->GetValue(v) == VmbErrorSuccess) return v;
|
return v;
|
||||||
VmbInt64_t i = 0;
|
|
||||||
if (f->GetValue(i) == VmbErrorSuccess) return static_cast<double>(i);
|
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
std::string getString(const CameraPtr& cam, const char* name) {
|
std::string getString(const CameraPtr& cam, const char* name) {
|
||||||
|
|
@ -302,15 +231,6 @@ struct VimbaCameraSource::Impl {
|
||||||
for (auto& cam : cameras) e = std::max(e, getFloat(cam, "ExposureTime"));
|
for (auto& cam : cameras) e = std::max(e, getFloat(cam, "ExposureTime"));
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
// Longest frame period any camera runs at (the rate it reports), 0 if unknown.
|
|
||||||
double slowestFramePeriodMs() {
|
|
||||||
double p = 0;
|
|
||||||
for (auto& cam : cameras) {
|
|
||||||
const double fps = getFloat(cam, "AcquisitionFrameRate");
|
|
||||||
if (fps > 0) p = std::max(p, 1000.0 / fps);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reboot one camera, reopen it by id and re-apply its profile. See
|
// Reboot one camera, reopen it by id and re-apply its profile. See
|
||||||
// CameraStall.h for why this exists.
|
// CameraStall.h for why this exists.
|
||||||
|
|
@ -528,15 +448,7 @@ bool VimbaCameraSource::trigger() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newest <= impl_->last_captured_ts) {
|
if (newest <= impl_->last_captured_ts) {
|
||||||
// Nothing newer than the last frame kept - but with long exposures the
|
LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition";
|
||||||
// next one may simply not be due yet. Restarting now would throw away the
|
|
||||||
// exposure in progress, so wait while that is still plausible.
|
|
||||||
const long long since_newest = nowMs() - newest;
|
|
||||||
if (onStaleFrame(since_newest, impl_->longestExposureUs(),
|
|
||||||
impl_->slowestFramePeriodMs()) == StallAction::Wait)
|
|
||||||
return false;
|
|
||||||
LOG_TRACE_CAT(LogCat::Camera) << "stream stale (" << since_newest
|
|
||||||
<< " ms since the newest frame); restarting acquisition";
|
|
||||||
for (auto& s : impl_->stats) s->restarts.fetch_add(1);
|
for (auto& s : impl_->stats) s->restarts.fetch_add(1);
|
||||||
try {
|
try {
|
||||||
impl_->stopAcquisition();
|
impl_->stopAcquisition();
|
||||||
|
|
@ -721,39 +633,6 @@ bool VimbaCameraSource::setFeature(int camera_index, const std::string& name,
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint64_t VimbaCameraSource::frameSeq() {
|
|
||||||
if (impl_->stats.empty()) return 0;
|
|
||||||
std::lock_guard<std::mutex> lock(impl_->stats[0]->recent_mutex);
|
|
||||||
return impl_->stats[0]->seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<FrameStats> VimbaCameraSource::framesSince(uint64_t after) {
|
|
||||||
std::vector<FrameStats> out;
|
|
||||||
if (impl_->stats.empty()) return out;
|
|
||||||
std::lock_guard<std::mutex> lock(impl_->stats[0]->recent_mutex);
|
|
||||||
for (const auto& f : impl_->stats[0]->recent)
|
|
||||||
if (f.seq > after) out.push_back(f);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool VimbaCameraSource::readFeature(int camera_index, const std::string& name, double& value) {
|
|
||||||
const size_t idx = static_cast<size_t>(camera_index);
|
|
||||||
if (camera_index < 0 || idx >= impl_->cameras.size()) return false;
|
|
||||||
FeaturePtr f;
|
|
||||||
VmbFeatureDataType type = VmbFeatureDataUnknown;
|
|
||||||
if (SP_ACCESS(impl_->cameras[idx])->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess ||
|
|
||||||
f->GetDataType(type) != VmbErrorSuccess)
|
|
||||||
return false;
|
|
||||||
if (type == VmbFeatureDataFloat) return f->GetValue(value) == VmbErrorSuccess;
|
|
||||||
if (type == VmbFeatureDataInt) {
|
|
||||||
VmbInt64_t v = 0;
|
|
||||||
if (f->GetValue(v) != VmbErrorSuccess) return false;
|
|
||||||
value = static_cast<double>(v);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool VimbaCameraSource::featureRange(int camera_index, const std::string& name, double& min,
|
bool VimbaCameraSource::featureRange(int camera_index, const std::string& name, double& min,
|
||||||
double& max) {
|
double& max) {
|
||||||
const size_t idx = static_cast<size_t>(camera_index);
|
const size_t idx = static_cast<size_t>(camera_index);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
#include "fgc/IImuSource.h"
|
#include "fgc/IImuSource.h"
|
||||||
#include "fgc/IMotorController.h"
|
#include "fgc/IMotorController.h"
|
||||||
#include "fgc/ImagePipeline.h"
|
#include "fgc/ImagePipeline.h"
|
||||||
#include "fgc/SettleRule.h"
|
|
||||||
#include "fgc/Logger.h"
|
#include "fgc/Logger.h"
|
||||||
#include "fgc/MountedImuSource.h"
|
#include "fgc/MountedImuSource.h"
|
||||||
#include "fgc/MtiImuSource.h"
|
#include "fgc/MtiImuSource.h"
|
||||||
|
|
@ -503,47 +502,22 @@ struct Application::Impl {
|
||||||
if (camera) camera->setProfile(index, it->second);
|
if (camera) camera->setProfile(index, it->second);
|
||||||
active_profile[cam_name] = name;
|
active_profile[cam_name] = name;
|
||||||
|
|
||||||
configureSettle();
|
// The gimbal must stand still long enough for every camera, so recompute
|
||||||
|
// from all of them rather than from the one that just changed.
|
||||||
|
if (scheduler) {
|
||||||
|
int settle = 0, timeout = 0;
|
||||||
|
for (const auto* c : cfg.camera.active()) {
|
||||||
|
const auto t = c->timingFor(active_profile.count(c->name) ? active_profile[c->name]
|
||||||
|
: c->profile);
|
||||||
|
settle = std::max(settle, t.settle_wait_ms);
|
||||||
|
timeout = std::max(timeout, t.frame_timeout_ms);
|
||||||
|
}
|
||||||
|
scheduler->setSettleWait(settle, timeout);
|
||||||
|
}
|
||||||
LOG_INFO << "camera '" << cam_name << "' profile -> " << name;
|
LOG_INFO << "camera '" << cam_name << "' profile -> " << name;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long the scan waits after standstill, and how it decides the frame is
|
|
||||||
// good to keep, from the active profiles. The gimbal must stand still long
|
|
||||||
// enough for every camera, so the waits are the longest across them; the
|
|
||||||
// keep rule is on if any active profile asks for it, judged on camera 0's
|
|
||||||
// frames against the camera's own auto-exposure target and limits.
|
|
||||||
void configureSettle() {
|
|
||||||
if (!scheduler) return;
|
|
||||||
int settle = 0, timeout = 0;
|
|
||||||
bool rule = false;
|
|
||||||
for (const auto* c : cfg.camera.active()) {
|
|
||||||
const auto t = c->timingFor(active_profile.count(c->name) ? active_profile[c->name]
|
|
||||||
: c->profile);
|
|
||||||
settle = std::max(settle, t.settle_wait_ms);
|
|
||||||
timeout = std::max(timeout, t.frame_timeout_ms);
|
|
||||||
rule = rule || t.settle_rule == "brightness";
|
|
||||||
}
|
|
||||||
scheduler->setSettleWait(settle, timeout);
|
|
||||||
SettleRule::Params p;
|
|
||||||
if (rule && camera) {
|
|
||||||
double v = 0;
|
|
||||||
if (camera->readFeature(0, "IntensityControllerTarget", v)) p.target_pct = v;
|
|
||||||
if (camera->readFeature(0, "IntensityControllerTolerance", v)) p.tolerance_pct = v;
|
|
||||||
if (camera->readFeature(0, "ExposureAutoMin", v)) p.exposure_min_us = v;
|
|
||||||
if (camera->readFeature(0, "ExposureAutoMax", v)) p.exposure_max_us = v;
|
|
||||||
if (camera->readFeature(0, "GainAutoMin", v)) p.gain_min_db = v;
|
|
||||||
if (camera->readFeature(0, "GainAutoMax", v)) p.gain_max_db = v;
|
|
||||||
}
|
|
||||||
scheduler->setSettleRule(rule, p);
|
|
||||||
if (rule)
|
|
||||||
LOG_INFO << "keep rule: brightness in " << p.target_pct << " +- " << p.tolerance_pct
|
|
||||||
<< " % and steady for " << p.frames << " frames, after " << settle
|
|
||||||
<< " ms, within " << timeout << " ms";
|
|
||||||
else
|
|
||||||
LOG_INFO << "keep rule: fixed wait of " << settle << " ms after standstill";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comma-joined "label (type N)" for every XKF profile the device reports, so a
|
// Comma-joined "label (type N)" for every XKF profile the device reports, so a
|
||||||
// bad argument can say what IS available instead of just refusing.
|
// bad argument can say what IS available instead of just refusing.
|
||||||
std::string imuProfileList() const {
|
std::string imuProfileList() const {
|
||||||
|
|
@ -1041,7 +1015,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->phase();
|
const char* phase = scheduler->moving() ? "moving" : "dwell";
|
||||||
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();
|
||||||
|
|
@ -2098,9 +2072,10 @@ 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 " << interval_s
|
LOG_INFO << "continuous capture on: one image every ~"
|
||||||
<< " s, each once the gimbal has stood still for "
|
<< interval_s + scheduler->settleWaitMs() / 1000.0 << " s ("
|
||||||
<< scheduler->settleWaitMs() << " ms";
|
<< interval_s << " s interval + " << scheduler->settleWaitMs()
|
||||||
|
<< " ms settle), kept only while the gimbal stands still";
|
||||||
}
|
}
|
||||||
|
|
||||||
// End continuous capture because something else is taking the gimbal or the
|
// End continuous capture because something else is taking the gimbal or the
|
||||||
|
|
@ -3039,13 +3014,7 @@ struct Application::Impl {
|
||||||
// In open order, matching Frame::cam_id: the source opens the active
|
// In open order, matching Frame::cam_id: the source opens the active
|
||||||
// cameras in this order too.
|
// cameras in this order too.
|
||||||
for (const auto* c : cfg.camera.active())
|
for (const auto* c : cfg.camera.active())
|
||||||
pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort,
|
pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort});
|
||||||
c->demosaic == "edge_aware"});
|
|
||||||
pp.workers = cfg.hardware.save_workers;
|
|
||||||
pp.jxl_threads = cfg.hardware.jxl_threads;
|
|
||||||
pp.queue_max = cfg.hardware.save_queue_max;
|
|
||||||
LOG_INFO << "image saving: " << pp.workers << " worker(s) x " << pp.jxl_threads
|
|
||||||
<< " thread(s), up to " << pp.queue_max << " frame(s) waiting";
|
|
||||||
// Orientation supplier, called by ImagePipeline::submit() as each frame
|
// Orientation supplier, called by ImagePipeline::submit() as each frame
|
||||||
// arrives. The two axes come from different sources on purpose:
|
// arrives. The two axes come from different sources on purpose:
|
||||||
//
|
//
|
||||||
|
|
@ -3097,12 +3066,9 @@ struct Application::Impl {
|
||||||
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, cfg.image_rate(),
|
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, cfg.image_rate(),
|
||||||
cfg.geometry, grid);
|
cfg.geometry, grid);
|
||||||
scheduler->setPanStep(cfg.hardware.pan_step_deg);
|
scheduler->setPanStep(cfg.hardware.pan_step_deg);
|
||||||
// A scan that outruns saving holds at its waypoint instead of piling
|
|
||||||
// frames up in memory (hardware.ini save_queue_max).
|
|
||||||
scheduler->setSaveGate([pipe_ptr] { return pipe_ptr->canAccept(); });
|
|
||||||
// Every camera has to have finished by the time the gimbal moves on, so
|
// Every camera has to have finished by the time the gimbal moves on, so
|
||||||
// the wait is the longest across them, not the first one's.
|
// the wait is the longest across them, not the first one's.
|
||||||
configureSettle();
|
scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs());
|
||||||
|
|
||||||
// Before the firmware is talked to: the registers are wanted by the very
|
// Before the firmware is talked to: the registers are wanted by the very
|
||||||
// first sendAnchorConfig(), on both the homing and the no-homing startup path.
|
// first sendAnchorConfig(), on both the homing and the no-homing startup path.
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,6 @@ StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_r
|
||||||
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
|
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
|
||||||
}
|
}
|
||||||
|
|
||||||
long long staleFrameLimitMs(double exposure_us, double frame_period_ms) {
|
|
||||||
const double exposure_ms = exposure_us > 0 ? exposure_us / 1000 : 0;
|
|
||||||
const double slowest = std::max(exposure_ms, frame_period_ms > 0 ? frame_period_ms : 0);
|
|
||||||
return std::max<long long>(2000, static_cast<long long>(2 * slowest) + 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms) {
|
|
||||||
return since_newest_ms < staleFrameLimitMs(exposure_us, frame_period_ms) ? StallAction::Wait
|
|
||||||
: StallAction::Restart;
|
|
||||||
}
|
|
||||||
|
|
||||||
StallAction onRestartFailed(int failed_restarts) {
|
StallAction onRestartFailed(int failed_restarts) {
|
||||||
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
|
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,8 @@
|
||||||
#include "fgc/Logger.h"
|
#include "fgc/Logger.h"
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <iomanip>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sstream>
|
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
|
|
@ -24,13 +21,6 @@ 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,
|
||||||
|
|
@ -41,8 +31,7 @@ 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_();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,37 +42,26 @@ 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
|
||||||
hold_ = {};
|
settled_at_ms_ = 0;
|
||||||
// The first move goes from wherever the gimbal is, so it may be long: no
|
|
||||||
// overrun warning for it.
|
|
||||||
first_move_ = true;
|
|
||||||
rule_started_ = 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.
|
||||||
snapshot_pending_ = false;
|
snapshot_pending_ = false;
|
||||||
// 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_hold_ = {};
|
continuous_settled_ms_ = 0;
|
||||||
// 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_hold_ = {};
|
continuous_settled_ms_ = 0;
|
||||||
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
void CaptureScheduler::setSettleRule(bool on, const SettleRule::Params& params) {
|
|
||||||
use_rule_ = on;
|
|
||||||
rule_.setParams(params);
|
|
||||||
rule_started_ = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void CaptureScheduler::setSettleWait(int settle_ms, int timeout_ms) {
|
void CaptureScheduler::setSettleWait(int settle_ms, int timeout_ms) {
|
||||||
settle_wait_ms_ = settle_ms < 0 ? 0 : settle_ms;
|
settle_wait_ms_ = settle_ms < 0 ? 0 : settle_ms;
|
||||||
frame_timeout_ms_ = timeout_ms < 0 ? 0 : timeout_ms;
|
frame_timeout_ms_ = timeout_ms < 0 ? 0 : timeout_ms;
|
||||||
|
|
@ -107,117 +85,38 @@ bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {
|
||||||
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
|
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CaptureScheduler::roomToSave() {
|
|
||||||
const bool room = !can_save_ || can_save_();
|
|
||||||
if (!room && !holding_) {
|
|
||||||
holding_ = true;
|
|
||||||
LOG_WARN << "saving is behind: holding the scan here until it catches up";
|
|
||||||
} else if (room && holding_) {
|
|
||||||
holding_ = false;
|
|
||||||
LOG_INFO << "saving caught up: the scan carries on";
|
|
||||||
}
|
|
||||||
return room;
|
|
||||||
}
|
|
||||||
|
|
||||||
const char* CaptureScheduler::phase() const {
|
|
||||||
if (!moving_ || hold_.settled_ms == 0) return "moving";
|
|
||||||
return hold_.ready_ms == 0 ? "settling" : "waiting";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string CaptureScheduler::waypointLabel() const {
|
|
||||||
if (mode_ == Mode::Pan)
|
|
||||||
return "pan position " + std::to_string(pan_index_ + 1) + "/" +
|
|
||||||
std::to_string(pan_headings_.size());
|
|
||||||
return "waypoint " + std::to_string(grid_.index() + 1) + "/" + std::to_string(grid_.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
void CaptureScheduler::requestSnapshot() {
|
void CaptureScheduler::requestSnapshot() {
|
||||||
snapshot_pending_ = true;
|
snapshot_pending_ = true;
|
||||||
snapshot_hold_ = {};
|
snapshot_settled_ms_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CaptureScheduler::beginHold(Hold& h) {
|
// Wait for rest, settle, then shoot - the tail of a scan waypoint without the
|
||||||
h = {};
|
// move. A snapshot and a continuous-capture frame both come through here, so
|
||||||
h.settled_ms = now_ms_();
|
// 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.
|
||||||
// From here on, watch the frames the camera delivers until brightness says the
|
// The frame budget is measured from settling, not from the request - a request
|
||||||
// image has settled, or the frame budget runs out.
|
// made mid-slew must not spend the camera's budget on the slew.
|
||||||
bool CaptureScheduler::ruleDecided(long long since_settled) {
|
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t,
|
||||||
if (!rule_started_) {
|
long long& settled_ms) {
|
||||||
rule_started_ = true;
|
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
|
||||||
rule_.reset();
|
if (!standstill) {
|
||||||
rule_seq_ = camera_.frameSeq();
|
settled_ms = 0; // still slewing (e.g. a jog is in flight)
|
||||||
// No frame data from this camera: keep the fixed wait's behaviour.
|
return Shot::Waiting;
|
||||||
if (rule_seq_ == 0) rule_seq_ = UINT64_MAX;
|
|
||||||
}
|
}
|
||||||
if (rule_seq_ == UINT64_MAX) return true;
|
if (settled_ms == 0) {
|
||||||
SettleRule::Verdict v = SettleRule::Verdict::Waiting;
|
settled_ms = now_ms_();
|
||||||
for (const auto& f : camera_.framesSince(rule_seq_)) {
|
return Shot::Waiting;
|
||||||
rule_seq_ = f.seq;
|
|
||||||
v = rule_.add(f);
|
|
||||||
if (v != SettleRule::Verdict::Waiting) break;
|
|
||||||
}
|
}
|
||||||
if (v == SettleRule::Verdict::Waiting) {
|
const long long since_settled = now_ms_() - settled_ms;
|
||||||
if (since_settled < settle_wait_ms_ + frame_timeout_ms_) return false; // keep watching
|
if (since_settled < settle_wait_ms_) return Shot::Waiting;
|
||||||
LOG_WARN << waypointLabel() << ": the image did not settle within " << frame_timeout_ms_
|
|
||||||
<< " ms of standstill; keeping the newest frame";
|
|
||||||
} else {
|
|
||||||
LOG_TRACE_CAT(LogCat::Control)
|
|
||||||
<< "image settled after " << since_settled << " ms"
|
|
||||||
<< (v == SettleRule::Verdict::AtLimit ? " (exposure and gain at their limit)" : "");
|
|
||||||
}
|
|
||||||
rule_seq_ = UINT64_MAX; // decided
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CaptureScheduler::dwellOver(Hold& h, bool keep_rule) {
|
|
||||||
if (h.ready_ms != 0) return true;
|
|
||||||
const long long since = now_ms_() - h.settled_ms;
|
|
||||||
if (since < settle_wait_ms_) return false;
|
|
||||||
if (keep_rule && use_rule_ && !ruleDecided(since)) return false;
|
|
||||||
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 (now_ms_() - h.due_ms >= frame_timeout_ms_) return Shot::TimedOut;
|
if (since_settled > settle_wait_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,
|
|
||||||
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, false)) return Shot::Waiting;
|
|
||||||
if (wait_interval) {
|
|
||||||
if (interval_ms_ <= 0.0 || elapsedMs() < interval_ms_) return Shot::Waiting;
|
|
||||||
// Continuous capture holds, as the scan does, while saving is behind.
|
|
||||||
if (h.due_ms == 0 && !roomToSave()) return Shot::Waiting;
|
|
||||||
}
|
|
||||||
return fire(h);
|
|
||||||
}
|
|
||||||
|
|
||||||
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
|
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
|
||||||
switch (shootAtRest(t, snapshot_hold_, false)) {
|
switch (shootAtRest(t, snapshot_settled_ms_)) {
|
||||||
case Shot::Waiting:
|
case Shot::Waiting:
|
||||||
return;
|
return;
|
||||||
case Shot::Captured:
|
case Shot::Captured:
|
||||||
|
|
@ -225,17 +124,20 @@ 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; send 'snapshot' (Space) again";
|
<< " ms of settling; send 'snapshot' (Space) again";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
snapshot_pending_ = false;
|
snapshot_pending_ = false;
|
||||||
snapshot_hold_ = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The scan's cycle without the move: one frame per interval since the last,
|
// The scan's cycle without the move: wait the interval, then shoot at rest.
|
||||||
// once at rest and the exposure has settled.
|
// The timer restarts after each frame kept or given up, exactly as a scan's
|
||||||
|
// 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) {
|
||||||
switch (shootAtRest(t, continuous_hold_, true)) {
|
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
|
||||||
|
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:
|
||||||
|
|
@ -243,92 +145,10 @@ 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; trying again next interval";
|
<< " ms of settling; trying again next interval";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continuous_hold_ = {};
|
continuous_settled_ms_ = 0;
|
||||||
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)";
|
|
||||||
}
|
|
||||||
// Time spent waiting for saving is already announced, so not an overrun.
|
|
||||||
quiet_overrun_ = first_move_ || pass_start || save_held_;
|
|
||||||
first_move_ = false;
|
|
||||||
save_held_ = false;
|
|
||||||
sendMove();
|
|
||||||
moving_ = true;
|
|
||||||
hold_ = {};
|
|
||||||
rule_started_ = false;
|
|
||||||
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_ = {};
|
|
||||||
rule_started_ = false;
|
|
||||||
if (mode_ == Mode::Auto) grid_.advance();
|
|
||||||
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
|
|
||||||
resetTimer();
|
resetTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,63 +168,108 @@ 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).
|
||||||
// Move straight away - while there is room to save what it will capture -
|
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
|
||||||
// and wait out what is left of the interval at the end.
|
|
||||||
if (interval_ms_ <= 0.0) return;
|
|
||||||
if (!moving_) {
|
if (!moving_) {
|
||||||
if (!roomToSave()) {
|
// Issue the next move once the interval has elapsed.
|
||||||
save_held_ = true;
|
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
|
||||||
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)";
|
||||||
}
|
}
|
||||||
startNextMove();
|
sendMove();
|
||||||
|
moving_ = true;
|
||||||
|
settled_at_ms_ = 0;
|
||||||
|
resetTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moving: wait for standstill at the target.
|
// Moving: wait for standstill at the target, then hold still a moment before
|
||||||
if (hold_.settled_ms == 0) {
|
// keeping a frame.
|
||||||
|
if (settled_at_ms_ == 0) {
|
||||||
if (!settledAt(t)) return;
|
if (!settledAt(t)) return;
|
||||||
beginHold(hold_);
|
settled_at_ms_ = now_ms_();
|
||||||
LOG_TRACE_CAT(LogCat::Control)
|
LOG_TRACE_CAT(LogCat::Control)
|
||||||
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc
|
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; waiting "
|
||||||
<< "; waiting " << settle_wait_ms_ << " ms"
|
<< settle_wait_ms_ << " ms before capture";
|
||||||
<< (use_rule_ ? " and for the image to settle" : "") << " before capture";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The camera free-runs, so this wait is what makes the kept frame one that
|
// The camera free-runs, so this pause is what makes the kept frame one that was
|
||||||
// was exposed with the gimbal at rest (no slew blur) and with the on-camera
|
// exposed with the gimbal at rest (no slew blur) and with the on-camera auto
|
||||||
// auto exposure settled on this waypoint's light.
|
// exposure settled on this waypoint's light.
|
||||||
if (hold_.ready_ms == 0) {
|
const long long since_settled = now_ms_() - settled_at_ms_;
|
||||||
if (!dwellOver(hold_, true)) return;
|
if (since_settled < settle_wait_ms_) return;
|
||||||
const long long used = hold_.ready_ms - timer_start_;
|
|
||||||
if (static_cast<double>(used) > interval_ms_ && !quiet_overrun_) {
|
if (camera_.trigger()) {
|
||||||
++overruns_;
|
if (mode_ == Mode::Auto)
|
||||||
LOG_WARN << waypointLabel() << ": " << seconds(static_cast<double>(used))
|
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
|
||||||
<< " since the last picture, over the " << seconds(interval_ms_)
|
else
|
||||||
<< " interval (move "
|
LOG_INFO << "captured pan position " << (pan_index_ + 1) << "/"
|
||||||
<< seconds(static_cast<double>(hold_.settled_ms - move_sent_ms_))
|
<< pan_headings_.size();
|
||||||
<< ", exposure "
|
} else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) {
|
||||||
<< seconds(static_cast<double>(hold_.ready_ms - hold_.settled_ms)) << ")";
|
return; // no fresh frame yet; retry next tick
|
||||||
}
|
} 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then whatever is left of the interval.
|
moving_ = false;
|
||||||
if (static_cast<double>(elapsedMs()) < interval_ms_) return;
|
settled_at_ms_ = 0;
|
||||||
|
if (mode_ == Mode::Auto) grid_.advance();
|
||||||
switch (fire(hold_)) {
|
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
|
||||||
case Shot::Waiting:
|
resetTimer();
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,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 = getDouble(kv, "General.image_interval", cfg.general.image_interval);
|
cfg.general.image_interval = getInt(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
|
||||||
|
|
@ -277,18 +277,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
throw std::runtime_error("Hardware.pan_step_deg must be > 0 (degrees of bearing "
|
throw std::runtime_error("Hardware.pan_step_deg must be > 0 (degrees of bearing "
|
||||||
"one picture covers)");
|
"one picture covers)");
|
||||||
}
|
}
|
||||||
cfg.hardware.save_workers = getInt(kv, "Hardware.save_workers", cfg.hardware.save_workers);
|
|
||||||
cfg.hardware.jxl_threads = getInt(kv, "Hardware.jxl_threads", cfg.hardware.jxl_threads);
|
|
||||||
cfg.hardware.save_queue_max = getInt(kv, "Hardware.save_queue_max", cfg.hardware.save_queue_max);
|
|
||||||
if (cfg.hardware.save_workers < 1 || cfg.hardware.save_workers > 4)
|
|
||||||
throw std::runtime_error("Hardware.save_workers must be in 1..4 (the boards have 4 cores)");
|
|
||||||
if (cfg.hardware.jxl_threads < 0 || cfg.hardware.jxl_threads > 8)
|
|
||||||
throw std::runtime_error("Hardware.jxl_threads must be in 0..8 (0 = 4 / save_workers)");
|
|
||||||
if (cfg.hardware.jxl_threads == 0)
|
|
||||||
cfg.hardware.jxl_threads = std::max(1, 4 / cfg.hardware.save_workers);
|
|
||||||
if (cfg.hardware.save_queue_max < 1 || cfg.hardware.save_queue_max > 32)
|
|
||||||
throw std::runtime_error("Hardware.save_queue_max must be in 1..32 (each raw frame "
|
|
||||||
"waiting holds ~24 MB)");
|
|
||||||
for (const auto& a : cfg.hardware.axes)
|
for (const auto& a : cfg.hardware.axes)
|
||||||
if (a != "yaw" && a != "pitch")
|
if (a != "yaw" && a != "pitch")
|
||||||
throw std::runtime_error("Hardware.axes: unknown axis '" + a +
|
throw std::runtime_error("Hardware.axes: unknown axis '" + a +
|
||||||
|
|
@ -308,7 +296,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
// ---- [Camera] shared defaults, then one [Camera.<name>] block per camera ----
|
// ---- [Camera] shared defaults, then one [Camera.<name>] block per camera ----
|
||||||
cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance);
|
cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance);
|
||||||
cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort);
|
cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort);
|
||||||
cfg.camera.demosaic = lowered(get(kv, "Camera.demosaic", cfg.camera.demosaic));
|
|
||||||
|
|
||||||
// Cameras are discovered from the key names rather than a list, so there is
|
// Cameras are discovered from the key names rather than a list, so there is
|
||||||
// no registry to keep in sync: "Camera.rgb.id" declares a camera called rgb.
|
// no registry to keep in sync: "Camera.rgb.id" declares a camera called rgb.
|
||||||
|
|
@ -343,7 +330,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg);
|
c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg);
|
||||||
c.jxl_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance);
|
c.jxl_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance);
|
||||||
c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort);
|
c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort);
|
||||||
c.demosaic = lowered(get(kv, pre + "demosaic", cfg.camera.demosaic));
|
|
||||||
|
|
||||||
// Per-profile timing: Camera.<name>.<profile>.settle_wait_ms
|
// Per-profile timing: Camera.<name>.<profile>.settle_wait_ms
|
||||||
for (const auto& [key, val] : kv) {
|
for (const auto& [key, val] : kv) {
|
||||||
|
|
@ -357,12 +343,8 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
CameraProfileTiming& t = c.timing[profile];
|
CameraProfileTiming& t = c.timing[profile];
|
||||||
if (field == "settle_wait_ms") t.settle_wait_ms = getInt(kv, key, t.settle_wait_ms);
|
if (field == "settle_wait_ms") t.settle_wait_ms = getInt(kv, key, t.settle_wait_ms);
|
||||||
else if (field == "frame_timeout_ms") t.frame_timeout_ms = getInt(kv, key, t.frame_timeout_ms);
|
else if (field == "frame_timeout_ms") t.frame_timeout_ms = getInt(kv, key, t.frame_timeout_ms);
|
||||||
else if (field == "settle_rule") t.settle_rule = lowered(get(kv, key));
|
else throw std::runtime_error("unknown key '" + key + "' (expected " + pre +
|
||||||
else throw std::runtime_error("unknown key '" + key + "' (expected " + pre + profile +
|
profile + ".settle_wait_ms or .frame_timeout_ms)");
|
||||||
".settle_wait_ms, .frame_timeout_ms or .settle_rule)");
|
|
||||||
if (t.settle_rule != "fixed" && t.settle_rule != "brightness")
|
|
||||||
throw std::runtime_error(key.substr(0, key.rfind('.')) + ".settle_rule must be "
|
|
||||||
"'fixed' or 'brightness'");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.driver != "vimba")
|
if (c.driver != "vimba")
|
||||||
|
|
@ -376,9 +358,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)");
|
throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)");
|
||||||
if (c.jxl_effort < 1 || c.jxl_effort > 9)
|
if (c.jxl_effort < 1 || c.jxl_effort > 9)
|
||||||
throw std::runtime_error("Camera." + name + ".jxl_effort must be in 1..9");
|
throw std::runtime_error("Camera." + name + ".jxl_effort must be in 1..9");
|
||||||
if (c.demosaic != "edge_aware" && c.demosaic != "bilinear")
|
|
||||||
throw std::runtime_error("Camera." + name + ".demosaic must be 'edge_aware' or "
|
|
||||||
"'bilinear' (got '" + c.demosaic + "')");
|
|
||||||
if (c.rotation_deg != 0 && c.rotation_deg != 90 && c.rotation_deg != 180 &&
|
if (c.rotation_deg != 0 && c.rotation_deg != 90 && c.rotation_deg != 180 &&
|
||||||
c.rotation_deg != 270)
|
c.rotation_deg != 270)
|
||||||
throw std::runtime_error("Camera." + name + ".rotation_deg must be 0, 90, 180 or 270");
|
throw std::runtime_error("Camera." + name + ".rotation_deg must be 0, 90, 180 or 270");
|
||||||
|
|
@ -558,9 +537,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) || !std::isfinite(cfg.general.image_interval))
|
if (cfg.general.image_interval <= 0)
|
||||||
throw std::runtime_error("General.image_interval must be > 0 (got " +
|
throw std::runtime_error("General.image_interval must be > 0 (got " +
|
||||||
get(kv, "General.image_interval") + ")");
|
std::to_string(cfg.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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,10 @@ const std::vector<HelpSection>& helpCatalog() {
|
||||||
"Waits for standstill and the profile's settle time, as a scan waypoint",
|
"Waits for standstill and the profile's settle time, 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 [General] image_interval since the last, each once the",
|
"One image per scan cycle: [General] image_interval, then standstill and",
|
||||||
"gimbal has stood still for the profile's settle time. The gimbal can",
|
"the profile's settle time. The gimbal can still be nudged; images are",
|
||||||
"still be nudged; images are kept only while it stands still. Bare",
|
"kept only while it stands still. Bare 'capture' reports on/off. Ends on",
|
||||||
"'capture' reports on/off. Ends on any mode change, homing, heading",
|
"any mode change, homing, heading setup or test."}},
|
||||||
"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.",
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
#include "fgc/RawImage.h"
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
const char* layoutName(PixelLayout layout) {
|
|
||||||
switch (layout) {
|
|
||||||
case PixelLayout::Mono8: return "Mono8";
|
|
||||||
case PixelLayout::RGB8: return "RGB8";
|
|
||||||
case PixelLayout::BayerRG8: return "BayerRG8";
|
|
||||||
case PixelLayout::BayerRG12p: return "BayerRG12p";
|
|
||||||
case PixelLayout::Unknown: break;
|
|
||||||
}
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
PixelLayout layoutFromName(const std::string& name) {
|
|
||||||
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
|
|
||||||
PixelLayout::BayerRG12p})
|
|
||||||
if (name == layoutName(l)) return l;
|
|
||||||
return PixelLayout::Unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool layoutIsBayer(PixelLayout layout) {
|
|
||||||
return layout == PixelLayout::BayerRG8 || layout == PixelLayout::BayerRG12p;
|
|
||||||
}
|
|
||||||
|
|
||||||
int layoutBits(PixelLayout layout) {
|
|
||||||
switch (layout) {
|
|
||||||
case PixelLayout::Mono8:
|
|
||||||
case PixelLayout::RGB8:
|
|
||||||
case PixelLayout::BayerRG8: return 8;
|
|
||||||
case PixelLayout::BayerRG12p: return 12;
|
|
||||||
case PixelLayout::Unknown: break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height) {
|
|
||||||
const size_t n = static_cast<size_t>(width) * height;
|
|
||||||
switch (layout) {
|
|
||||||
case PixelLayout::Mono8:
|
|
||||||
case PixelLayout::BayerRG8: return n;
|
|
||||||
case PixelLayout::RGB8: return n * 3;
|
|
||||||
case PixelLayout::BayerRG12p: return (n * 3 + 1) / 2;
|
|
||||||
case PixelLayout::Unknown: break;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
inline uint16_t px12p(const uint8_t* in, size_t i) {
|
|
||||||
const uint8_t* p = in + (i >> 1) * 3;
|
|
||||||
return (i & 1) == 0 ? static_cast<uint16_t>(p[0] | ((p[1] & 0x0F) << 8))
|
|
||||||
: static_cast<uint16_t>((p[1] >> 4) | (p[2] << 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void unpack12p(const uint8_t* in, uint16_t* out, size_t pixels) {
|
|
||||||
const size_t pairs = pixels / 2;
|
|
||||||
for (size_t k = 0; k < pairs; ++k, in += 3, out += 2) {
|
|
||||||
out[0] = static_cast<uint16_t>(in[0] | ((in[1] & 0x0F) << 8));
|
|
||||||
out[1] = static_cast<uint16_t>((in[1] >> 4) | (in[2] << 4));
|
|
||||||
}
|
|
||||||
if (pixels & 1) out[0] = static_cast<uint16_t>(in[0] | ((in[1] & 0x0F) << 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayout layout,
|
|
||||||
uint32_t width, uint32_t height, size_t stride) {
|
|
||||||
BrightnessSample s;
|
|
||||||
const size_t pixels = static_cast<size_t>(width) * height;
|
|
||||||
if (!frame || layout == PixelLayout::Unknown || stride == 0 ||
|
|
||||||
bytes < layoutFrameBytes(layout, width, height))
|
|
||||||
return s;
|
|
||||||
// Values per pixel (RGB8 has three); sampling every stride-th value keeps
|
|
||||||
// the colour channels mixed for RGB8 just as for a mosaic.
|
|
||||||
const size_t values = layout == PixelLayout::RGB8 ? pixels * 3 : pixels;
|
|
||||||
double sum = 0;
|
|
||||||
size_t hi = 0;
|
|
||||||
for (size_t i = 0; i < values; i += stride) {
|
|
||||||
double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0 : frame[i];
|
|
||||||
sum += v;
|
|
||||||
hi += v >= 250.0;
|
|
||||||
++s.samples;
|
|
||||||
}
|
|
||||||
if (s.samples) {
|
|
||||||
s.mean = sum / s.samples;
|
|
||||||
s.clipped = static_cast<double>(hi) / s.samples;
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
#include "fgc/SettleRule.h"
|
|
||||||
|
|
||||||
#include <cmath>
|
|
||||||
|
|
||||||
namespace fgc {
|
|
||||||
|
|
||||||
SettleRule::Verdict SettleRule::add(const FrameStats& f) {
|
|
||||||
if (f.brightness < 0) {
|
|
||||||
window_.clear();
|
|
||||||
return Verdict::Waiting;
|
|
||||||
}
|
|
||||||
window_.push_back(f);
|
|
||||||
const size_t n = p_.frames < 1 ? 1 : static_cast<size_t>(p_.frames);
|
|
||||||
while (window_.size() > n) window_.pop_front();
|
|
||||||
if (window_.size() < n || !steady()) return Verdict::Waiting;
|
|
||||||
|
|
||||||
bool in_band = true;
|
|
||||||
for (const auto& w : window_)
|
|
||||||
in_band = in_band && w.brightness >= bandLow() && w.brightness <= bandHigh();
|
|
||||||
if (in_band) return Verdict::Settled;
|
|
||||||
return atLimit() ? Verdict::AtLimit : Verdict::Waiting;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool SettleRule::steady() const {
|
|
||||||
for (size_t i = 1; i < window_.size(); ++i) {
|
|
||||||
const double a = window_[i - 1].brightness, b = window_[i].brightness;
|
|
||||||
if (std::fabs(b - a) > p_.steady * std::fmax(a, 1.0)) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool SettleRule::atLimit() const {
|
|
||||||
const FrameStats& f = window_.back();
|
|
||||||
if (f.exposure_us < 0 || f.gain_db < 0) return false;
|
|
||||||
// A hair of slack: the camera reports quantised exposure and gain.
|
|
||||||
if (f.brightness < bandLow() && p_.exposure_max_us > 0 && p_.gain_max_db >= 0)
|
|
||||||
return f.exposure_us >= p_.exposure_max_us * 0.99 && f.gain_db >= p_.gain_max_db - 0.1;
|
|
||||||
if (f.brightness > bandHigh() && p_.exposure_min_us > 0 && p_.gain_min_db >= 0)
|
|
||||||
return f.exposure_us <= p_.exposure_min_us * 1.01 && f.gain_db <= p_.gain_min_db + 0.1;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgc
|
|
||||||
|
|
@ -57,8 +57,6 @@ add_executable(fgc_tests
|
||||||
test_testreport.cpp
|
test_testreport.cpp
|
||||||
test_hostmetrics.cpp
|
test_hostmetrics.cpp
|
||||||
test_testrunner.cpp
|
test_testrunner.cpp
|
||||||
test_rawimage.cpp
|
|
||||||
test_settlerule.cpp
|
|
||||||
)
|
)
|
||||||
target_link_libraries(fgc_tests PRIVATE fgc_core doctest::doctest)
|
target_link_libraries(fgc_tests PRIVATE fgc_core doctest::doctest)
|
||||||
# So a test can read the shipped config/config.example.ini and hold it to what
|
# So a test can read the shipped config/config.example.ini and hold it to what
|
||||||
|
|
@ -67,13 +65,3 @@ target_compile_definitions(fgc_tests PRIVATE FGC_SOURCE_DIR="${PROJECT_SOURCE_DI
|
||||||
|
|
||||||
include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake)
|
include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake)
|
||||||
doctest_discover_tests(fgc_tests)
|
doctest_discover_tests(fgc_tests)
|
||||||
|
|
||||||
# Image tests: the JPEG XL encoder and the frame-to-image pipeline, which need
|
|
||||||
# OpenCV and libjxl (fgc_imaging) - still no camera SDK.
|
|
||||||
add_executable(fgc_imaging_tests
|
|
||||||
doctest_main.cpp
|
|
||||||
test_jpegxl.cpp
|
|
||||||
test_imagepipeline.cpp
|
|
||||||
)
|
|
||||||
target_link_libraries(fgc_imaging_tests PRIVATE fgc_imaging doctest::doctest)
|
|
||||||
doctest_discover_tests(fgc_imaging_tests)
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,57 +0,0 @@
|
||||||
#pragma once
|
|
||||||
// Decoding JPEG XL files in tests (libjxl), shared by the image tests.
|
|
||||||
|
|
||||||
#include <jxl/decode.h>
|
|
||||||
#include <jxl/decode_cxx.h>
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iterator>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace fgc_test {
|
|
||||||
|
|
||||||
struct Decoded {
|
|
||||||
std::vector<uint16_t> px; // values at the file's own bit depth
|
|
||||||
int bits = 0;
|
|
||||||
int channels = 0;
|
|
||||||
bool original_profile = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Decode to 16-bit words at the file's own bit depth (12-bit files give
|
|
||||||
// 0..4095, 8-bit files 0..255).
|
|
||||||
inline Decoded decode(const std::string& path, int channels) {
|
|
||||||
std::ifstream f(path, std::ios::binary);
|
|
||||||
std::vector<uint8_t> data{std::istreambuf_iterator<char>(f), {}};
|
|
||||||
Decoded out;
|
|
||||||
auto dec = JxlDecoderMake(nullptr);
|
|
||||||
JxlDecoderSubscribeEvents(dec.get(), JXL_DEC_BASIC_INFO | JXL_DEC_FULL_IMAGE);
|
|
||||||
JxlDecoderSetInput(dec.get(), data.data(), data.size());
|
|
||||||
JxlDecoderCloseInput(dec.get());
|
|
||||||
JxlPixelFormat pf = {static_cast<uint32_t>(channels), JXL_TYPE_UINT16, JXL_NATIVE_ENDIAN, 0};
|
|
||||||
for (;;) {
|
|
||||||
auto s = JxlDecoderProcessInput(dec.get());
|
|
||||||
if (s == JXL_DEC_BASIC_INFO) {
|
|
||||||
JxlBasicInfo bi;
|
|
||||||
JxlDecoderGetBasicInfo(dec.get(), &bi);
|
|
||||||
out.bits = static_cast<int>(bi.bits_per_sample);
|
|
||||||
out.channels = static_cast<int>(bi.num_color_channels);
|
|
||||||
out.original_profile = bi.uses_original_profile;
|
|
||||||
} else if (s == JXL_DEC_NEED_IMAGE_OUT_BUFFER) {
|
|
||||||
size_t n = 0;
|
|
||||||
JxlDecoderImageOutBufferSize(dec.get(), &pf, &n);
|
|
||||||
out.px.resize(n / 2);
|
|
||||||
JxlDecoderSetImageOutBuffer(dec.get(), &pf, out.px.data(), n);
|
|
||||||
JxlBitDepth bd = {JXL_BIT_DEPTH_FROM_CODESTREAM, 0, 0};
|
|
||||||
JxlDecoderSetImageOutBitDepth(dec.get(), &bd);
|
|
||||||
} else if (s == JXL_DEC_FULL_IMAGE || s == JXL_DEC_SUCCESS) {
|
|
||||||
return out;
|
|
||||||
} else {
|
|
||||||
out.px.clear();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgc_test
|
|
||||||
|
|
@ -25,19 +25,3 @@ TEST_CASE("a stale stream escalates to a reset once restarts stop working") {
|
||||||
CHECK(onRestartFailed(1) == StallAction::Restart);
|
CHECK(onRestartFailed(1) == StallAction::Restart);
|
||||||
CHECK(onRestartFailed(kRestartsBeforeReset) == StallAction::Reset);
|
CHECK(onRestartFailed(kRestartsBeforeReset) == StallAction::Reset);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("a slow next frame is not a stale stream") {
|
|
||||||
// Fast stream (5 ms exposure, 5 fps): the 2 s floor.
|
|
||||||
CHECK(staleFrameLimitMs(5000, 200) == 2000);
|
|
||||||
// Tower 111 at dusk: 9 s exposures, a frame every ~9 s.
|
|
||||||
CHECK(staleFrameLimitMs(9e6, 9000) == 2 * 9000 + 1000);
|
|
||||||
// A frame-rate cap slower than the exposure governs (night: 0.5 s at 0.215 fps).
|
|
||||||
CHECK(staleFrameLimitMs(5e5, 1000.0 / 0.215) == 2 * 4651 + 1000);
|
|
||||||
|
|
||||||
// Asked 8 s after the last frame of a 9 s exposure: wait for it, do not
|
|
||||||
// restart the stream and throw that exposure away (tower 111, 2026-10-04).
|
|
||||||
CHECK(onStaleFrame(8000, 9e6, 9000) == StallAction::Wait);
|
|
||||||
CHECK(onStaleFrame(20000, 9e6, 9000) == StallAction::Restart);
|
|
||||||
// A fast stream that has delivered nothing new for seconds is stale.
|
|
||||||
CHECK(onStaleFrame(2500, 5000, 200) == StallAction::Restart);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -95,17 +95,9 @@ 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.
|
||||||
|
|
@ -815,55 +807,3 @@ TEST_CASE("a declaration beats a flag that disagrees, and the clash is reported"
|
||||||
CHECK(agree.enabled);
|
CHECK(agree.enabled);
|
||||||
CHECK_FALSE(agree.conflict);
|
CHECK_FALSE(agree.conflict);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("image saving and de-Bayer settings: defaults, sharing the cores, limits") {
|
|
||||||
auto base = [](std::map<std::string, std::string> extra) {
|
|
||||||
std::map<std::string, std::string> kv = {{"Camera.rgb.id", "DEV_1"},
|
|
||||||
{"Camera.rgb.profile_dir", "/tmp/p"}};
|
|
||||||
for (auto& [k, v] : extra) kv[k] = v;
|
|
||||||
return ConfigLoader::fromMap(kv);
|
|
||||||
};
|
|
||||||
|
|
||||||
AppConfig c = base({});
|
|
||||||
CHECK(c.hardware.save_workers == 1);
|
|
||||||
CHECK(c.hardware.jxl_threads == 4); // 4 / save_workers
|
|
||||||
CHECK(c.hardware.save_queue_max == 4);
|
|
||||||
REQUIRE(c.camera.cameras.size() == 1);
|
|
||||||
CHECK(c.camera.cameras[0].demosaic == "edge_aware");
|
|
||||||
|
|
||||||
// Two workers share the four cores unless told otherwise (the 3 Delta).
|
|
||||||
c = base({{"Hardware.save_workers", "2"}});
|
|
||||||
CHECK(c.hardware.jxl_threads == 2);
|
|
||||||
c = base({{"Hardware.save_workers", "2"}, {"Hardware.jxl_threads", "3"}});
|
|
||||||
CHECK(c.hardware.jxl_threads == 3);
|
|
||||||
|
|
||||||
// [Camera] default, overridden per camera; case does not matter.
|
|
||||||
c = base({{"Camera.demosaic", "Bilinear"}});
|
|
||||||
CHECK(c.camera.cameras[0].demosaic == "bilinear");
|
|
||||||
c = base({{"Camera.demosaic", "bilinear"}, {"Camera.rgb.demosaic", "edge_aware"}});
|
|
||||||
CHECK(c.camera.cameras[0].demosaic == "edge_aware");
|
|
||||||
|
|
||||||
CHECK_THROWS(base({{"Camera.rgb.demosaic", "vng"}}));
|
|
||||||
CHECK_THROWS(base({{"Hardware.save_workers", "0"}}));
|
|
||||||
CHECK_THROWS(base({{"Hardware.save_workers", "5"}}));
|
|
||||||
CHECK_THROWS(base({{"Hardware.jxl_threads", "9"}}));
|
|
||||||
CHECK_THROWS(base({{"Hardware.save_queue_max", "0"}}));
|
|
||||||
CHECK_THROWS(base({{"Hardware.save_queue_max", "33"}}));
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("each profile chooses its keep rule: fixed (default) or brightness") {
|
|
||||||
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"},
|
|
||||||
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
||||||
{"Camera.rgb.standard.settle_rule", "Brightness"},
|
|
||||||
{"Camera.rgb.standard.frame_timeout_ms", "5000"},
|
|
||||||
{"Camera.rgb.night.settle_wait_ms", "2500"}});
|
|
||||||
REQUIRE(c.camera.cameras.size() == 1);
|
|
||||||
const auto& cam = c.camera.cameras[0];
|
|
||||||
CHECK(cam.timingFor("standard").settle_rule == "brightness");
|
|
||||||
CHECK(cam.timingFor("standard").frame_timeout_ms == 5000);
|
|
||||||
CHECK(cam.timingFor("night").settle_rule == "fixed");
|
|
||||||
CHECK(cam.timingFor("unnamed").settle_rule == "fixed");
|
|
||||||
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"},
|
|
||||||
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
||||||
{"Camera.rgb.standard.settle_rule", "magic"}}));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
#include "fgc/ImagePipeline.h"
|
|
||||||
#include "fgc/mock/MockCameraSource.h"
|
|
||||||
#include "jxl_test_decode.h"
|
|
||||||
|
|
||||||
#include <doctest/doctest.h>
|
|
||||||
|
|
||||||
#include <chrono>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <mutex>
|
|
||||||
#include <thread>
|
|
||||||
|
|
||||||
using namespace fgc;
|
|
||||||
using fgc_test::decode;
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
struct FakeChannel : IControlChannel {
|
|
||||||
std::mutex m;
|
|
||||||
std::vector<CamEvent> events;
|
|
||||||
bool connect() override { return true; }
|
|
||||||
void disconnect() override {}
|
|
||||||
bool connected() const override { return true; }
|
|
||||||
void publishStatus(int) override {}
|
|
||||||
void publishCamEvent(const CamEvent& e) override {
|
|
||||||
std::lock_guard<std::mutex> l(m);
|
|
||||||
events.push_back(e);
|
|
||||||
}
|
|
||||||
size_t count() {
|
|
||||||
std::lock_guard<std::mutex> l(m);
|
|
||||||
return events.size();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
struct TempDir {
|
|
||||||
fs::path path;
|
|
||||||
TempDir() {
|
|
||||||
path = fs::temp_directory_path() /
|
|
||||||
("fgc_pipe_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
||||||
fs::create_directories(path);
|
|
||||||
}
|
|
||||||
~TempDir() { fs::remove_all(path); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// A uniformly coloured raw frame: every red site `r`, green `g`, blue `b`
|
|
||||||
// (on the layout's own scale), in BayerRG order (R G / G B).
|
|
||||||
Frame bayerFrame(PixelLayout layout, uint32_t w, uint32_t h, int r, int g, int b, long long ts) {
|
|
||||||
Frame f;
|
|
||||||
f.width = w; f.height = h; f.layout = layout; f.channels = 1; f.timestamp_ms = ts;
|
|
||||||
std::vector<uint16_t> m(static_cast<size_t>(w) * h);
|
|
||||||
for (uint32_t y = 0; y < h; ++y)
|
|
||||||
for (uint32_t x = 0; x < w; ++x)
|
|
||||||
m[static_cast<size_t>(y) * w + x] = static_cast<uint16_t>(
|
|
||||||
y % 2 == 0 ? (x % 2 == 0 ? r : g) : (x % 2 == 0 ? g : b));
|
|
||||||
f.data.assign(layoutFrameBytes(layout, w, h), 0);
|
|
||||||
if (layout == PixelLayout::BayerRG8) {
|
|
||||||
for (size_t i = 0; i < m.size(); ++i) f.data[i] = static_cast<uint8_t>(m[i]);
|
|
||||||
} else {
|
|
||||||
for (size_t i = 0, o = 0; i < m.size(); i += 2, o += 3) {
|
|
||||||
f.data[o] = static_cast<uint8_t>(m[i] & 0xFF);
|
|
||||||
f.data[o + 1] = static_cast<uint8_t>(((m[i] >> 8) & 0x0F) | ((m[i + 1] & 0x0F) << 4));
|
|
||||||
f.data[o + 2] = static_cast<uint8_t>(m[i + 1] >> 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool waitSaved(const ImagePipeline& p, long long n, int ms = 20000) {
|
|
||||||
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
|
|
||||||
while (p.imagesSaved() < n && std::chrono::steady_clock::now() < end)
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
||||||
return p.imagesSaved() >= n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mean of channel c over the interior (de-Bayering blurs the frame's edge).
|
|
||||||
double interiorMean(const fgc_test::Decoded& d, int w, int h, int c) {
|
|
||||||
double s = 0;
|
|
||||||
size_t n = 0;
|
|
||||||
for (int y = 4; y < h - 4; ++y)
|
|
||||||
for (int x = 4; x < w - 4; ++x, ++n) s += d.px[(static_cast<size_t>(y) * w + x) * 3 + c];
|
|
||||||
return s / n;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImagePipeline::Params params(const TempDir& t, double distance = 0.0) {
|
|
||||||
ImagePipeline::Params p;
|
|
||||||
p.output_dir = t.path.string();
|
|
||||||
p.tower = "test";
|
|
||||||
p.cameras = {{"RGB", 0, distance, 1, true}};
|
|
||||||
p.jxl_threads = 2;
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string onlyFile(const TempDir& t) {
|
|
||||||
for (auto& e : fs::recursive_directory_iterator(t.path))
|
|
||||||
if (e.path().extension() == ".jxl") return e.path().string();
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TEST_CASE("a 12-bit raw frame is saved as a 12-bit colour file, colours in place") {
|
|
||||||
TempDir t;
|
|
||||||
FakeChannel ch;
|
|
||||||
ImagePipeline pipe(ch, [] { return Orientation{12.5f, 3.0f}; }, params(t));
|
|
||||||
pipe.start();
|
|
||||||
// Red 3200, green 1600, blue 800 of 4095.
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3200, 1600, 800, 1000));
|
|
||||||
REQUIRE(waitSaved(pipe, 1));
|
|
||||||
pipe.stop();
|
|
||||||
const auto d = decode(onlyFile(t), 3);
|
|
||||||
CHECK(d.bits == 12);
|
|
||||||
REQUIRE(d.px.size() == 64u * 48 * 3);
|
|
||||||
CHECK(interiorMean(d, 64, 48, 0) == doctest::Approx(3200).epsilon(0.01));
|
|
||||||
CHECK(interiorMean(d, 64, 48, 1) == doctest::Approx(1600).epsilon(0.01));
|
|
||||||
CHECK(interiorMean(d, 64, 48, 2) == doctest::Approx(800).epsilon(0.01));
|
|
||||||
REQUIRE(ch.count() == 1);
|
|
||||||
CHECK(ch.events[0].heading_decideg == 125);
|
|
||||||
CHECK(ch.events[0].timestamp_ms == 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("an 8-bit raw frame is saved as 8-bit colour, either de-Bayer method") {
|
|
||||||
for (bool ea : {true, false}) {
|
|
||||||
CAPTURE(ea);
|
|
||||||
TempDir t;
|
|
||||||
FakeChannel ch;
|
|
||||||
auto p = params(t);
|
|
||||||
p.cameras[0].edge_aware = ea;
|
|
||||||
ImagePipeline pipe(ch, nullptr, p);
|
|
||||||
pipe.start();
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 64, 48, 200, 100, 50, 2000));
|
|
||||||
REQUIRE(waitSaved(pipe, 1));
|
|
||||||
pipe.stop();
|
|
||||||
const auto d = decode(onlyFile(t), 3);
|
|
||||||
CHECK(d.bits == 8);
|
|
||||||
CHECK(interiorMean(d, 64, 48, 0) == doctest::Approx(200).epsilon(0.02));
|
|
||||||
CHECK(interiorMean(d, 64, 48, 2) == doctest::Approx(50).epsilon(0.05));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("RGB8 frames are saved as before") {
|
|
||||||
TempDir t;
|
|
||||||
FakeChannel ch;
|
|
||||||
ImagePipeline pipe(ch, nullptr, params(t, 0.8));
|
|
||||||
MockCameraSource cam(1, 64, 48, PixelLayout::RGB8);
|
|
||||||
cam.setFrameCallback([&pipe](const Frame& f) { pipe.submit(f); });
|
|
||||||
pipe.start();
|
|
||||||
REQUIRE(cam.trigger());
|
|
||||||
REQUIRE(waitSaved(pipe, 1));
|
|
||||||
pipe.stop();
|
|
||||||
const auto d = decode(onlyFile(t), 3);
|
|
||||||
CHECK(d.bits == 8);
|
|
||||||
CHECK(d.px.size() == 64u * 48 * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("rotation applies to raw frames too") {
|
|
||||||
TempDir t;
|
|
||||||
FakeChannel ch;
|
|
||||||
auto p = params(t);
|
|
||||||
p.cameras[0].rotation_deg = 90;
|
|
||||||
ImagePipeline pipe(ch, nullptr, p);
|
|
||||||
pipe.start();
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3200, 1600, 800, 3000));
|
|
||||||
REQUIRE(waitSaved(pipe, 1));
|
|
||||||
pipe.stop();
|
|
||||||
const auto d = decode(onlyFile(t), 3);
|
|
||||||
CHECK(d.px.size() == 48u * 64 * 3); // 48 wide, 64 high now
|
|
||||||
CHECK(interiorMean(d, 48, 64, 0) == doctest::Approx(3200).epsilon(0.01));
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("several workers save every frame") {
|
|
||||||
TempDir t;
|
|
||||||
FakeChannel ch;
|
|
||||||
auto p = params(t, 1.0);
|
|
||||||
p.workers = 3;
|
|
||||||
p.jxl_threads = 1;
|
|
||||||
ImagePipeline pipe(ch, nullptr, p);
|
|
||||||
pipe.start();
|
|
||||||
for (int i = 0; i < 9; ++i)
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3000, 1500, 700, 5000 + i));
|
|
||||||
REQUIRE(waitSaved(pipe, 9));
|
|
||||||
pipe.stop();
|
|
||||||
CHECK(ch.count() == 9);
|
|
||||||
size_t files = 0;
|
|
||||||
for (auto& e : fs::recursive_directory_iterator(t.path)) files += e.path().extension() == ".jxl";
|
|
||||||
CHECK(files == 9);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("canAccept says no once queue_max frames are waiting, yes once they are saved") {
|
|
||||||
TempDir t;
|
|
||||||
FakeChannel ch;
|
|
||||||
auto p = params(t, 1.0);
|
|
||||||
p.queue_max = 2;
|
|
||||||
ImagePipeline pipe(ch, nullptr, p);
|
|
||||||
CHECK(pipe.canAccept());
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 1));
|
|
||||||
CHECK(pipe.canAccept());
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 2));
|
|
||||||
CHECK_FALSE(pipe.canAccept()); // not started: both still waiting
|
|
||||||
CHECK(pipe.pending() == 2);
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 3)); // never refused
|
|
||||||
CHECK(pipe.pending() == 3);
|
|
||||||
pipe.start();
|
|
||||||
REQUIRE(waitSaved(pipe, 3));
|
|
||||||
pipe.stop();
|
|
||||||
CHECK(pipe.canAccept());
|
|
||||||
CHECK(pipe.pending() == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("a frame shorter than its layout is dropped, not saved") {
|
|
||||||
TempDir t;
|
|
||||||
FakeChannel ch;
|
|
||||||
ImagePipeline pipe(ch, nullptr, params(t));
|
|
||||||
pipe.start();
|
|
||||||
Frame f = bayerFrame(PixelLayout::BayerRG12p, 64, 48, 1, 1, 1, 7);
|
|
||||||
f.data.resize(f.data.size() / 2);
|
|
||||||
pipe.submit(f);
|
|
||||||
pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 1, 1, 1, 8));
|
|
||||||
REQUIRE(waitSaved(pipe, 1));
|
|
||||||
pipe.stop();
|
|
||||||
CHECK(pipe.imagesSaved() == 1);
|
|
||||||
CHECK(ch.count() == 1);
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
#include "fgc/JpegXlEncoder.h"
|
|
||||||
|
|
||||||
#include <doctest/doctest.h>
|
|
||||||
#include "jxl_test_decode.h"
|
|
||||||
|
|
||||||
#include <chrono>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iterator>
|
|
||||||
#include <random>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
using fgc::JpegXlEncoder;
|
|
||||||
using fgc_test::decode;
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
struct TempDir {
|
|
||||||
fs::path path;
|
|
||||||
TempDir() {
|
|
||||||
path = fs::temp_directory_path() /
|
|
||||||
("fgc_jxl_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
||||||
fs::create_directories(path);
|
|
||||||
}
|
|
||||||
~TempDir() { fs::remove_all(path); }
|
|
||||||
std::string file(const std::string& name) const { return (path / name).string(); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// A test image: smooth gradients (what lossy coding keeps well) plus noise
|
|
||||||
// and a few hard edges, in `maxv` levels.
|
|
||||||
std::vector<uint16_t> image(int w, int h, int ch, int maxv, unsigned seed = 1) {
|
|
||||||
std::mt19937 rng(seed);
|
|
||||||
std::normal_distribution<> noise(0.0, maxv / 200.0);
|
|
||||||
std::vector<uint16_t> v(static_cast<size_t>(w) * h * ch);
|
|
||||||
for (int y = 0; y < h; ++y)
|
|
||||||
for (int x = 0; x < w; ++x)
|
|
||||||
for (int c = 0; c < ch; ++c) {
|
|
||||||
double base = maxv * (0.15 + 0.7 * (x + y * (c + 1)) / double(w + h * ch));
|
|
||||||
if ((x / 16 + y / 16) % 7 == 0) base = maxv * 0.9; // edges
|
|
||||||
double val = std::round(base + noise(rng));
|
|
||||||
v[(static_cast<size_t>(y) * w + x) * ch + c] =
|
|
||||||
static_cast<uint16_t>(std::min<double>(maxv, std::max(0.0, val)));
|
|
||||||
}
|
|
||||||
// The extremes must survive too.
|
|
||||||
v.front() = 0;
|
|
||||||
v.back() = static_cast<uint16_t>(maxv);
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<uint8_t> to8(const std::vector<uint16_t>& v) {
|
|
||||||
return std::vector<uint8_t>(v.begin(), v.end());
|
|
||||||
}
|
|
||||||
|
|
||||||
double meanAbsError(const std::vector<uint16_t>& a, const std::vector<uint16_t>& b) {
|
|
||||||
double s = 0;
|
|
||||||
for (size_t i = 0; i < a.size(); ++i) s += std::abs(double(a[i]) - double(b[i]));
|
|
||||||
return s / a.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TEST_CASE("8-bit lossless decodes exactly (known-issues #20)") {
|
|
||||||
TempDir t;
|
|
||||||
for (int ch : {1, 3}) {
|
|
||||||
CAPTURE(ch);
|
|
||||||
const auto src = image(96, 64, ch, 255);
|
|
||||||
const auto b = to8(src);
|
|
||||||
REQUIRE(JpegXlEncoder::encodeToFile(t.file("ll8.jxl"), b.data(), 96, 64, ch, 0.0f, 1, 2));
|
|
||||||
const auto d = decode(t.file("ll8.jxl"), ch);
|
|
||||||
CHECK(d.bits == 8);
|
|
||||||
CHECK(d.original_profile);
|
|
||||||
CHECK(d.px == src);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("12-bit lossless decodes exactly, extremes included") {
|
|
||||||
TempDir t;
|
|
||||||
for (int ch : {1, 3}) {
|
|
||||||
CAPTURE(ch);
|
|
||||||
const auto src = image(96, 64, ch, 4095, 7);
|
|
||||||
REQUIRE(JpegXlEncoder::encodeToFile(t.file("ll12.jxl"), src.data(), 96, 64, ch, 12, 0.0f,
|
|
||||||
1, 2));
|
|
||||||
const auto d = decode(t.file("ll12.jxl"), ch);
|
|
||||||
CHECK(d.bits == 12);
|
|
||||||
CHECK(d.channels == ch);
|
|
||||||
CHECK(d.px == src);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("lossy keeps the bit depth and stays close") {
|
|
||||||
TempDir t;
|
|
||||||
SUBCASE("12-bit colour") {
|
|
||||||
const auto src = image(128, 96, 3, 4095, 3);
|
|
||||||
REQUIRE(JpegXlEncoder::encodeToFile(t.file("l12.jxl"), src.data(), 128, 96, 3, 12, 1.0f,
|
|
||||||
3, 2));
|
|
||||||
const auto d = decode(t.file("l12.jxl"), 3);
|
|
||||||
CHECK(d.bits == 12);
|
|
||||||
CHECK_FALSE(d.original_profile); // lossy goes through XYB, as before
|
|
||||||
REQUIRE(d.px.size() == src.size());
|
|
||||||
CHECK(meanAbsError(d.px, src) < 4095 * 0.02);
|
|
||||||
}
|
|
||||||
SUBCASE("8-bit colour, as the program has always saved") {
|
|
||||||
const auto src = image(128, 96, 3, 255, 3);
|
|
||||||
const auto b = to8(src);
|
|
||||||
REQUIRE(JpegXlEncoder::encodeToFile(t.file("l8.jxl"), b.data(), 128, 96, 3, 0.8f, 4));
|
|
||||||
const auto d = decode(t.file("l8.jxl"), 3);
|
|
||||||
CHECK(d.bits == 8);
|
|
||||||
REQUIRE(d.px.size() == src.size());
|
|
||||||
CHECK(meanAbsError(d.px, src) < 255 * 0.02);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("the thread count changes speed, not the file") {
|
|
||||||
TempDir t;
|
|
||||||
const auto src = image(160, 120, 3, 4095, 5);
|
|
||||||
REQUIRE(JpegXlEncoder::encodeToFile(t.file("t0.jxl"), src.data(), 160, 120, 3, 12, 1.0f, 3, 0));
|
|
||||||
REQUIRE(JpegXlEncoder::encodeToFile(t.file("t4.jxl"), src.data(), 160, 120, 3, 12, 1.0f, 3, 4));
|
|
||||||
std::ifstream a(t.file("t0.jxl"), std::ios::binary), b(t.file("t4.jxl"), std::ios::binary);
|
|
||||||
std::vector<char> fa{std::istreambuf_iterator<char>(a), {}}, fb{std::istreambuf_iterator<char>(b), {}};
|
|
||||||
CHECK(!fa.empty());
|
|
||||||
CHECK(fa == fb);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("bad input is refused and leaves no file") {
|
|
||||||
TempDir t;
|
|
||||||
const auto src = image(16, 16, 3, 255);
|
|
||||||
const auto b = to8(src);
|
|
||||||
const std::string p = t.file("bad.jxl");
|
|
||||||
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 2, 8, 1.0f, 3, 1)); // 2 channels
|
|
||||||
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 3, 7, 1.0f, 3, 1)); // 7 bits
|
|
||||||
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 3, 17, 1.0f, 3, 1)); // 17 bits
|
|
||||||
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 0, 16, 3, 8, 1.0f, 3, 1)); // no width
|
|
||||||
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, b.data(), 16, 16, 3, 8, -1.0f, 3, 1)); // distance
|
|
||||||
CHECK_FALSE(JpegXlEncoder::encodeToFile(p, nullptr, 16, 16, 3, 8, 1.0f, 3, 1)); // no image
|
|
||||||
CHECK_FALSE(fs::exists(p));
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
#include "fgc/RawImage.h"
|
|
||||||
|
|
||||||
#include <doctest/doctest.h>
|
|
||||||
|
|
||||||
#include <random>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
using namespace fgc;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Pack 12-bit values the way the camera does (GenICam 12p).
|
|
||||||
std::vector<uint8_t> pack12p(const std::vector<uint16_t>& v) {
|
|
||||||
std::vector<uint8_t> out((v.size() * 3 + 1) / 2);
|
|
||||||
for (size_t i = 0, o = 0; i < v.size(); i += 2, o += 3) {
|
|
||||||
const uint16_t p0 = v[i];
|
|
||||||
const uint16_t p1 = i + 1 < v.size() ? v[i + 1] : 0;
|
|
||||||
out[o] = static_cast<uint8_t>(p0 & 0xFF);
|
|
||||||
out[o + 1] = static_cast<uint8_t>(((p0 >> 8) & 0x0F) | ((p1 & 0x0F) << 4));
|
|
||||||
if (o + 2 < out.size()) out[o + 2] = static_cast<uint8_t>(p1 >> 4);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TEST_CASE("layout names round-trip, and unknown formats are refused") {
|
|
||||||
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
|
|
||||||
PixelLayout::BayerRG12p})
|
|
||||||
CHECK(layoutFromName(layoutName(l)) == l);
|
|
||||||
CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled
|
|
||||||
CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown);
|
|
||||||
CHECK(std::string(layoutName(PixelLayout::Unknown)) == "Unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("layout properties") {
|
|
||||||
CHECK(layoutIsBayer(PixelLayout::BayerRG8));
|
|
||||||
CHECK(layoutIsBayer(PixelLayout::BayerRG12p));
|
|
||||||
CHECK_FALSE(layoutIsBayer(PixelLayout::RGB8));
|
|
||||||
CHECK(layoutBits(PixelLayout::BayerRG12p) == 12);
|
|
||||||
CHECK(layoutBits(PixelLayout::RGB8) == 8);
|
|
||||||
CHECK(layoutBits(PixelLayout::Unknown) == 0);
|
|
||||||
// The 1620c's full frame, as measured: 24.3 / 16.2 / 48.6 MB.
|
|
||||||
CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 5328, 3040) == 24295680);
|
|
||||||
CHECK(layoutFrameBytes(PixelLayout::BayerRG8, 5328, 3040) == 16197120);
|
|
||||||
CHECK(layoutFrameBytes(PixelLayout::RGB8, 5328, 3040) == 48591360);
|
|
||||||
CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 3, 1) == 5); // odd pixel count rounds up
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("unpack12p matches GenICam 12p, by hand") {
|
|
||||||
// p0 = 0xABC, p1 = 0x123: bytes BC, 3A, 12.
|
|
||||||
const uint8_t in[3] = {0xBC, 0x3A, 0x12};
|
|
||||||
uint16_t out[2] = {};
|
|
||||||
unpack12p(in, out, 2);
|
|
||||||
CHECK(out[0] == 0xABC);
|
|
||||||
CHECK(out[1] == 0x123);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("unpack12p round-trips every value, odd counts included") {
|
|
||||||
std::mt19937 rng(12);
|
|
||||||
std::vector<uint16_t> src(1001);
|
|
||||||
for (auto& v : src) v = static_cast<uint16_t>(rng() % 4096);
|
|
||||||
src[0] = 0;
|
|
||||||
src[1] = 4095;
|
|
||||||
for (size_t n : {size_t(1000), size_t(1001)}) {
|
|
||||||
CAPTURE(n);
|
|
||||||
std::vector<uint16_t> part(src.begin(), src.begin() + n);
|
|
||||||
const auto packed = pack12p(part);
|
|
||||||
std::vector<uint16_t> out(n, 0xFFFF);
|
|
||||||
unpack12p(packed.data(), out.data(), n);
|
|
||||||
CHECK(out == part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("brightness sample: 8-bit layouts") {
|
|
||||||
const uint32_t w = 400, h = 300;
|
|
||||||
SUBCASE("mid grey") {
|
|
||||||
std::vector<uint8_t> f(w * h, 100);
|
|
||||||
auto s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG8, w, h);
|
|
||||||
REQUIRE(s.valid());
|
|
||||||
CHECK(s.mean == doctest::Approx(100));
|
|
||||||
CHECK(s.clipped == 0);
|
|
||||||
}
|
|
||||||
SUBCASE("half blown out, RGB8 mixes the channels") {
|
|
||||||
std::vector<uint8_t> f(w * h * 3, 20);
|
|
||||||
for (size_t i = 0; i < f.size() / 2; ++i) f[i] = 255;
|
|
||||||
auto s = sampleBrightness(f.data(), f.size(), PixelLayout::RGB8, w, h);
|
|
||||||
REQUIRE(s.valid());
|
|
||||||
CHECK(s.clipped == doctest::Approx(0.5).epsilon(0.02));
|
|
||||||
CHECK(s.mean == doctest::Approx((255 + 20) / 2.0).epsilon(0.02));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("brightness sample: 12-bit is reported on the 0..255 scale") {
|
|
||||||
const uint32_t w = 400, h = 300;
|
|
||||||
std::vector<uint16_t> v(w * h, 1600); // 1600 / 16 = 100
|
|
||||||
auto f = pack12p(v);
|
|
||||||
auto s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG12p, w, h);
|
|
||||||
REQUIRE(s.valid());
|
|
||||||
CHECK(s.mean == doctest::Approx(100));
|
|
||||||
CHECK(s.clipped == 0);
|
|
||||||
std::fill(v.begin(), v.end(), 4095);
|
|
||||||
f = pack12p(v);
|
|
||||||
s = sampleBrightness(f.data(), f.size(), PixelLayout::BayerRG12p, w, h);
|
|
||||||
CHECK(s.mean == doctest::Approx(4095 / 16.0));
|
|
||||||
CHECK(s.clipped == 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("brightness sample refuses what it cannot read") {
|
|
||||||
std::vector<uint8_t> f(100, 50);
|
|
||||||
CHECK_FALSE(sampleBrightness(f.data(), f.size(), PixelLayout::Unknown, 10, 10).valid());
|
|
||||||
CHECK_FALSE(sampleBrightness(f.data(), f.size(), PixelLayout::RGB8, 10, 10).valid()); // too short
|
|
||||||
CHECK_FALSE(sampleBrightness(nullptr, 0, PixelLayout::Mono8, 10, 10).valid());
|
|
||||||
CHECK(sampleBrightness(f.data(), f.size(), PixelLayout::Mono8, 10, 10).valid());
|
|
||||||
}
|
|
||||||
|
|
||||||
#include "fgc/mock/MockCameraSource.h"
|
|
||||||
|
|
||||||
TEST_CASE("the mock camera delivers raw frames like a raw-capture profile") {
|
|
||||||
for (PixelLayout l : {PixelLayout::RGB8, PixelLayout::BayerRG8, PixelLayout::BayerRG12p}) {
|
|
||||||
CAPTURE(layoutName(l));
|
|
||||||
MockCameraSource cam(1, 64, 48, l);
|
|
||||||
Frame got;
|
|
||||||
cam.setFrameCallback([&got](const Frame& f) { got = f; });
|
|
||||||
REQUIRE(cam.trigger());
|
|
||||||
CHECK(got.layout == l);
|
|
||||||
CHECK(got.channels == (l == PixelLayout::RGB8 ? 3 : 1));
|
|
||||||
CHECK(got.data.size() == layoutFrameBytes(l, 64, 48));
|
|
||||||
REQUIRE(got.brightness.valid());
|
|
||||||
// The scene's mid grey (110) whatever the layout and bit depth.
|
|
||||||
CHECK(got.brightness.mean == doctest::Approx(110).epsilon(0.15));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,18 +27,6 @@ struct FakeMotor : IMotorController {
|
||||||
struct FakeCamera : ICameraSource {
|
struct FakeCamera : ICameraSource {
|
||||||
int triggers = 0;
|
int triggers = 0;
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
// A stream of frames for the keep rule; empty = a camera with no frame data.
|
|
||||||
std::vector<FrameStats> stream;
|
|
||||||
void deliver(double brightness) {
|
|
||||||
stream.push_back(FrameStats{stream.size() + 1, brightness, 20000, 15});
|
|
||||||
}
|
|
||||||
uint64_t frameSeq() override { return stream.empty() ? 0 : stream.back().seq; }
|
|
||||||
std::vector<FrameStats> framesSince(uint64_t after) override {
|
|
||||||
std::vector<FrameStats> out;
|
|
||||||
for (const auto& f : stream)
|
|
||||||
if (f.seq > after) out.push_back(f);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
void open() override {}
|
void open() override {}
|
||||||
void close() override {}
|
void close() override {}
|
||||||
void start() override {}
|
void start() override {}
|
||||||
|
|
@ -75,35 +63,36 @@ 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); // no exposure wait
|
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
|
||||||
sch.setMode(Mode::Auto);
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
// The move goes out straight away: MOVE to the first grid point (0,0).
|
|
||||||
clock = 10;
|
// Before the interval elapses: nothing moves.
|
||||||
|
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
|
||||||
|
|
||||||
// At rest early: the frame waits for the interval, counted from the mode start.
|
// Report standstill at the target -> camera fires, cursor advances.
|
||||||
settleAt(motor, 0, 0);
|
settleAt(motor, 0, 0);
|
||||||
clock = 100;
|
clock = 1700;
|
||||||
sch.tick(); // records standstill
|
sch.tick(); // first tick records standstill
|
||||||
clock = 200;
|
clock = 1710;
|
||||||
sch.tick(); // exposure settled, interval not over
|
sch.tick(); // next one keeps the frame
|
||||||
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());
|
||||||
|
|
||||||
// And the next move goes out on the next tick: (90 deg -> 900 counts).
|
// Next interval: MOVE to the second grid point (90 deg -> 900 counts).
|
||||||
clock = 1010;
|
clock = 2800;
|
||||||
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") {
|
||||||
|
|
@ -262,12 +251,11 @@ 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, 0.2 /*5000 ms*/, testGeometry(), grid,
|
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
||||||
[&] { return clock; });
|
|
||||||
sch.setSettleWait(1000, 2000);
|
sch.setSettleWait(1000, 2000);
|
||||||
sch.setMode(Mode::Auto);
|
sch.setMode(Mode::Auto);
|
||||||
|
|
||||||
clock = 10;
|
clock = 1600;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
REQUIRE(motor.cmds.size() == 1);
|
REQUIRE(motor.cmds.size() == 1);
|
||||||
|
|
||||||
|
|
@ -276,153 +264,17 @@ 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(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, but the interval still has time left: wait it out.
|
// Past the wait, the newest frame is kept and the sweep advances.
|
||||||
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;
|
||||||
|
|
@ -434,22 +286,20 @@ 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 = 10;
|
clock = 1600;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
settleAt(motor, 0, 0);
|
settleAt(motor, 0, 0);
|
||||||
clock = 100;
|
clock = 1700;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
|
|
||||||
// Due at 1100 (settled + 1000 > interval); retries through the timeout window.
|
// Retries through the timeout window without giving up.
|
||||||
clock = 1100;
|
clock = 3500; // 1800 ms after settling: past the wait, inside the timeout
|
||||||
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 the timeout, the waypoint is skipped rather than blocking the sweep.
|
// Past wait + timeout, the waypoint is skipped rather than blocking the sweep.
|
||||||
clock = 3200;
|
clock = 4800;
|
||||||
sch.tick();
|
sch.tick();
|
||||||
CHECK_FALSE(sch.moving());
|
CHECK_FALSE(sch.moving());
|
||||||
CHECK(grid.index() == 1);
|
CHECK(grid.index() == 1);
|
||||||
|
|
@ -603,11 +453,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: standstill + settle, then the rest of the
|
// The scan's cycle without the move: interval, then standstill + settle, then
|
||||||
// interval since the last frame, then one frame - so the cadence is what a scan
|
// one frame, and the timer restarts from there - 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, in manual") {
|
TEST_CASE("continuous capture keeps one frame per interval + settle, in manual") {
|
||||||
long long clock = 0;
|
long long clock = 0;
|
||||||
FakeMotor motor;
|
FakeMotor motor;
|
||||||
FakeCamera cam;
|
FakeCamera cam;
|
||||||
|
|
@ -620,21 +470,22 @@ TEST_CASE("continuous capture keeps one frame per interval, in manual") {
|
||||||
sch.setContinuous(true);
|
sch.setContinuous(true);
|
||||||
CHECK(sch.continuous());
|
CHECK(sch.continuous());
|
||||||
|
|
||||||
clock = 100; sch.tick(); // standstill seen, settle starts
|
clock = 900; sch.tick(); // inside the interval
|
||||||
clock = 700; sch.tick(); // settled, interval not over
|
|
||||||
CHECK(cam.triggers == 0);
|
CHECK(cam.triggers == 0);
|
||||||
clock = 1000; sch.tick(); // interval over: shoot
|
clock = 1100; sch.tick(); // interval over: standstill seen, settle starts
|
||||||
|
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 one interval on.
|
// The timer restarted at the capture, so the next frame is a whole cycle on.
|
||||||
clock = 1100; sch.tick(); // standstill seen
|
clock = 2600; sch.tick();
|
||||||
clock = 1700; sch.tick(); // settled
|
|
||||||
clock = 1900; sch.tick();
|
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
clock = 2000; sch.tick();
|
clock = 2800; sch.tick(); // interval over, standstill seen
|
||||||
|
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") {
|
||||||
|
|
@ -656,7 +507,6 @@ 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") {
|
||||||
|
|
@ -670,18 +520,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 = 100; sch.tick(); // standstill seen
|
clock = 1100; sch.tick(); // standstill seen
|
||||||
clock = 1000; sch.tick(); // due: tries, no frame
|
clock = 1200; sch.tick(); // tries, no frame
|
||||||
CHECK(cam.triggers == 1);
|
CHECK(cam.triggers == 1);
|
||||||
clock = 2000; sch.tick(); // past the budget: give up, timer restarts
|
clock = 2200; 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(); // standstill seen; next frame not due yet
|
clock = 2500; sch.tick(); // new cycle 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 = 3000; sch.tick(); // one interval after giving up
|
clock = 3300; sch.tick(); // next cycle: standstill seen
|
||||||
|
clock = 3400; sch.tick();
|
||||||
CHECK(cam.triggers == after_giving_up + 1);
|
CHECK(cam.triggers == after_giving_up + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -725,192 +575,3 @@ 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 scan holds at its waypoint while saving is behind, and loses nothing") {
|
|
||||||
long long clock = 0;
|
|
||||||
FakeMotor motor;
|
|
||||||
FakeCamera cam;
|
|
||||||
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
|
||||||
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
|
|
||||||
[&] { return clock; });
|
|
||||||
sch.setSettleWait(0, 0);
|
|
||||||
bool room = true;
|
|
||||||
sch.setSaveGate([&room] { return room; });
|
|
||||||
sch.setMode(Mode::Auto);
|
|
||||||
|
|
||||||
clock = 1100; sch.tick(); // first move
|
|
||||||
REQUIRE(motor.cmds.size() == 1);
|
|
||||||
settleAt(motor, 0, 0);
|
|
||||||
clock = 1200; sch.tick(); // standstill seen
|
|
||||||
clock = 1300; sch.tick(); // keep the frame
|
|
||||||
CHECK(cam.triggers == 1);
|
|
||||||
|
|
||||||
room = false; // saving falls behind
|
|
||||||
clock = 1400; sch.tick(); // the next move is due, but no room
|
|
||||||
clock = 9000; sch.tick();
|
|
||||||
CHECK(motor.cmds.size() == 1); // held: no next move
|
|
||||||
CHECK(sch.holdingForSave());
|
|
||||||
|
|
||||||
room = true; // caught up
|
|
||||||
clock = 9100; sch.tick();
|
|
||||||
REQUIRE(motor.cmds.size() == 2); // moves on to the waypoint it was due
|
|
||||||
CHECK(motor.cmds[1] == "MOVE 900,0");
|
|
||||||
CHECK_FALSE(sch.holdingForSave());
|
|
||||||
|
|
||||||
// The hold is long past the interval, but it was announced as such: that
|
|
||||||
// waypoint is not also an overrun.
|
|
||||||
motor.tel.yaw.standstill = false;
|
|
||||||
clock = 9200; settleAt(motor, 900, 0); sch.tick();
|
|
||||||
clock = 9300; sch.tick();
|
|
||||||
CHECK(cam.triggers == 2);
|
|
||||||
CHECK(sch.overruns() == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("continuous capture holds while saving is behind; a snapshot never does") {
|
|
||||||
long long clock = 0;
|
|
||||||
FakeMotor motor;
|
|
||||||
FakeCamera cam;
|
|
||||||
ScanGrid grid({{0.0, 0.0}});
|
|
||||||
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
|
|
||||||
[&] { return clock; });
|
|
||||||
sch.setSettleWait(0, 2000);
|
|
||||||
settleAt(motor, 0, 0);
|
|
||||||
bool room = false;
|
|
||||||
sch.setSaveGate([&room] { return room; });
|
|
||||||
|
|
||||||
sch.requestSnapshot(); // the operator's own picture
|
|
||||||
clock = 100; sch.tick(); // standstill seen
|
|
||||||
clock = 200; sch.tick();
|
|
||||||
CHECK(cam.triggers == 1); // taken despite the full queue
|
|
||||||
|
|
||||||
sch.setContinuous(true); // the interval counts from here
|
|
||||||
clock = 1500; sch.tick(); // standstill seen
|
|
||||||
clock = 3000; sch.tick(); // settled and due, but no room
|
|
||||||
CHECK(cam.triggers == 1); // held
|
|
||||||
CHECK(sch.holdingForSave());
|
|
||||||
room = true;
|
|
||||||
clock = 3100; sch.tick(); // kept at once: the gimbal never moved
|
|
||||||
CHECK(cam.triggers == 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
// One waypoint of an auto scan, up to the point where the gimbal has stopped.
|
|
||||||
struct RuleRig {
|
|
||||||
long long clock = 0;
|
|
||||||
FakeMotor motor;
|
|
||||||
FakeCamera cam;
|
|
||||||
ScanGrid grid{{{0.0, 0.0}, {90.0, 0.0}}};
|
|
||||||
CaptureScheduler sch{motor, cam, 1.0, testGeometry(), grid, [this] { return clock; }};
|
|
||||||
RuleRig() {
|
|
||||||
sch.setSettleWait(200, 3000);
|
|
||||||
sch.setSettleRule(true);
|
|
||||||
cam.deliver(255); // the stream is running
|
|
||||||
sch.setMode(Mode::Auto);
|
|
||||||
clock = 1100; sch.tick(); // MOVE
|
|
||||||
settleAt(motor, 0, 0);
|
|
||||||
clock = 1200; sch.tick(); // standstill seen
|
|
||||||
clock = 1400; sch.tick(); // mechanical settle over: rule starts
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TEST_CASE("with the keep rule, the frame is kept once brightness has settled") {
|
|
||||||
RuleRig r;
|
|
||||||
for (double b : {230.0, 190.0, 150.0}) { // auto-exposure still coming down
|
|
||||||
r.cam.deliver(b);
|
|
||||||
r.clock += 100; r.sch.tick();
|
|
||||||
}
|
|
||||||
CHECK(r.cam.triggers == 0);
|
|
||||||
for (double b : {126.0, 125.0}) { // in the band, steadying
|
|
||||||
r.cam.deliver(b);
|
|
||||||
r.clock += 100; r.sch.tick();
|
|
||||||
}
|
|
||||||
CHECK(r.cam.triggers == 0); // two frames are not enough
|
|
||||||
r.cam.deliver(125.5);
|
|
||||||
r.clock += 100; r.sch.tick();
|
|
||||||
CHECK(r.cam.triggers == 1);
|
|
||||||
CHECK_FALSE(r.sch.moving()); // waypoint done
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("with the keep rule but no frame data, the fixed wait applies") {
|
|
||||||
long long clock = 0;
|
|
||||||
FakeMotor motor;
|
|
||||||
FakeCamera cam; // no stream
|
|
||||||
ScanGrid grid({{0.0, 0.0}});
|
|
||||||
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
|
|
||||||
sch.setSettleWait(200, 3000);
|
|
||||||
sch.setSettleRule(true);
|
|
||||||
sch.setMode(Mode::Auto);
|
|
||||||
clock = 1100; sch.tick();
|
|
||||||
settleAt(motor, 0, 0);
|
|
||||||
clock = 1200; sch.tick();
|
|
||||||
clock = 1400; sch.tick(); // settle wait over: keep at once
|
|
||||||
CHECK(cam.triggers == 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("with the keep rule, an image that never settles is kept at the time limit") {
|
|
||||||
RuleRig r;
|
|
||||||
for (int i = 0; i < 25; ++i) { // stuck far too bright, mid-way pause
|
|
||||||
r.cam.deliver(157);
|
|
||||||
r.clock += 100; r.sch.tick();
|
|
||||||
}
|
|
||||||
CHECK(r.cam.triggers == 0); // 1400 + 2500 < 1200 + 200 + 3000
|
|
||||||
r.cam.deliver(157);
|
|
||||||
r.clock = 4500; r.sch.tick(); // past settle + timeout
|
|
||||||
CHECK(r.cam.triggers == 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("with the keep rule, a settled image still waits out the rest of the interval") {
|
|
||||||
long long clock = 0;
|
|
||||||
FakeMotor motor;
|
|
||||||
FakeCamera cam;
|
|
||||||
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
|
|
||||||
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
|
|
||||||
[&] { return clock; });
|
|
||||||
sch.setSettleWait(200, 3000);
|
|
||||||
sch.setSettleRule(true);
|
|
||||||
cam.deliver(255);
|
|
||||||
sch.setMode(Mode::Auto);
|
|
||||||
|
|
||||||
clock = 0; sch.tick(); // MOVE
|
|
||||||
settleAt(motor, 0, 0);
|
|
||||||
clock = 100; sch.tick(); // standstill seen
|
|
||||||
clock = 300; sch.tick(); // mechanical settle over: rule starts
|
|
||||||
cam.deliver(126);
|
|
||||||
clock = 400; sch.tick();
|
|
||||||
CHECK(std::string(sch.phase()) == "settling");
|
|
||||||
for (double b : {125.0, 125.5}) {
|
|
||||||
cam.deliver(b);
|
|
||||||
clock += 100; sch.tick();
|
|
||||||
}
|
|
||||||
CHECK(std::string(sch.phase()) == "waiting"); // settled at 600 ms
|
|
||||||
CHECK(cam.triggers == 0);
|
|
||||||
clock = 4990; sch.tick();
|
|
||||||
CHECK(cam.triggers == 0);
|
|
||||||
clock = 5000; sch.tick(); // one interval after the mode started
|
|
||||||
CHECK(cam.triggers == 1);
|
|
||||||
CHECK(sch.overruns() == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("the keep rule starts afresh at each waypoint") {
|
|
||||||
RuleRig r;
|
|
||||||
for (double b : {126.0, 125.0, 125.5}) { // the first waypoint settles
|
|
||||||
r.cam.deliver(b);
|
|
||||||
r.clock += 100; r.sch.tick();
|
|
||||||
}
|
|
||||||
REQUIRE(r.cam.triggers == 1);
|
|
||||||
|
|
||||||
r.clock = 1800; r.sch.tick(); // MOVE to the second
|
|
||||||
REQUIRE(r.motor.cmds.back() == "MOVE 900,0");
|
|
||||||
r.motor.tel.yaw.standstill = false;
|
|
||||||
r.clock = 1900; settleAt(r.motor, 900, 0); r.sch.tick();
|
|
||||||
r.clock = 2100; r.sch.tick(); // settle over: the rule starts again
|
|
||||||
r.cam.deliver(230); // new view, far too bright
|
|
||||||
r.clock = 2800; r.sch.tick(); // interval over, but not settled
|
|
||||||
CHECK(r.cam.triggers == 1);
|
|
||||||
for (double b : {126.0, 125.0, 125.5}) {
|
|
||||||
r.cam.deliver(b);
|
|
||||||
r.clock += 100; r.sch.tick();
|
|
||||||
}
|
|
||||||
CHECK(r.cam.triggers == 2);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
#include "fgc/SettleRule.h"
|
|
||||||
|
|
||||||
#include <doctest/doctest.h>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cmath>
|
|
||||||
#include <fstream>
|
|
||||||
#include <map>
|
|
||||||
#include <sstream>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
using namespace fgc;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
FrameStats frame(double b, double e = 20000, double g = 15) {
|
|
||||||
static uint64_t seq = 0;
|
|
||||||
return FrameStats{++seq, b, e, g};
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Row {
|
|
||||||
double t, exposure, gain, brightness;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The 24 auto-exposure steps recorded on towers 121 and 111 (tests/data/ae_steps.csv).
|
|
||||||
std::map<int, std::vector<Row>> recordedSteps() {
|
|
||||||
std::map<int, std::vector<Row>> runs;
|
|
||||||
std::ifstream f(std::string(FGC_SOURCE_DIR) + "/tests/data/ae_steps.csv");
|
|
||||||
std::string line;
|
|
||||||
while (std::getline(f, line)) {
|
|
||||||
if (line.empty() || line[0] == '#') continue;
|
|
||||||
std::stringstream ss(line);
|
|
||||||
std::string field;
|
|
||||||
std::vector<std::string> v;
|
|
||||||
while (std::getline(ss, field, ',')) v.push_back(field);
|
|
||||||
if (v.size() != 8) continue;
|
|
||||||
runs[std::stoi(v[0])].push_back({std::stod(v[4]), std::stod(v[5]), std::stod(v[6]),
|
|
||||||
std::stod(v[7])});
|
|
||||||
}
|
|
||||||
return runs;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
TEST_CASE("settled: in the camera's band and steady for 3 frames") {
|
|
||||||
SettleRule r; // band 50 +- 5 % of 255 = 114.75 .. 140.25
|
|
||||||
CHECK(r.add(frame(120)) == SettleRule::Verdict::Waiting);
|
|
||||||
CHECK(r.add(frame(121)) == SettleRule::Verdict::Waiting);
|
|
||||||
CHECK(r.add(frame(121.5)) == SettleRule::Verdict::Settled);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("not settled while brightness is still moving through the band") {
|
|
||||||
SettleRule r;
|
|
||||||
r.add(frame(139));
|
|
||||||
r.add(frame(131)); // -6%: still coming down
|
|
||||||
CHECK(r.add(frame(124)) == SettleRule::Verdict::Waiting);
|
|
||||||
CHECK(r.add(frame(122)) == SettleRule::Verdict::Waiting); // 124 -> 122 steady, 131 -> 124 not
|
|
||||||
CHECK(r.add(frame(122)) == SettleRule::Verdict::Settled);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("a pause outside the band is not settled (auto-exposure stalls mid-way)") {
|
|
||||||
SettleRule r;
|
|
||||||
for (int i = 0; i < 10; ++i) CHECK(r.add(frame(157, 20000, 19.6)) == SettleRule::Verdict::Waiting);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("steady but too dark at full exposure and gain: waiting cannot help") {
|
|
||||||
SettleRule::Params p;
|
|
||||||
p.exposure_max_us = 20000;
|
|
||||||
p.gain_max_db = 24;
|
|
||||||
SettleRule r(p);
|
|
||||||
r.add(frame(40, 20000, 24));
|
|
||||||
r.add(frame(40.2, 20000, 24));
|
|
||||||
CHECK(r.add(frame(40.1, 20000, 24)) == SettleRule::Verdict::AtLimit);
|
|
||||||
// Below the limits it is just slow, not stuck.
|
|
||||||
SettleRule s(p);
|
|
||||||
s.add(frame(40, 20000, 12));
|
|
||||||
s.add(frame(40, 20000, 12));
|
|
||||||
CHECK(s.add(frame(40, 20000, 12)) == SettleRule::Verdict::Waiting);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("steady but too bright at minimum exposure and gain") {
|
|
||||||
SettleRule::Params p;
|
|
||||||
p.exposure_min_us = 82;
|
|
||||||
p.gain_min_db = 0;
|
|
||||||
SettleRule r(p);
|
|
||||||
r.add(frame(200, 82, 0));
|
|
||||||
r.add(frame(200, 82, 0));
|
|
||||||
CHECK(r.add(frame(200, 82, 0)) == SettleRule::Verdict::AtLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("a frame without brightness starts the count again") {
|
|
||||||
SettleRule r;
|
|
||||||
r.add(frame(120));
|
|
||||||
r.add(frame(120));
|
|
||||||
CHECK(r.add(frame(-1)) == SettleRule::Verdict::Waiting);
|
|
||||||
CHECK(r.add(frame(120)) == SettleRule::Verdict::Waiting);
|
|
||||||
CHECK(r.add(frame(120)) == SettleRule::Verdict::Waiting);
|
|
||||||
CHECK(r.add(frame(120)) == SettleRule::Verdict::Settled);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("the band follows the camera's target and tolerance") {
|
|
||||||
SettleRule::Params p;
|
|
||||||
p.target_pct = 40;
|
|
||||||
p.tolerance_pct = 10;
|
|
||||||
SettleRule r(p);
|
|
||||||
CHECK(r.bandLow() == doctest::Approx(76.5));
|
|
||||||
CHECK(r.bandHigh() == doctest::Approx(127.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("replayed on the 24 recorded exposure steps, it keeps a good frame at least 21 times") {
|
|
||||||
const auto runs = recordedSteps();
|
|
||||||
REQUIRE(runs.size() == 24);
|
|
||||||
int correct = 0, early = 0, never = 0;
|
|
||||||
for (const auto& [id, rows] : runs) {
|
|
||||||
// Where the run finally settles: median brightness over its last 2 s.
|
|
||||||
std::vector<double> tail;
|
|
||||||
for (const auto& r : rows)
|
|
||||||
if (r.t >= rows.back().t - 2.0) tail.push_back(r.brightness);
|
|
||||||
std::sort(tail.begin(), tail.end());
|
|
||||||
const double final_level = tail[tail.size() / 2];
|
|
||||||
|
|
||||||
SettleRule rule;
|
|
||||||
bool fired = false;
|
|
||||||
for (const auto& r : rows) {
|
|
||||||
if (rule.add(FrameStats{0, r.brightness, r.exposure, r.gain}) !=
|
|
||||||
SettleRule::Verdict::Waiting) {
|
|
||||||
fired = true;
|
|
||||||
(std::fabs(r.brightness / final_level - 1.0) <= 0.10 ? correct : early)++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
never += !fired;
|
|
||||||
}
|
|
||||||
INFO("correct " << correct << ", early " << early << ", never " << never);
|
|
||||||
CHECK(correct >= 21);
|
|
||||||
CHECK(never == 0);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue