Compare commits
3 Commits
a03da2d8f4
...
28ff027950
| Author | SHA1 | Date |
|---|---|---|
|
|
28ff027950 | |
|
|
474e3cac8a | |
|
|
3d90af8490 |
|
|
@ -94,6 +94,10 @@ add_library(fgc_core STATIC
|
||||||
src/core/ChannelHistory.cpp
|
src/core/ChannelHistory.cpp
|
||||||
src/core/ChannelTopics.cpp
|
src/core/ChannelTopics.cpp
|
||||||
src/core/RawImage.cpp
|
src/core/RawImage.cpp
|
||||||
|
src/core/ThermalStats.cpp
|
||||||
|
src/camera/IrbgrabApi.cpp
|
||||||
|
src/camera/TarisCameraSource.cpp
|
||||||
|
src/camera/CompositeCameraSource.cpp
|
||||||
src/core/SettleRule.cpp
|
src/core/SettleRule.cpp
|
||||||
src/core/ThermalGuard.cpp
|
src/core/ThermalGuard.cpp
|
||||||
src/sensors/I2cBus.cpp
|
src/sensors/I2cBus.cpp
|
||||||
|
|
@ -108,7 +112,7 @@ target_include_directories(fgc_core PUBLIC
|
||||||
${CMAKE_SOURCE_DIR}/include
|
${CMAKE_SOURCE_DIR}/include
|
||||||
${CMAKE_SOURCE_DIR} # for the bundled ini.h
|
${CMAKE_SOURCE_DIR} # for the bundled ini.h
|
||||||
)
|
)
|
||||||
target_link_libraries(fgc_core PUBLIC Threads::Threads)
|
target_link_libraries(fgc_core PUBLIC Threads::Threads ${CMAKE_DL_LIBS})
|
||||||
# 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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ demosaic = edge_aware
|
||||||
; generated human-readable record of the same settings; it configures nothing.
|
; generated human-readable record of the same settings; it configures nothing.
|
||||||
[Camera.rgb]
|
[Camera.rgb]
|
||||||
enabled = true
|
enabled = true
|
||||||
driver = vimba ; only vimba is implemented
|
driver = vimba ; vimba (colour) or taris (thermal, below)
|
||||||
id = DEV_0000000000 ; USB DEV_ id, or a GigE IP
|
id = DEV_0000000000 ; USB DEV_ id, or a GigE IP
|
||||||
label = RGB ; output subdir + CamEvent "cam"
|
label = RGB ; output subdir + CamEvent "cam"
|
||||||
profile_dir = camera_profiles/rgb
|
profile_dir = camera_profiles/rgb
|
||||||
|
|
@ -98,6 +98,27 @@ settle_rule = brightness
|
||||||
settle_wait_ms = 2500
|
settle_wait_ms = 2500
|
||||||
frame_timeout_ms = 5000
|
frame_timeout_ms = 5000
|
||||||
|
|
||||||
|
; --- A thermal camera (InfraTec TarisIR mini), beside the colour one ---------
|
||||||
|
; It takes its picture at the same waypoints and intervals, right after the
|
||||||
|
; colour camera, and both carry the same timestamp: RGB/<t>.jxl and TIR/<t>.jxl.
|
||||||
|
; The counts are saved 16-bit and always lossless (jxl_distance must stay 0);
|
||||||
|
; T[K] = count x 0.01 under profile 0, x 0.02 under profile 1, emissivity 1.
|
||||||
|
; Its driver, id (the serial) and interface belong in hardware.ini; [Hardware]
|
||||||
|
; cameras lists it. Needs the InfraTec SDK and licence on the tower, and
|
||||||
|
; cap_net_raw on the binary (scripts/fgc-setcap, which deploy.sh runs) - see
|
||||||
|
; docs/tower-installation.md. No settings XML: profile only names the timing.
|
||||||
|
;[Camera.tir]
|
||||||
|
;label = TIR
|
||||||
|
;calib_profile = 0 ; 0: -40..120 C (forest), 1: 0..600 C
|
||||||
|
;frame_rate_hz = 5 ; the camera free-runs at this, throttled
|
||||||
|
;max_frame_age_ms = 400 ; older is not kept; below the scan's settle wait
|
||||||
|
;rotation_deg = 0
|
||||||
|
;jxl_effort = 3
|
||||||
|
;
|
||||||
|
;[Camera.tir.standard] ; optional: without it 0 / 0, as it should be
|
||||||
|
;settle_wait_ms = 0 ; it takes the frame it already has
|
||||||
|
;frame_timeout_ms = 0
|
||||||
|
|
||||||
[Serial]
|
[Serial]
|
||||||
; Motor-controller serial device and baud rate.
|
; Motor-controller serial device and baud rate.
|
||||||
device = /dev/ttyACM0
|
device = /dev/ttyACM0
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ env = sht41
|
||||||
; The legacy towers: off to 25 C, +20/255 per degree, full from 37.75 C, and full
|
; The legacy towers: off to 25 C, +20/255 per degree, full from 37.75 C, and full
|
||||||
; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm.
|
; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm.
|
||||||
;cooling = fan
|
;cooling = fan
|
||||||
; The [Camera.<name>] blocks expected to be present and found.
|
; The [Camera.<name>] blocks expected to be present and found ("rgb, tir" with
|
||||||
|
; the thermal camera).
|
||||||
cameras = rgb
|
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.
|
||||||
|
|
@ -64,6 +65,7 @@ pan_step_deg = 6
|
||||||
; (N5105) needs 2 workers for that. jxl_threads is libjxl's threads per frame;
|
; (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
|
; 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).
|
; or be saving before the scan holds at its waypoint (each raw frame ~24 MB).
|
||||||
|
; A thermal camera adds a frame per picture (0.6 MB): raise it by 2.
|
||||||
save_workers = 1
|
save_workers = 1
|
||||||
;jxl_threads = 0
|
;jxl_threads = 0
|
||||||
save_queue_max = 4
|
save_queue_max = 4
|
||||||
|
|
@ -247,3 +249,11 @@ has_encoder = true
|
||||||
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
|
; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a
|
||||||
; GigE IP address.
|
; GigE IP address.
|
||||||
id = DEV_1AB22C049CD1
|
id = DEV_1AB22C049CD1
|
||||||
|
|
||||||
|
; A thermal camera (TarisIR mini): its serial number, as the SDK's search finds
|
||||||
|
; it, and the network port it is cabled to (static address, no DHCP, MTU 1500;
|
||||||
|
; only used to say what is wrong when the camera is not found).
|
||||||
|
;[Camera.tir]
|
||||||
|
;driver = taris
|
||||||
|
;id = 2017744
|
||||||
|
;interface = enp1s0
|
||||||
|
|
|
||||||
|
|
@ -308,6 +308,10 @@ ssh "$REMOTE_HOST" "$RESOLVE_CMAKE $CHECK_DEPS_CMD cd '$REMOTE_DIR' && \
|
||||||
GIT_REV='$GIT_REV' \"\$CMAKE\" -S . -B build $CMAKE_ARGS && \
|
GIT_REV='$GIT_REV' \"\$CMAKE\" -S . -B build $CMAKE_ARGS && \
|
||||||
\"\$CMAKE\" --build build $JOBS_FLAG"
|
\"\$CMAKE\" --build build $JOBS_FLAG"
|
||||||
echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control"
|
echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control"
|
||||||
|
# The rebuilt binary has lost its capabilities; a thermal camera needs
|
||||||
|
# cap_net_raw back (scripts/fgc-setcap). Without a thermal camera: nothing.
|
||||||
|
# shellcheck disable=SC2029
|
||||||
|
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && scripts/fgc-setcap --if-thermal" || true
|
||||||
|
|
||||||
# A pushed config.ini was written on another machine: check that it loads on
|
# A pushed config.ini was written on another machine: check that it loads on
|
||||||
# this one, with this tower's hardware.ini layered over it, before anyone runs it.
|
# this one, with this tower's hardware.ini layered over it, before anyone runs it.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ The design separates **policy** (the control logic) from **mechanism** (the I/O
|
||||||
|-----------|------|-------------|
|
|-----------|------|-------------|
|
||||||
| `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) |
|
| `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) |
|
||||||
| `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) |
|
| `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) |
|
||||||
| `ICameraSource` | `VimbaCameraSource` (Vimba X) | `MockCameraSource` (synthetic frames) |
|
| `ICameraSource` | `VimbaCameraSource` (Vimba X), `TarisCameraSource` (InfraTec TarisIR, thermal), both behind `CompositeCameraSource` | `MockCameraSource` (synthetic frames, also thermal counts) |
|
||||||
| `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) |
|
| `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) |
|
||||||
| `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) |
|
| `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) |
|
||||||
|
|
||||||
|
|
@ -54,6 +54,7 @@ thread so all geometry/state mutation stays single-threaded.
|
||||||
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
|
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
|
||||||
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) |
|
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) |
|
||||||
| Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one |
|
| Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one |
|
||||||
|
| Thermal acquisition | `TarisCameraSource` worker | owns every SDK call: connects (and reconnects), polls the newest frame of counts, switches the calibration profile; `trigger()` only takes the kept frame |
|
||||||
|
|
||||||
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue
|
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue
|
||||||
(pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the
|
(pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the
|
||||||
|
|
@ -132,6 +133,23 @@ everything below is an implementation detail of the camera source. That is what
|
||||||
|
|
||||||
## Image capture
|
## Image capture
|
||||||
|
|
||||||
|
### Two cameras, one array: `CompositeCameraSource`
|
||||||
|
|
||||||
|
With a thermal camera the scheduler and the pipeline still see one
|
||||||
|
`ICameraSource`. The composite numbers the colour cameras first (camera 0 is
|
||||||
|
the one the keep rule reads) and the thermal one after them. Its `trigger()`
|
||||||
|
asks the colour source first; only if that delivered does it ask the thermal
|
||||||
|
source, which hands over its newest frame if it is no older than
|
||||||
|
`max_frame_age_ms` and otherwise says no at once. Every frame of one trigger
|
||||||
|
takes the colour frame's timestamp. The colour camera failing stops the run as
|
||||||
|
before; the thermal one failing never does (`offline()` says why, Health
|
||||||
|
`cams_offline` names it). Thermal frames are `Mono16` counts: the pipeline
|
||||||
|
rotates them, takes `tmax`/`tmed`/hot spot on the rotated image
|
||||||
|
(`ThermalStats.h`), and saves them 16-bit lossless whatever `jxl_distance` says.
|
||||||
|
The SDK is declared in `IrbgrabApi.h` (not copied: its licence forbids that) and
|
||||||
|
loaded with `dlopen`, so every build has the thermal code and its tests run
|
||||||
|
against a fake SDK.
|
||||||
|
|
||||||
### The model: free-run, then hold still
|
### The model: free-run, then hold still
|
||||||
|
|
||||||
The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in
|
The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,10 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) |
|
| `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) |
|
||||||
| `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
|
| `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
|
||||||
| `Serial` | `baud` | int | `115200` | Serial baud rate |
|
| `Serial` | `baud` | int | `115200` | Serial baud rate |
|
||||||
| `Camera.<name>` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled |
|
| `Camera.<name>` | `id` | string | — | `vimba`: USB `DEV_` id or GigE IP; `taris`: the camera's serial number. Required when enabled |
|
||||||
| `Camera.<name>` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented |
|
| `Camera.<name>` | `driver` | `vimba` \| `taris` | `vimba` | Backend: Allied Vision Vimba X (colour), or InfraTec TarisIR (thermal, [below](#thermal-camera-driver--taris)). Colour cameras always come first in the camera order, whatever the block names |
|
||||||
| `Camera.<name>` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field |
|
| `Camera.<name>` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field |
|
||||||
| `Camera.<name>` | `profile_dir` | string | — | Directory of profile XMLs; a profile named X is `<profile_dir>/X.xml`. A relative path resolves against the directory holding the `config.ini` in use |
|
| `Camera.<name>` | `profile_dir` | string | — | `vimba` only. Directory of profile XMLs; a profile named X is `<profile_dir>/X.xml`. A relative path resolves against the directory holding the `config.ini` in use |
|
||||||
| `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` |
|
| `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` |
|
||||||
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
|
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
|
||||||
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
|
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
|
||||||
|
|
@ -75,6 +75,11 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| `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. Distance 0 is truly lossless |
|
||||||
| `Camera` / `Camera.<name>` | `demosaic` | `edge_aware` \| `bilinear` | `edge_aware` | How raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower |
|
| `Camera` / `Camera.<name>` | `demosaic` | `edge_aware` \| `bilinear` | `edge_aware` | How raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower |
|
||||||
|
| `Camera.<name>` (`taris`) | `calib_profile` | int ≥ 0 | `0` | Calibration profile set on connect: 0 = -40..120 °C at 0.01 K/count, 1 = 0..600 °C at 0.02 K/count. Switch live with `camera calib <n>` |
|
||||||
|
| `Camera.<name>` (`taris`) | `frame_rate_hz` | 0 < x ≤ 50 | `5` | The rate the camera free-runs at |
|
||||||
|
| `Camera.<name>` (`taris`) | `max_frame_age_ms` | int > 0 | `400` | A frame older than this when the picture is taken is not kept: that picture has no thermal image. Keep it below the scan's settle wait, or the frame can predate standstill (warned at startup) |
|
||||||
|
| `Camera.<name>` (`taris`) | `library` | string | `libirbgrab_x86_64-linux.so` | The InfraTec SDK, found through the ld cache |
|
||||||
|
| `Camera.<name>` (`taris`, hardware.ini) | `interface` | string | — | The network port the camera is cabled to; only named in the message when it is not found |
|
||||||
| `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) | `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) | `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) |
|
| `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) |
|
||||||
|
|
@ -1054,3 +1059,29 @@ grid (front to back, repeating); in **pan** mode it walks the pan's headings lef
|
||||||
pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as
|
pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as
|
||||||
firmware `ST` lines (per-axis state + encoder counts), parsed by
|
firmware `ST` lines (per-axis state + encoder counts), parsed by
|
||||||
[TelemetryParser](../src/core/TelemetryParser.cpp).
|
[TelemetryParser](../src/core/TelemetryParser.cpp).
|
||||||
|
|
||||||
|
## Thermal camera (`driver = taris`)
|
||||||
|
|
||||||
|
A TarisIR mini beside the colour camera takes its picture at the same waypoints
|
||||||
|
and intervals: the colour camera is asked first, and only once it has delivered
|
||||||
|
is the thermal one asked for its newest frame. Both frames carry the colour
|
||||||
|
frame's timestamp, so `RGB/<t>.jxl` and `TIR/<t>.jxl` are a pair (and so are
|
||||||
|
their CamEvents, see [mqtt-api.md](mqtt-api.md#thermal-camera-cam-tir)).
|
||||||
|
|
||||||
|
- **Best effort.** A thermal camera that is missing, unlicensed or has no fresh
|
||||||
|
frame costs that picture its thermal image, nothing more: the scan goes on, the
|
||||||
|
camera reconnects by itself, and Health `cams_offline` names it meanwhile.
|
||||||
|
- **Always lossless.** The counts are measurements; `jxl_distance` must be 0
|
||||||
|
(the default for this driver) and the loader refuses anything else.
|
||||||
|
- **No settings XML.** The camera is set up from its keys; `profile` only picks a
|
||||||
|
`[Camera.<name>.<profile>]` timing block, which it does not need: without one
|
||||||
|
its wait is 0 and the colour camera's settle wait alone sets the pace. The
|
||||||
|
frame it hands over is at most `max_frame_age_ms` old, so that must stay below
|
||||||
|
the colour camera's `settle_wait_ms`.
|
||||||
|
- **Kept grabbing**: from start to exit, also in manual mode. A microbolometer
|
||||||
|
that is restarted begins with zero frames and a fresh NUC.
|
||||||
|
- **Split between the files** like any camera: `driver`, `id` and `interface`
|
||||||
|
in hardware.ini (with `tir` in `[Hardware] cameras`), the rest in config.ini.
|
||||||
|
`save_queue_max` counts frames, so raise it by 2.
|
||||||
|
- The binary needs `cap_net_raw` (the SDK uses a raw socket):
|
||||||
|
[tower-installation.md](tower-installation.md#phase-2b---thermal-camera).
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,22 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
|
||||||
key features (`AcquisitionFrameRate`, `Width`, `PixelFormat`) would turn a silent misconfiguration into
|
key features (`AcquisitionFrameRate`, `Width`, `PixelFormat`) would turn a silent misconfiguration into
|
||||||
an error.
|
an error.
|
||||||
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
|
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
|
||||||
- Support a second camera backend. `driver` is parsed and validated but only `vimba` is accepted, so a
|
- ~~Support a second camera backend.~~ Done for the thermal camera (`driver = taris`,
|
||||||
thermal or NIR unit needs a new `ICameraSource` implementation behind that key.
|
`TarisCameraSource`, behind `CompositeCameraSource`), tested with the TarisIR mini on tower 111 on
|
||||||
|
2026-10-14 (docs/tower-installation.md, Phase 2b). Not yet tested: a pulled camera cable during a scan
|
||||||
|
(the reconnect is only unit-tested), and a long unattended run. A NIR unit would be a third driver
|
||||||
|
behind the same key.
|
||||||
|
- The InfraTec SDK costs about a quarter of one core on the 3 Delta even between pictures: three threads
|
||||||
|
it starts in `dll_init` and one per connection poll the raw socket in `nanosleep` loops (measured on
|
||||||
|
tower 111, 2026-10-14). The frame rate does not change it. In the first minutes after deploying, the
|
||||||
|
CPU guard slowed the scan once (86 °C, back to normal after 2 min). Watch Health `cpu_c` over a warm
|
||||||
|
day before a tower without air flow gets a thermal camera.
|
||||||
|
- Tower 111's colour profile does not apply: every start since 2026-10-07 logs `applying profile 'standard'
|
||||||
|
from .../rgb/standard.xml failed (error -39)`, and the camera runs on whatever it had. Unrelated to the
|
||||||
|
thermal camera; seen again while testing it.
|
||||||
|
- Per-channel boresight: `[Motor]` heading and tilt calibration belong to the tower, but the thermal camera
|
||||||
|
sits on the gimbal with its own small offset against the colour one. `hotx`/`hoty` are therefore in the
|
||||||
|
thermal image's own frame; projecting them onto terrain needs a per-camera offset nobody has measured yet.
|
||||||
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
|
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
|
||||||
removed).
|
removed).
|
||||||
- **Calibration persistence needs the TUI.** `gimbal pitch setup` and `gimbal heading fit` are always
|
- **Calibration persistence needs the TUI.** `gimbal pitch setup` and `gimbal heading fit` are always
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| `fwt` | string | Tower name (`config.ini` `tower_name`) |
|
| `fwt` | string | Tower name (`config.ini` `tower_name`) |
|
||||||
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
|
| `cam` | string | Camera label (`[Camera.<name>] label`): `RGB`, `TIR`, `ACR` or `NIR` |
|
||||||
| `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** |
|
| `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** |
|
||||||
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) |
|
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) |
|
||||||
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
|
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
|
||||||
|
|
@ -135,6 +135,40 @@ Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG X
|
||||||
finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
|
finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
|
||||||
pixels.
|
pixels.
|
||||||
|
|
||||||
|
#### Thermal camera (`cam: "TIR"`)
|
||||||
|
|
||||||
|
A thermal camera's events carry six more fields; a colour camera's never do, and
|
||||||
|
their payload is unchanged.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "fwt":"ExampleTower", "cam":"TIR", "hdg":1373, "pit":300, "time":1719312345678,
|
||||||
|
"cal":0, "missed":0, "tmax":3015, "tmed":187, "hotx":0.7359, "hoty":0.5833 }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `cal` | int | Calibration profile the image was taken on. **Required to read the file**: it sets the counts' scale |
|
||||||
|
| `missed` | int | GigE packets the SDK lost for this frame (transfer quality) |
|
||||||
|
| `tmax` | int/null | Hottest pixel, **apparent** temperature in tenths of a °C |
|
||||||
|
| `tmed` | int/null | Median of the image, tenths of a °C (the anchor for a display window) |
|
||||||
|
| `hotx`, `hoty` | number/null | Where the hottest pixel is: its centre, 0..1 across and down the image **as stored** (after `rotation_deg`), as for detection boxes |
|
||||||
|
|
||||||
|
- **Same `time` as the colour picture.** The thermal camera is asked right after
|
||||||
|
the colour one, at the same waypoint, and its event and file take the colour
|
||||||
|
frame's timestamp: `RGB/<time>.jxl` and `TIR/<time>.jxl` are one pair. A
|
||||||
|
picture the thermal camera had no fresh frame for has no `TIR` event.
|
||||||
|
- **The file holds counts**, 16-bit single channel, lossless:
|
||||||
|
`T[K] = count × 0.01` under `cal` 0 (`FF (-40-120C)`), `× 0.02` under `cal` 1
|
||||||
|
(`FF (0-600C)`), valid for `count ≤ 65280` (above it the camera's table wraps;
|
||||||
|
0 is never a temperature).
|
||||||
|
- **Apparent temperature**: emissivity and path transmission are taken as 1.
|
||||||
|
Vegetation (ε ≈ 0.97) over kilometres of air reads a few kelvin off its true
|
||||||
|
temperature; the counts keep every correction possible later. `tmax`/`tmed`
|
||||||
|
use the same convention, so they are for triage and display, not for a
|
||||||
|
reported fire temperature.
|
||||||
|
- `null` in the four statistics means the frame had no valid pixel; `cal` and
|
||||||
|
`missed` are still set.
|
||||||
|
|
||||||
CamEvent and Health leave through a small queue on their own thread, so a slow link
|
CamEvent and Health leave through a small queue on their own thread, so a slow link
|
||||||
never holds an image save or the control loop. If 64 are waiting, the oldest is
|
never holds an image save or the control loop. If 64 are waiting, the oldest is
|
||||||
dropped (and shows as dropped in the connectivity view).
|
dropped (and shows as dropped in the connectivity view).
|
||||||
|
|
@ -149,8 +183,8 @@ The tower computer's heat and load, and whether the scan is slowed or paused for
|
||||||
{"fwt":"t121","state":"paused","reason":"CPU at 93 °C for 20 s","guard":true,
|
{"fwt":"t121","state":"paused","reason":"CPU at 93 °C for 20 s","guard":true,
|
||||||
"cpu_c":93.0,"cpu_mhz":1188,"cpu_busy_pct":41,"throttling":true,
|
"cpu_c":93.0,"cpu_mhz":1188,"cpu_busy_pct":41,"throttling":true,
|
||||||
"throttle_events_last_min":14,"enclosure_c":48.2,"saved_last_min":0,
|
"throttle_events_last_min":14,"enclosure_c":48.2,"saved_last_min":0,
|
||||||
"save_ms_mean":null,"save_ms_max":null,"held_s":0,"interval_s":10.0,
|
"save_ms_mean":null,"save_ms_max":null,"cams_offline":"","held_s":0,
|
||||||
"time":1759917600000}
|
"interval_s":10.0,"time":1759917600000}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
|
|
@ -164,7 +198,8 @@ The tower computer's heat and load, and whether the scan is slowed or paused for
|
||||||
| `throttling` | bool | The CPU is slowing itself (kernel counters, or a low clock while busy) |
|
| `throttling` | bool | The CPU is slowing itself (kernel counters, or a low clock while busy) |
|
||||||
| `throttle_events_last_min` | int | Kernel throttle events counted in the last minute |
|
| `throttle_events_last_min` | int | Kernel throttle events counted in the last minute |
|
||||||
| `enclosure_c` | number/null | The ambient sensor (SHT41/DHT11), when there is one |
|
| `enclosure_c` | number/null | The ambient sensor (SHT41/DHT11), when there is one |
|
||||||
| `saved_last_min`, `save_ms_mean`, `save_ms_max` | int, number/null | Pictures saved in the last minute and how long each took |
|
| `saved_last_min`, `save_ms_mean`, `save_ms_max` | int, number/null | Pictures saved in the last minute and how long each took (every camera's: with a thermal camera, two per waypoint) |
|
||||||
|
| `cams_offline` | string | Labels of cameras not delivering, comma-separated (`"TIR"`); `""` when all are. Only a camera the scan can do without - the thermal one - is ever listed: it reconnects by itself while the scan goes on |
|
||||||
| `held_s` | int | How long the scan has been waiting for saving to catch up |
|
| `held_s` | int | How long the scan has been waiting for saving to catch up |
|
||||||
| `interval_s` | number | Time between pictures now |
|
| `interval_s` | number | Time between pictures now |
|
||||||
| `time` | int | Unix epoch ms |
|
| `time` | int | Unix epoch ms |
|
||||||
|
|
|
||||||
|
|
@ -884,6 +884,77 @@ Then `mode auto`, and confirm `.jxl` files appear under the configured output di
|
||||||
> running an older binary, export the profiles (step 8) first or it will refuse
|
> running an older binary, export the profiles (step 8) first or it will refuse
|
||||||
> with `no camera profiles (*.xml)`.
|
> with `no camera profiles (*.xml)`.
|
||||||
|
|
||||||
|
### Phase 2b - thermal camera
|
||||||
|
|
||||||
|
Only for a tower with an InfraTec TarisIR mini (tower 111 since 2026-10). The
|
||||||
|
program runs as the tower's user; the SDK needs three things from the system.
|
||||||
|
|
||||||
|
1. **SDK and licence.** Packages `infratec-sdk-irbgrab` and
|
||||||
|
`infratec-itcamtarisirmini` (amd64), then the licence for the camera's serial
|
||||||
|
via the vendor's `copycustomerdata.sh` into `/var/lib/infratec/.license/`
|
||||||
|
(`root:infratec`, `rw-rw----`). **Repeat after every OS update.** Keep the
|
||||||
|
source files on the tower (`~/InfraTec/LINUX/` on 111) so it can be re-run.
|
||||||
|
The tower's user must be in group `infratec` (`id` shows it; `sudo usermod -aG
|
||||||
|
infratec ggs` and log in again otherwise). The library must be in the ld
|
||||||
|
cache: `ldconfig -p | grep irbgrab`.
|
||||||
|
2. **Network.** Camera and port directly cabled, static address, **no DHCP**
|
||||||
|
(`dhcp4: false` in netplan: DHCP on a link without a server keeps failing and
|
||||||
|
takes the static address with it), **MTU 1500** - the camera does no jumbo
|
||||||
|
frames. The SDK finds the camera by serial and MAC on raw Ethernet; the IP
|
||||||
|
only matters to ordinary GigE Vision tools. On 111: `enp1s0`, 192.168.0.111,
|
||||||
|
camera 192.168.0.200.
|
||||||
|
3. **`cap_net_raw` on the binary.** The SDK opens a raw `AF_PACKET` socket.
|
||||||
|
Rather than running the program as root, the binary gets this one
|
||||||
|
capability. A rebuild drops it, so `deploy.sh` and `install-tower` run
|
||||||
|
`scripts/fgc-setcap --if-thermal` after every build. Once per tower, let
|
||||||
|
deploy do that without a password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/fgc-setcap --install-sudoers # /etc/sudoers.d/fire-gimbal-control-setcap
|
||||||
|
scripts/fgc-setcap && getcap build/fire_gimbal_control
|
||||||
|
```
|
||||||
|
|
||||||
|
A capability binary runs in the loader's secure mode (`LD_*` ignored, no core
|
||||||
|
dumps): check once that Vimba X still opens the colour camera, and that
|
||||||
|
`scripts/fgc status` and `stop` still find the process.
|
||||||
|
|
||||||
|
Then hardware.ini (`driver = taris`, `id = <serial>`, `interface = enp1s0`,
|
||||||
|
`cameras = rgb, tir`) and config.ini (`label = TIR`, see
|
||||||
|
[configuration.md](configuration.md#thermal-camera-driver--taris)), and a run
|
||||||
|
with the real cameras but no motors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build/fire_gimbal_control --mock-serial --mock-env --no-mqtt --no-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO ] IRBGRAB SDK 4.3.44
|
||||||
|
[INFO ] thermal camera connected via SN:2017744|...|192.168.0.200#0
|
||||||
|
[INFO ] thermal camera: profile 0, 0.01 K per count
|
||||||
|
```
|
||||||
|
|
||||||
|
`snapshot` then writes `RGB/<t>.jxl` and `TIR/<t>.jxl` with the same `<t>`.
|
||||||
|
`camera calib` reports the profile. If the camera stays offline the log says
|
||||||
|
why, once per change - `no cap_net_raw`, `not found (found: ...)`, the port's
|
||||||
|
state and MTU. A `0x80000017` that keeps coming is the licence before it is the
|
||||||
|
NUC.
|
||||||
|
|
||||||
|
Measured on tower 111 (2026-10-14, SDK 4.3.44, SN 2017744):
|
||||||
|
|
||||||
|
- `cap_net_raw` alone is enough; no root, no `cap_net_admin`. Vimba X still
|
||||||
|
opens the colour camera from the capability binary.
|
||||||
|
- Connecting takes about 15 s (SDK init and search); the scan does not wait.
|
||||||
|
- `Framerate_Hz` is settable. The LUT is empty for the first frame after
|
||||||
|
connecting and right from then on: 0.0099998 K/count (profile 0), 0.0199995
|
||||||
|
(profile 1).
|
||||||
|
- A profile switch takes about 3 s; frames keep the old scale until
|
||||||
|
`Calib_ChangeInProgress` reaches 0, and the program takes none meanwhile.
|
||||||
|
- The camera's frame counter (`ImageNum`) is always 0; `MissedPackets` was
|
||||||
|
always 0 on the direct link.
|
||||||
|
- The SDK switches the process's C locale to `LC_NUMERIC=de_DE` when it is
|
||||||
|
loaded. The program puts it back after every SDK call and writes its JSON
|
||||||
|
without the C library's number formatting.
|
||||||
|
|
||||||
### Phase 3 - the broker
|
### Phase 3 - the broker
|
||||||
|
|
||||||
Set `[Features] enable_mqtt = true` and point `zkms_server_ip` at the broker.
|
Set `[Features] enable_mqtt = true` and point `zkms_server_ip` at the broker.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "fgc/ICameraSource.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
|
||||||
|
// Several camera sources behind one, so the scheduler and the pipeline keep
|
||||||
|
// seeing a single camera array: the colour cameras through Vimba and the
|
||||||
|
// thermal one through its own SDK take their pictures at the same waypoints
|
||||||
|
// and intervals without either knowing about the other.
|
||||||
|
//
|
||||||
|
// The first child leads. Its cameras come first in the index order (camera 0
|
||||||
|
// is its first camera, which the keep rule reads), and its trigger() decides
|
||||||
|
// whether a picture was taken: only once it has delivered are the others
|
||||||
|
// asked, so a retry while the leader waits never yields a second thermal
|
||||||
|
// frame for the same waypoint. The others are best effort - a follower with
|
||||||
|
// no fresh frame costs that picture its thermal image, nothing more.
|
||||||
|
//
|
||||||
|
// Every frame of one trigger carries the leader's timestamp, so the pair is
|
||||||
|
// RGB/<t>.jxl and TIR/<t>.jxl with the same <t> in both CamEvents.
|
||||||
|
//
|
||||||
|
// Indices are global: child k's camera i is offset(k) + i. Calls about one
|
||||||
|
// camera go to the child that has it.
|
||||||
|
//
|
||||||
|
// The offsets come from `counts`, how many cameras each child is configured
|
||||||
|
// with, not from cameraCount(): VimbaCameraSource counts what it has opened,
|
||||||
|
// which is 0 before open(), and a thermal frame numbered from that landed as
|
||||||
|
// camera 0 - the colour camera's label and file (seen on tower 111). Without
|
||||||
|
// counts, each child's cameraCount() at construction is taken.
|
||||||
|
class CompositeCameraSource : public ICameraSource {
|
||||||
|
public:
|
||||||
|
explicit CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
|
||||||
|
std::vector<int> counts = {});
|
||||||
|
|
||||||
|
void open() override;
|
||||||
|
void close() override;
|
||||||
|
void start() override;
|
||||||
|
void stop() override;
|
||||||
|
bool trigger() override;
|
||||||
|
bool setProfile(int camera_index, const CameraProfile& profile) override;
|
||||||
|
void setFrameCallback(FrameCallback cb) 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;
|
||||||
|
int cameraCount() const override;
|
||||||
|
std::vector<CameraDeviceInfo> deviceInfo() override;
|
||||||
|
std::string offline(int camera_index) 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 featureRange(int camera_index, const std::string& name, double& min, double& max) override;
|
||||||
|
bool restoreProfile(int camera_index) override;
|
||||||
|
bool resetCamera(int camera_index) override;
|
||||||
|
|
||||||
|
size_t childCount() const { return children_.size(); }
|
||||||
|
ICameraSource& child(size_t k) { return *children_[k]; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
// The child holding a global index and its index there; nullptr if none.
|
||||||
|
ICameraSource* route(int camera_index, int& local) const;
|
||||||
|
|
||||||
|
std::vector<std::unique_ptr<ICameraSource>> children_;
|
||||||
|
std::vector<int> counts_; // cameras each child is configured with
|
||||||
|
std::vector<int> offsets_; // global index of each child's camera 0
|
||||||
|
FrameCallback callback_;
|
||||||
|
bool in_trigger_ = false;
|
||||||
|
long long shot_ts_ = 0; // the leader's frame time, during a trigger
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -199,11 +199,14 @@ struct CameraProfileTiming {
|
||||||
struct CameraSpec {
|
struct CameraSpec {
|
||||||
std::string name; // the block suffix, e.g. "rgb"
|
std::string name; // the block suffix, e.g. "rgb"
|
||||||
bool enabled = true;
|
bool enabled = true;
|
||||||
std::string driver = "vimba"; // only "vimba" is implemented
|
// "vimba" (Allied Vision, colour) or "taris" (InfraTec TarisIR, thermal).
|
||||||
std::string id; // USB DEV_ id or GigE IP
|
std::string driver = "vimba";
|
||||||
|
std::string id; // vimba: USB DEV_ id or GigE IP; taris: serial number
|
||||||
std::string label; // output subdir + CamEvent "cam"; defaults to name
|
std::string label; // output subdir + CamEvent "cam"; defaults to name
|
||||||
std::string profile_dir; // relative => resolved next to config.ini
|
std::string profile_dir; // vimba only; relative => resolved next to config.ini
|
||||||
std::string profile = "standard"; // which profile to start in
|
// Which profile to start in. For vimba a settings XML in profile_dir; for
|
||||||
|
// taris only the name of a timing block, the camera having no settings file.
|
||||||
|
std::string profile = "standard";
|
||||||
int rotation_deg = 0; // clockwise; 0/90/180/270
|
int rotation_deg = 0; // clockwise; 0/90/180/270
|
||||||
|
|
||||||
// Encoding, defaulted from [Camera] and overridable per camera.
|
// Encoding, defaulted from [Camera] and overridable per camera.
|
||||||
|
|
@ -212,6 +215,19 @@ struct CameraSpec {
|
||||||
// De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear".
|
// De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear".
|
||||||
std::string demosaic = "edge_aware";
|
std::string demosaic = "edge_aware";
|
||||||
|
|
||||||
|
// ---- driver = taris only (TarisCameraSource.h) ----
|
||||||
|
// Operation (config.ini):
|
||||||
|
int calib_profile = 0; // camera calibration profile; sets the counts' scale
|
||||||
|
double frame_rate_hz = 5.0; // the camera free-runs at this rate, throttled
|
||||||
|
// A frame older than this at capture is not kept. Must stay below the scan's
|
||||||
|
// settle wait, or the frame may have been taken while the gimbal still moved.
|
||||||
|
int max_frame_age_ms = 400;
|
||||||
|
std::string library = "libirbgrab_x86_64-linux.so"; // the SDK, found through the ld cache
|
||||||
|
// Mounting (hardware.ini): the camera's network port, for diagnostics only.
|
||||||
|
std::string interface;
|
||||||
|
|
||||||
|
bool thermal() const { return driver == "taris"; }
|
||||||
|
|
||||||
std::map<std::string, CameraProfileTiming> timing; // by profile name
|
std::map<std::string, CameraProfileTiming> timing; // by profile name
|
||||||
|
|
||||||
// Timing for `profile`, or the defaults when the block does not name it.
|
// Timing for `profile`, or the defaults when the block does not name it.
|
||||||
|
|
@ -219,7 +235,10 @@ struct CameraSpec {
|
||||||
};
|
};
|
||||||
|
|
||||||
struct CameraConfig {
|
struct CameraConfig {
|
||||||
std::vector<CameraSpec> cameras; // in config order; empty is a config error
|
// Colour (vimba) cameras first, then thermal ones, each group in block-name
|
||||||
|
// order: camera 0 is the one the keep rule and the scan pace follow, and
|
||||||
|
// that must be a colour camera. Empty is a config error.
|
||||||
|
std::vector<CameraSpec> cameras;
|
||||||
// [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;
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
#include "fgc/CameraProfile.h"
|
#include "fgc/CameraProfile.h"
|
||||||
#include "fgc/RawImage.h"
|
#include "fgc/RawImage.h"
|
||||||
#include "fgc/SettleRule.h"
|
#include "fgc/SettleRule.h"
|
||||||
|
#include "fgc/ThermalStats.h"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
|
@ -32,6 +34,8 @@ struct Frame {
|
||||||
// Sampled from `data` on arrival (see RawImage.h); invalid when the layout
|
// Sampled from `data` on arrival (see RawImage.h); invalid when the layout
|
||||||
// is Unknown.
|
// is Unknown.
|
||||||
BrightnessSample brightness;
|
BrightnessSample brightness;
|
||||||
|
// Set by a thermal camera only: how to read its Mono16 counts.
|
||||||
|
std::optional<ThermalFrameInfo> thermal;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live per-camera identity, sensor telemetry, and acquisition stats, for the
|
// Live per-camera identity, sensor telemetry, and acquisition stats, for the
|
||||||
|
|
@ -113,6 +117,12 @@ public:
|
||||||
// Default: none. Implementations may refresh a throttled cache, so non-const.
|
// Default: none. Implementations may refresh a throttled cache, so non-const.
|
||||||
virtual std::vector<CameraDeviceInfo> deviceInfo() { return {}; }
|
virtual std::vector<CameraDeviceInfo> deviceInfo() { return {}; }
|
||||||
|
|
||||||
|
// Why one camera delivers nothing right now ("not found on the network"),
|
||||||
|
// or empty while it works. For a camera the scan can do without (the
|
||||||
|
// thermal one), which reconnects by itself rather than stopping the run.
|
||||||
|
// Default: always working.
|
||||||
|
virtual std::string offline(int /*camera_index*/) { return {}; }
|
||||||
|
|
||||||
// ---- For `test camera` only ------------------------------------------------
|
// ---- For `test camera` only ------------------------------------------------
|
||||||
// The camera's configuration otherwise changes through setProfile() and
|
// The camera's configuration otherwise changes through setProfile() and
|
||||||
// nothing else; these let the self-test drive a camera to known extremes and
|
// nothing else; these let the self-test drive a camera to known extremes and
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,32 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
|
// What a thermal camera's CamEvent carries beyond the common fields (see
|
||||||
|
// docs/mqtt-api.md). Temperatures are apparent (emissivity 1), in tenths of a
|
||||||
|
// degree Celsius; the hot spot is normalised 0..1 across the stored image.
|
||||||
|
struct ThermalEventFields {
|
||||||
|
int cal = 0; // calibration profile: says how to read the counts
|
||||||
|
int missed = 0; // GigE packets lost for this frame
|
||||||
|
bool stats_ok = false; // false: no valid pixel, the four below go out as null
|
||||||
|
int tmax_decic = 0;
|
||||||
|
int tmed_decic = 0;
|
||||||
|
double hotx = 0.0;
|
||||||
|
double hoty = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
// Notification published after an image is captured and saved.
|
// Notification published after an image is captured and saved.
|
||||||
struct CamEvent {
|
struct CamEvent {
|
||||||
std::string tower; // tower / FWT name
|
std::string tower; // tower / FWT name
|
||||||
std::string camera; // "RGB" / "ACR" / "NIR"
|
std::string camera; // "RGB" / "TIR" / "ACR" / "NIR"
|
||||||
int heading_decideg = 0; // yaw heading * 10 (one decimal as integer)
|
int heading_decideg = 0; // yaw heading * 10 (one decimal as integer)
|
||||||
int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer)
|
int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer)
|
||||||
long long timestamp_ms = 0; // Unix epoch ms; matches the image filename
|
long long timestamp_ms = 0; // Unix epoch ms; matches the image filename
|
||||||
|
std::optional<ThermalEventFields> thermal; // thermal cameras only
|
||||||
};
|
};
|
||||||
|
|
||||||
// The tower computer's health, on GGS/FWT/<tower>/Health (retained): the CPU
|
// The tower computer's health, on GGS/FWT/<tower>/Health (retained): the CPU
|
||||||
|
|
@ -34,6 +49,7 @@ struct HealthReport {
|
||||||
int save_count_last_min = 0;
|
int save_count_last_min = 0;
|
||||||
double save_ms_mean = 0.0;
|
double save_ms_mean = 0.0;
|
||||||
double save_ms_max = 0.0;
|
double save_ms_max = 0.0;
|
||||||
|
std::string cams_offline; // labels of cameras not delivering, comma-separated
|
||||||
long long held_s = 0; // the scan waiting for saving, seconds
|
long long held_s = 0; // the scan waiting for saving, seconds
|
||||||
double interval_s = 0.0; // the time between pictures now
|
double interval_s = 0.0; // the time between pictures now
|
||||||
long long timestamp_ms = 0; // Unix epoch ms
|
long long timestamp_ms = 0; // Unix epoch ms
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ struct Orientation {
|
||||||
// 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. Raw frames
|
||||||
// (BayerRG8, BayerRG12p) are de-Bayered first and saved at their own bit depth,
|
// (BayerRG8, BayerRG12p) are de-Bayered first and saved at their own bit depth,
|
||||||
// so a 12-bit frame becomes a 12-bit file. Runs background workers so encoding
|
// so a 12-bit frame becomes a 12-bit file. Thermal counts (Mono16) are saved
|
||||||
|
// as they are, 16-bit and always lossless, and their CamEvent carries the
|
||||||
|
// frame's temperature figures (ThermalStats.h). Runs background workers so encoding
|
||||||
// never blocks acquisition. Split out of the old VimbaHandler::SaveImage so any
|
// never blocks acquisition. Split out of the old VimbaHandler::SaveImage so any
|
||||||
// camera source (real or mock) can feed it.
|
// camera source (real or mock) can feed it.
|
||||||
class ImagePipeline {
|
class ImagePipeline {
|
||||||
|
|
@ -89,6 +91,8 @@ public:
|
||||||
// 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;
|
||||||
|
// The same for the last thermal picture only (its event carries `thermal`).
|
||||||
|
std::optional<CamEvent> lastThermalEvent() const;
|
||||||
|
|
||||||
// Number of images successfully written this session (for the camera view).
|
// Number of images successfully written this session (for the camera view).
|
||||||
long long imagesSaved() const { return saved_.load(); }
|
long long imagesSaved() const { return saved_.load(); }
|
||||||
|
|
@ -117,8 +121,10 @@ private:
|
||||||
|
|
||||||
void run();
|
void run();
|
||||||
void process(const QueuedFrame& queued);
|
void process(const QueuedFrame& queued);
|
||||||
// Encode and write one image; false (logged) on failure.
|
// Encode and write one image; false (logged) on failure. For a thermal
|
||||||
bool save(const Frame& frame, const CameraOutput& out, const std::string& file);
|
// frame, `stats` receives its figures, taken after rotation.
|
||||||
|
bool save(const Frame& frame, const CameraOutput& out, const std::string& file,
|
||||||
|
ThermalStats* stats = nullptr);
|
||||||
// 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;
|
||||||
|
|
@ -136,6 +142,7 @@ private:
|
||||||
|
|
||||||
mutable std::mutex last_event_mutex_;
|
mutable std::mutex last_event_mutex_;
|
||||||
std::optional<CamEvent> last_event_;
|
std::optional<CamEvent> last_event_;
|
||||||
|
std::optional<CamEvent> last_thermal_event_;
|
||||||
std::atomic<long long> saved_{0}; // images successfully written this session
|
std::atomic<long long> saved_{0}; // images successfully written this session
|
||||||
|
|
||||||
// (steady ms when done, save ms) of each picture saved in the last minute.
|
// (steady ms when done, save ms) of each picture saved in the last minute.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// The few calls of InfraTec's IRBGRAB SDK (libirbgrab, v4) that the thermal
|
||||||
|
// camera needs, declared here rather than taken from the SDK's own headers:
|
||||||
|
// the SDK is licensed per tower and may not be copied into this repository or
|
||||||
|
// a build image. Everything below was established against the installed SDK
|
||||||
|
// (4.3.44) and a TarisIR mini - see TarisIR-Integration.md in the workspace.
|
||||||
|
//
|
||||||
|
// The library is loaded at run time (dlopen), so the program builds and its
|
||||||
|
// tests run on machines that have never seen the SDK; a tower without it
|
||||||
|
// simply has no thermal camera.
|
||||||
|
namespace fgc::irbgrab {
|
||||||
|
|
||||||
|
using Handle = size_t; // the SDK's handles are pointer-sized integers
|
||||||
|
using Ret = uint32_t;
|
||||||
|
|
||||||
|
// Return codes: a type in the top bits, a code below.
|
||||||
|
inline constexpr Ret kTypeOk = 0x10000000;
|
||||||
|
inline constexpr Ret kTypeStatus = 0x20000000;
|
||||||
|
inline constexpr Ret kTypeErr = 0x80000000;
|
||||||
|
inline constexpr Ret kSuccess = kTypeOk | 0x01;
|
||||||
|
inline constexpr Ret kErrNotSupported = kTypeErr | 0x02;
|
||||||
|
inline constexpr Ret kErrNotFound = kTypeErr | 0x03;
|
||||||
|
// Means both "NUC in progress" and "invalid licence". With the licence
|
||||||
|
// installed correctly it was never seen in testing, so when it piles up the
|
||||||
|
// licence is the first suspect.
|
||||||
|
inline constexpr Ret kErrNucOrLicence = 0x80000017;
|
||||||
|
|
||||||
|
inline bool ok(Ret r) { return r == kSuccess; }
|
||||||
|
|
||||||
|
// Fixed text buffers the SDK fills.
|
||||||
|
inline constexpr int kCharBuffer = 1024;
|
||||||
|
|
||||||
|
// What dev_getdata hands out.
|
||||||
|
enum MemObject : int32_t {
|
||||||
|
kMemBitmap32 = 1,
|
||||||
|
kMemIrDigitFrame = 2, // uint16 counts: what is archived
|
||||||
|
kMemTemperatures = 3, // float32 Kelvin: the counts through the LUT, nothing more
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parameter ids (dev_getparam / dev_setparam).
|
||||||
|
namespace param {
|
||||||
|
inline constexpr uint32_t kCalibMaxFrameRate = 117;
|
||||||
|
inline constexpr uint32_t kCalibChangeInProgress = 118;
|
||||||
|
inline constexpr uint32_t kCalibCount = 119;
|
||||||
|
inline constexpr uint32_t kCalibActive = 120;
|
||||||
|
inline constexpr uint32_t kCalibName = 121;
|
||||||
|
inline constexpr uint32_t kPollingEnable = 184; // getdata without a callback
|
||||||
|
inline constexpr uint32_t kSearchString = 202;
|
||||||
|
inline constexpr uint32_t kCameraName = 203;
|
||||||
|
inline constexpr uint32_t kCameraSerialNum = 211;
|
||||||
|
inline constexpr uint32_t kCameraTempHousing = 217; // Kelvin
|
||||||
|
inline constexpr uint32_t kFramerateHz = 240;
|
||||||
|
inline constexpr uint32_t kFrameWidth = 294;
|
||||||
|
inline constexpr uint32_t kFrameHeight = 295;
|
||||||
|
} // namespace param
|
||||||
|
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct String {
|
||||||
|
char* text;
|
||||||
|
int32_t len;
|
||||||
|
};
|
||||||
|
struct IdxString {
|
||||||
|
int32_t index;
|
||||||
|
String value;
|
||||||
|
};
|
||||||
|
struct IdxInt32 {
|
||||||
|
int32_t index;
|
||||||
|
int32_t value;
|
||||||
|
};
|
||||||
|
// Per-frame information (mem_getinfo). struct_size must be set before the call.
|
||||||
|
struct MemInfo {
|
||||||
|
int32_t struct_size;
|
||||||
|
int32_t triggered;
|
||||||
|
double timestamp_ms; // since the grab started
|
||||||
|
int64_t image_num;
|
||||||
|
int32_t missed_packets;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
// Data type tokens: base type | extension | size of what the pointer points at.
|
||||||
|
inline constexpr uint32_t kBaseInt32 = 0x40000000;
|
||||||
|
inline constexpr uint32_t kBaseInt64 = 0x50000000;
|
||||||
|
inline constexpr uint32_t kBaseFloat32 = 0xA0000000;
|
||||||
|
inline constexpr uint32_t kBaseString = 0xF0000000;
|
||||||
|
inline constexpr uint32_t kExIndex = 0x01000000;
|
||||||
|
inline constexpr uint32_t kTypeInt32 = kBaseInt32 | sizeof(int32_t);
|
||||||
|
inline constexpr uint32_t kTypeInt64 = kBaseInt64 | sizeof(int64_t);
|
||||||
|
inline constexpr uint32_t kTypeSingle = kBaseFloat32 | sizeof(float);
|
||||||
|
inline constexpr uint32_t kTypeString = kBaseString | sizeof(String);
|
||||||
|
inline constexpr uint32_t kTypeIdxString = kExIndex | kBaseString | sizeof(IdxString);
|
||||||
|
inline constexpr uint32_t kTypeIdxInt32 = kExIndex | kBaseInt32 | sizeof(IdxInt32);
|
||||||
|
|
||||||
|
// The entry points, as function pointers so a test can stand in for the SDK.
|
||||||
|
struct Api {
|
||||||
|
Ret (*dll_init)() = nullptr;
|
||||||
|
void (*dll_uninit)() = nullptr;
|
||||||
|
Ret (*dll_version)(char* buf, int len) = nullptr;
|
||||||
|
Ret (*dll_devicetypenames)(char* buf, int len) = nullptr; // ';'-separated
|
||||||
|
Ret (*dev_create)(Handle* dev, int32_t type, char* ini) = nullptr;
|
||||||
|
Ret (*dev_free)(Handle* dev) = nullptr;
|
||||||
|
Ret (*dev_search)(Handle dev, int32_t* count) = nullptr;
|
||||||
|
Ret (*dev_connect)(Handle dev, char* search) = nullptr;
|
||||||
|
Ret (*dev_disconnect)(Handle dev) = nullptr;
|
||||||
|
Ret (*dev_startgrab)(Handle dev, int32_t stream) = nullptr;
|
||||||
|
Ret (*dev_stopgrab)(Handle dev, int32_t stream) = nullptr;
|
||||||
|
Ret (*dev_getparam)(Handle dev, uint32_t what, void* data, uint32_t type) = nullptr;
|
||||||
|
Ret (*dev_setparam)(Handle dev, uint32_t what, void* data, uint32_t type) = nullptr;
|
||||||
|
Ret (*dev_getdata)(Handle dev, int32_t what, Handle* mem) = nullptr;
|
||||||
|
Ret (*mem_free)(Handle* mem) = nullptr;
|
||||||
|
Ret (*mem_getdimension)(Handle mem, uint32_t* w, uint32_t* h, uint32_t* type) = nullptr;
|
||||||
|
Ret (*mem_getdataptr)(Handle mem, Handle* data, int32_t* bytes) = nullptr;
|
||||||
|
Ret (*mem_getinfo)(Handle mem, void* info) = nullptr;
|
||||||
|
// The counts-to-Kelvin table (65536 floats) for the given emissivity,
|
||||||
|
// transmission, environment and path temperature.
|
||||||
|
Ret (*mem_getdvlut)(Handle mem, void* lut, float eps, float tau, float t_env,
|
||||||
|
float t_path) = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load the SDK. RTLD_GLOBAL, because the camera plugins it loads in dll_init()
|
||||||
|
// resolve their symbols against it; without it no device type registers. The
|
||||||
|
// library stays loaded for the life of the process. nullptr, with `error` set,
|
||||||
|
// if the library or one of its entry points is missing.
|
||||||
|
std::shared_ptr<const Api> load(const std::string& library, std::string& error);
|
||||||
|
|
||||||
|
// "0x80000017 (NUC in progress or invalid licence)", for logs.
|
||||||
|
std::string describe(Ret r);
|
||||||
|
|
||||||
|
} // namespace fgc::irbgrab
|
||||||
|
|
@ -17,10 +17,12 @@ namespace fgc {
|
||||||
// BayerRG12p the same mosaic at 12 bits, packed: two pixels in three bytes
|
// BayerRG12p the same mosaic at 12 bits, packed: two pixels in three bytes
|
||||||
// (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8,
|
// (GenICam "12p": p0 = b0 | (b1 & 0x0F) << 8,
|
||||||
// p1 = b1 >> 4 | b2 << 4)
|
// p1 = b1 >> 4 | b2 << 4)
|
||||||
|
// Mono16 two bytes per pixel, grey, host byte order - the thermal
|
||||||
|
// camera's counts (TarisCameraSource.h), all 16 bits used
|
||||||
//
|
//
|
||||||
// OpenCV names Bayer patterns by the second row, so the camera's BayerRG is
|
// 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).
|
// OpenCV's COLOR_BayerBG2* (measured 2026-09-30 against the camera's own RGB8).
|
||||||
enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p };
|
enum class PixelLayout { Unknown, Mono8, RGB8, BayerRG8, BayerRG12p, Mono16 };
|
||||||
|
|
||||||
// The GenICam name ("BayerRG12p"), or "Unknown".
|
// The GenICam name ("BayerRG12p"), or "Unknown".
|
||||||
const char* layoutName(PixelLayout layout);
|
const char* layoutName(PixelLayout layout);
|
||||||
|
|
@ -29,7 +31,7 @@ PixelLayout layoutFromName(const std::string& name);
|
||||||
|
|
||||||
// True for the raw mosaic layouts, which need de-Bayering before saving as colour.
|
// True for the raw mosaic layouts, which need de-Bayering before saving as colour.
|
||||||
bool layoutIsBayer(PixelLayout layout);
|
bool layoutIsBayer(PixelLayout layout);
|
||||||
// Bits per value as delivered: 8, or 12 for BayerRG12p. 0 for Unknown.
|
// Bits per value as delivered: 8, 12 for BayerRG12p, 16 for Mono16. 0 for Unknown.
|
||||||
int layoutBits(PixelLayout layout);
|
int layoutBits(PixelLayout layout);
|
||||||
// Bytes one frame of width x height takes in this layout (0 for Unknown).
|
// 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);
|
size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height);
|
||||||
|
|
@ -42,7 +44,8 @@ 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
|
// 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
|
// pixel, so all colour sites of a mosaic are visited. Cheap enough to run on
|
||||||
// every frame the camera delivers (a few thousand samples).
|
// every frame the camera delivers (a few thousand samples).
|
||||||
// mean on a 0..255 scale whatever the bit depth (12-bit values / 16)
|
// mean on a 0..255 scale whatever the bit depth (12-bit values / 16,
|
||||||
|
// 16-bit values / 256)
|
||||||
// clipped share of samples at or near white (>= 250 on that scale)
|
// clipped share of samples at or near white (>= 250 on that scale)
|
||||||
struct BrightnessSample {
|
struct BrightnessSample {
|
||||||
double mean = -1.0;
|
double mean = -1.0;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "fgc/Config.h"
|
||||||
|
#include "fgc/ICameraSource.h"
|
||||||
|
#include "fgc/IrbgrabApi.h"
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
|
||||||
|
// The thermal camera: an InfraTec TarisIR mini through the IRBGRAB SDK
|
||||||
|
// (IrbgrabApi.h), one camera per source.
|
||||||
|
//
|
||||||
|
// The camera is kept grabbing from open() to close(), whatever the scan does:
|
||||||
|
// a microbolometer that is stopped and restarted starts with zero frames and a
|
||||||
|
// fresh NUC. A worker thread owns every SDK call - connecting, polling the
|
||||||
|
// newest frame of counts, switching the calibration profile, reconnecting
|
||||||
|
// when the camera goes quiet - so nothing here ever blocks the control loop.
|
||||||
|
// trigger() hands over the newest frame if it is fresh enough, and otherwise
|
||||||
|
// says no at once: the thermal picture of a waypoint is best effort, the
|
||||||
|
// colour camera's is not.
|
||||||
|
//
|
||||||
|
// A camera that is missing, unlicensed or unreachable is not a startup error.
|
||||||
|
// The source reports why in offline(), keeps retrying, and the scan goes on
|
||||||
|
// without it.
|
||||||
|
class TarisCameraSource : public ICameraSource {
|
||||||
|
public:
|
||||||
|
struct Options {
|
||||||
|
std::string serial; // the camera's serial number
|
||||||
|
int calib_profile = 0;
|
||||||
|
double frame_rate_hz = 5.0;
|
||||||
|
int max_frame_age_ms = 400;
|
||||||
|
std::string library = "libirbgrab_x86_64-linux.so";
|
||||||
|
std::string interface; // for diagnostics only; may be empty
|
||||||
|
int stall_ms = 5000; // connected but no frame this long: reconnect
|
||||||
|
int retry_min_ms = 2000; // between connection attempts, doubling...
|
||||||
|
int retry_max_ms = 30000;// ...up to this
|
||||||
|
int calib_timeout_ms = 15000;// a profile switch takes about 3 s
|
||||||
|
};
|
||||||
|
static Options fromSpec(const CameraSpec& spec);
|
||||||
|
|
||||||
|
// api: the SDK, or null to load opts.library when the worker starts.
|
||||||
|
// has_net_raw: may this process open a raw socket (the SDK needs one)?
|
||||||
|
// Null: read the effective capabilities from /proc/self/status.
|
||||||
|
explicit TarisCameraSource(Options opts, std::shared_ptr<const irbgrab::Api> api = nullptr,
|
||||||
|
std::function<bool()> has_net_raw = nullptr);
|
||||||
|
~TarisCameraSource() override;
|
||||||
|
|
||||||
|
void open() override;
|
||||||
|
void close() override;
|
||||||
|
// Only open and close delivery; the camera keeps grabbing either way.
|
||||||
|
void start() override;
|
||||||
|
void stop() override;
|
||||||
|
bool trigger() override;
|
||||||
|
void setFrameCallback(FrameCallback cb) override;
|
||||||
|
int cameraCount() const override { return 1; }
|
||||||
|
std::vector<CameraDeviceInfo> deviceInfo() override;
|
||||||
|
std::string offline(int camera_index) override;
|
||||||
|
bool grabFrame(int camera_index, Frame& out, int timeout_ms) override;
|
||||||
|
// "CalibProfile" (index), "KelvinPerCount", "HousingTemperature" (degC).
|
||||||
|
bool readFeature(int camera_index, const std::string& name, double& value) override;
|
||||||
|
// "CalibProfile": switch, and wait until the camera has (seconds).
|
||||||
|
bool setFeature(int camera_index, const std::string& name, const std::string& value) override;
|
||||||
|
// Disconnect and connect again; waits until it is back or gives up.
|
||||||
|
bool resetCamera(int camera_index) override;
|
||||||
|
|
||||||
|
// The counts' scale the profiles are documented to have (TarisIR mini):
|
||||||
|
// 0.01 K for profile 0, 0.02 K for profile 1; 0 for any other.
|
||||||
|
static double expectedKelvinPerCount(int calib_profile);
|
||||||
|
|
||||||
|
// Does /proc/self/status grant CAP_NET_RAW?
|
||||||
|
static bool processHasNetRaw();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
|
||||||
|
// What a thermal camera reports about one frame besides its pixels. The pixels
|
||||||
|
// themselves are counts (PixelLayout::Mono16): already calibrated apparent
|
||||||
|
// temperature, T[K] = count * kelvin_per_count, with emissivity and path
|
||||||
|
// transmission both taken as 1. The scale depends on the camera's active
|
||||||
|
// calibration profile, so a count means nothing without `cal`.
|
||||||
|
struct ThermalFrameInfo {
|
||||||
|
int cal = 0; // active calibration profile index
|
||||||
|
double kelvin_per_count = 0.01; // K per count under that profile
|
||||||
|
uint16_t valid_count_max = 65280; // above this the camera's LUT wraps
|
||||||
|
int missed_packets = 0; // GigE packets the SDK lost for this frame
|
||||||
|
long long image_num = -1; // the camera's own frame counter
|
||||||
|
};
|
||||||
|
|
||||||
|
// The per-frame figures that go into a thermal CamEvent. Temperatures are the
|
||||||
|
// apparent temperature in tenths of a degree Celsius; the hot spot is the
|
||||||
|
// pixel centre, normalised 0..1 across the image as stored.
|
||||||
|
struct ThermalStats {
|
||||||
|
bool valid = false; // false: no pixel in the valid range
|
||||||
|
size_t valid_pixels = 0;
|
||||||
|
uint16_t max_count = 0;
|
||||||
|
uint16_t median_count = 0;
|
||||||
|
int tmax_decic = 0;
|
||||||
|
int tmed_decic = 0;
|
||||||
|
double hotx = 0.0;
|
||||||
|
double hoty = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tenths of a degree Celsius for a count, rounded to nearest.
|
||||||
|
int countsToDeciC(uint32_t count, double kelvin_per_count);
|
||||||
|
|
||||||
|
// Maximum (its first occurrence, row-major), median and hot spot of a
|
||||||
|
// width x height image of counts. Only counts in 1..info.valid_count_max take
|
||||||
|
// part: 0 is what the camera sends before its first real frame, and anything
|
||||||
|
// above the limit has wrapped.
|
||||||
|
ThermalStats thermalStats(const uint16_t* counts, uint32_t width, uint32_t height,
|
||||||
|
const ThermalFrameInfo& info);
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cstring>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
@ -13,8 +14,10 @@ namespace fgc {
|
||||||
|
|
||||||
// Synthetic camera for development without hardware. On trigger() it generates
|
// Synthetic camera for development without hardware. On trigger() it generates
|
||||||
// a small gradient frame (varying with time so successive captures differ) and
|
// a small gradient frame (varying with time so successive captures differ) and
|
||||||
// delivers it to the frame callback - as RGB8 by default, or as the raw mosaic
|
// delivers it to the frame callback - as RGB8 by default, as the raw mosaic
|
||||||
// (BayerRG8, BayerRG12p) a raw-capture profile would deliver. setProfile() only
|
// (BayerRG8, BayerRG12p) a raw-capture profile would deliver, or as a thermal
|
||||||
|
// camera's counts (Mono16: a 15..25 degC gradient with one 300 degC spot that
|
||||||
|
// moves with time, profile 0). setProfile() only
|
||||||
// records the profile name - there is no sensor to configure - which is enough
|
// records the profile name - there is no sensor to configure - which is enough
|
||||||
// to exercise profile 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 {
|
||||||
|
|
@ -54,6 +57,11 @@ public:
|
||||||
}
|
}
|
||||||
bool readFeature(int camera_index, const std::string& name, double& value) override {
|
bool readFeature(int camera_index, const std::string& name, double& value) override {
|
||||||
if (camera_index < 0 || camera_index >= count_) return false;
|
if (camera_index < 0 || camera_index >= count_) return false;
|
||||||
|
if (layout_ == PixelLayout::Mono16) { // a thermal camera, like TarisCameraSource
|
||||||
|
if (name == "CalibProfile") { value = 0.0; return true; }
|
||||||
|
if (name == "KelvinPerCount") { value = 0.01; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (name == "IntensityControllerTarget") { value = 50.0; return true; }
|
if (name == "IntensityControllerTarget") { value = 50.0; return true; }
|
||||||
if (name == "IntensityControllerTolerance") { value = 5.0; return true; }
|
if (name == "IntensityControllerTolerance") { value = 5.0; return true; }
|
||||||
if (name == "ExposureAutoMin") { value = 82.0; return true; }
|
if (name == "ExposureAutoMin") { value = 82.0; return true; }
|
||||||
|
|
@ -95,6 +103,7 @@ public:
|
||||||
bool featureRange(int camera_index, const std::string& name, double& min,
|
bool featureRange(int camera_index, const std::string& name, double& min,
|
||||||
double& max) override {
|
double& max) override {
|
||||||
if (camera_index < 0 || camera_index >= count_) return false;
|
if (camera_index < 0 || camera_index >= count_) return false;
|
||||||
|
if (layout_ == PixelLayout::Mono16) return false; // a thermal camera: no exposure
|
||||||
if (name == "ExposureTime") { min = 66.357; max = 9999971.0; return true; }
|
if (name == "ExposureTime") { min = 66.357; max = 9999971.0; return true; }
|
||||||
if (name == "Gain") { min = 0.0; max = 48.0; return true; }
|
if (name == "Gain") { min = 0.0; max = 48.0; return true; }
|
||||||
if (name == "BlackLevel") { min = 0.0; max = 4095.0; return true; }
|
if (name == "BlackLevel") { min = 0.0; max = 4095.0; return true; }
|
||||||
|
|
@ -137,6 +146,7 @@ private:
|
||||||
// A mid-grey gradient frame. The pattern varies with time so successive
|
// A mid-grey gradient frame. The pattern varies with time so successive
|
||||||
// captures differ.
|
// captures differ.
|
||||||
Frame synth() const {
|
Frame synth() const {
|
||||||
|
if (layout_ == PixelLayout::Mono16) return thermal();
|
||||||
Frame f;
|
Frame f;
|
||||||
f.width = width_;
|
f.width = width_;
|
||||||
f.height = height_;
|
f.height = height_;
|
||||||
|
|
@ -182,6 +192,31 @@ private:
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A thermal scene in counts at 0.01 K per count.
|
||||||
|
Frame thermal() const {
|
||||||
|
Frame f;
|
||||||
|
f.width = width_;
|
||||||
|
f.height = height_;
|
||||||
|
f.channels = 1;
|
||||||
|
f.layout = PixelLayout::Mono16;
|
||||||
|
f.cam_id = 0;
|
||||||
|
f.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::system_clock::now().time_since_epoch())
|
||||||
|
.count();
|
||||||
|
std::vector<uint16_t> px(static_cast<size_t>(width_) * height_);
|
||||||
|
for (uint32_t y = 0; y < height_; ++y)
|
||||||
|
for (uint32_t x = 0; x < width_; ++x) // 288.15 K at the top .. 298.15 K
|
||||||
|
px[static_cast<size_t>(y) * width_ + x] =
|
||||||
|
static_cast<uint16_t>(28815 + 1000 * y / std::max<uint32_t>(1, height_ - 1));
|
||||||
|
const uint32_t hx = static_cast<uint32_t>(f.timestamp_ms / 100) % width_;
|
||||||
|
px[static_cast<size_t>(height_ / 2) * width_ + hx] = 57315; // 300 degC
|
||||||
|
f.data.resize(px.size() * 2);
|
||||||
|
std::memcpy(f.data.data(), px.data(), f.data.size());
|
||||||
|
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, f.width, f.height);
|
||||||
|
f.thermal = ThermalFrameInfo{};
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
// The RGB frame as the sensor would see it: each pixel keeps only the colour
|
// 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.
|
// of its filter (BayerRG: R G / G B), 8-bit or packed 12-bit.
|
||||||
Frame mosaic(const Frame& rgb) const {
|
Frame mosaic(const Frame& rgb) const {
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,9 @@ public:
|
||||||
drop(kTopicStatusCode, payload);
|
drop(kTopicStatusCode, payload);
|
||||||
}
|
}
|
||||||
void publishCamEvent(const CamEvent& e) override {
|
void publishCamEvent(const CamEvent& e) override {
|
||||||
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent cam=" << e.camera
|
const std::string payload = camEventJson(e);
|
||||||
<< " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms
|
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent " << payload << " (null channel)";
|
||||||
<< " (null channel)";
|
drop(kTopicCamEvent, payload);
|
||||||
drop(kTopicCamEvent, camEventJson(e));
|
|
||||||
}
|
}
|
||||||
void publishHealth(const HealthReport& h) override {
|
void publishHealth(const HealthReport& h) override {
|
||||||
++health_published_;
|
++health_published_;
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ struct CameraDeviceView {
|
||||||
unsigned width = 0, height = 0; long long payload_bytes = 0;
|
unsigned width = 0, height = 0; long long payload_bytes = 0;
|
||||||
double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0;
|
double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0;
|
||||||
long long frames_delivered = 0, frames_dropped = 0, restarts = 0;
|
long long frames_delivered = 0, frames_dropped = 0, restarts = 0;
|
||||||
|
std::string offline; // why it delivers nothing; empty while it does
|
||||||
};
|
};
|
||||||
|
|
||||||
struct CaptureView {
|
struct CaptureView {
|
||||||
|
|
@ -172,6 +173,15 @@ struct CaptureView {
|
||||||
double last_heading_deg = 0.0;
|
double last_heading_deg = 0.0;
|
||||||
double last_pitch_deg = 0.0;
|
double last_pitch_deg = 0.0;
|
||||||
long long last_ts_ms = 0;
|
long long last_ts_ms = 0;
|
||||||
|
|
||||||
|
// Last thermal picture (ImagePipeline::lastThermalEvent()): its apparent
|
||||||
|
// temperatures, so a hot spot shows on the dashboard without the archive.
|
||||||
|
bool has_thermal = false;
|
||||||
|
int thermal_cal = 0;
|
||||||
|
bool thermal_stats_ok = false;
|
||||||
|
double thermal_max_c = 0.0;
|
||||||
|
double thermal_med_c = 0.0;
|
||||||
|
long long thermal_ts_ms = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ConnView {
|
struct ConnView {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Give fire_gimbal_control the one privilege the thermal camera needs.
|
||||||
|
#
|
||||||
|
# The InfraTec SDK talks to the TarisIR over a raw AF_PACKET socket, which
|
||||||
|
# takes CAP_NET_RAW. Rather than running the whole program - motors, MQTT
|
||||||
|
# remote control and all - as root, the binary gets that one capability and
|
||||||
|
# keeps running as the tower's user. A rebuild replaces the binary and with it
|
||||||
|
# the capability, so deploy.sh and install-tower run this after every build.
|
||||||
|
#
|
||||||
|
# scripts/fgc-setcap set it (asks for the sudo password)
|
||||||
|
# scripts/fgc-setcap --if-thermal ... only if a [Camera.*] has driver = taris;
|
||||||
|
# never fails, says what is missing
|
||||||
|
# scripts/fgc-setcap --check exit 0 if the binary has it
|
||||||
|
# scripts/fgc-setcap --install-sudoers allow exactly this setcap without a
|
||||||
|
# password, so deploy.sh can run it
|
||||||
|
#
|
||||||
|
# A binary with file capabilities runs in the loader's secure mode: LD_* is
|
||||||
|
# ignored and no core dumps are written. So the SDK (/usr/lib/infratec, in the
|
||||||
|
# ld cache) and Vimba X must be found without LD_LIBRARY_PATH - see
|
||||||
|
# docs/tower-installation.md, "Thermal camera", for the check.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
BIN="$REPO/build/fire_gimbal_control"
|
||||||
|
CAPS="cap_net_raw+ep"
|
||||||
|
SETCAP=/usr/sbin/setcap # fixed: the sudoers rule names this exact path
|
||||||
|
[[ -x $SETCAP ]] || SETCAP=$(command -v setcap 2>/dev/null || echo /usr/sbin/setcap)
|
||||||
|
SUDOERS=/etc/sudoers.d/fire-gimbal-control-setcap
|
||||||
|
HW=/etc/fire_gimbal_control/hardware.ini
|
||||||
|
|
||||||
|
thermal_configured() {
|
||||||
|
local f cfg
|
||||||
|
cfg=$("$REPO/scripts/tower-config" where 2>/dev/null || true)
|
||||||
|
for f in "$cfg" "$REPO/config.ini" "$HW"; do
|
||||||
|
[[ -r $f ]] && grep -Eqi '^[[:space:]]*driver[[:space:]]*=[[:space:]]*taris' "$f" && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
has_cap() { command -v getcap > /dev/null && getcap "$BIN" 2>/dev/null | grep -q cap_net_raw; }
|
||||||
|
|
||||||
|
set_cap() {
|
||||||
|
[[ -x $BIN ]] || { echo "fgc-setcap: $BIN not built yet" >&2; return 1; }
|
||||||
|
if sudo -n "$SETCAP" "$CAPS" "$BIN" 2>/dev/null; then :
|
||||||
|
elif [[ -t 0 ]]; then sudo "$SETCAP" "$CAPS" "$BIN"
|
||||||
|
else return 1
|
||||||
|
fi
|
||||||
|
has_cap
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--check)
|
||||||
|
has_cap
|
||||||
|
;;
|
||||||
|
--install-sudoers)
|
||||||
|
rule="$(id -un) ALL=(root) NOPASSWD: $SETCAP $CAPS $BIN"
|
||||||
|
tmp=$(mktemp)
|
||||||
|
trap 'rm -f "$tmp"' EXIT
|
||||||
|
printf '# fire_gimbal_control: the thermal camera needs cap_net_raw after every build\n%s\n' \
|
||||||
|
"$rule" > "$tmp"
|
||||||
|
/usr/sbin/visudo -cf "$tmp" > /dev/null || { echo "fgc-setcap: rule rejected by visudo" >&2; exit 1; }
|
||||||
|
sudo install -m 0440 -o root -g root "$tmp" "$SUDOERS"
|
||||||
|
echo "wrote $SUDOERS: $rule"
|
||||||
|
;;
|
||||||
|
--if-thermal)
|
||||||
|
thermal_configured || exit 0
|
||||||
|
if set_cap; then
|
||||||
|
echo "fgc-setcap: $BIN may open raw sockets (thermal camera)"
|
||||||
|
else
|
||||||
|
echo "!! fgc-setcap: could not give $BIN $CAPS - the thermal camera stays offline." >&2
|
||||||
|
echo "!! Once on the tower: scripts/fgc-setcap --install-sudoers (or run scripts/fgc-setcap)" >&2
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
set_cap && echo "fgc-setcap: $BIN may open raw sockets"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
sed -n '2,20p' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -1064,6 +1064,15 @@ if [[ ${DO[config.ini]} != skip ]]; then
|
||||||
done_step config.ini
|
done_step config.ini
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A thermal camera's SDK opens a raw socket: give the freshly built binary
|
||||||
|
# cap_net_raw, and let later deploys do the same without a password. Does
|
||||||
|
# nothing on a tower without one.
|
||||||
|
"$REPO/scripts/fgc-setcap" --if-thermal
|
||||||
|
if "$REPO/scripts/fgc-setcap" --check 2>/dev/null && [[ ! -f /etc/sudoers.d/fire-gimbal-control-setcap ]]; then
|
||||||
|
"$REPO/scripts/fgc-setcap" --install-sudoers ||
|
||||||
|
say "could not write the sudoers rule; deploy.sh will ask you to run scripts/fgc-setcap"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ${DO[platformio]} == change ]]; then
|
if [[ ${DO[platformio]} == change ]]; then
|
||||||
hr platformio
|
hr platformio
|
||||||
curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o "$WORK/get-platformio.py"
|
curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o "$WORK/get-platformio.py"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
#include "fgc/CompositeCameraSource.h"
|
||||||
|
|
||||||
|
#include "fgc/Logger.h"
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
|
||||||
|
CompositeCameraSource::CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
|
||||||
|
std::vector<int> counts)
|
||||||
|
: children_(std::move(children)), counts_(std::move(counts)) {
|
||||||
|
if (children_.empty()) throw std::invalid_argument("CompositeCameraSource needs a camera");
|
||||||
|
if (counts_.empty())
|
||||||
|
for (const auto& c : children_) counts_.push_back(c->cameraCount());
|
||||||
|
if (counts_.size() != children_.size())
|
||||||
|
throw std::invalid_argument("CompositeCameraSource: one camera count per source");
|
||||||
|
int next = 0;
|
||||||
|
for (int n : counts_) {
|
||||||
|
offsets_.push_back(next);
|
||||||
|
next += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ICameraSource* CompositeCameraSource::route(int camera_index, int& local) const {
|
||||||
|
for (size_t k = children_.size(); k-- > 0;) {
|
||||||
|
if (camera_index < offsets_[k]) continue;
|
||||||
|
local = camera_index - offsets_[k];
|
||||||
|
return local < counts_[k] ? children_[k].get() : nullptr;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CompositeCameraSource::open() {
|
||||||
|
// The leader failing stops the run, as a lone camera would. A follower
|
||||||
|
// failing does not: the scan goes on without its pictures.
|
||||||
|
children_[0]->open();
|
||||||
|
for (size_t k = 1; k < children_.size(); ++k) {
|
||||||
|
try {
|
||||||
|
children_[k]->open();
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "camera " << offsets_[k] << " did not open (" << e.what()
|
||||||
|
<< "); continuing without it";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CompositeCameraSource::close() {
|
||||||
|
for (auto& c : children_) c->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CompositeCameraSource::start() {
|
||||||
|
for (auto& c : children_) c->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CompositeCameraSource::stop() {
|
||||||
|
for (auto& c : children_) c->stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::trigger() {
|
||||||
|
in_trigger_ = true;
|
||||||
|
shot_ts_ = 0;
|
||||||
|
const bool taken = children_[0]->trigger();
|
||||||
|
if (taken)
|
||||||
|
for (size_t k = 1; k < children_.size(); ++k) children_[k]->trigger();
|
||||||
|
in_trigger_ = false;
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CompositeCameraSource::setFrameCallback(FrameCallback cb) {
|
||||||
|
callback_ = std::move(cb);
|
||||||
|
// The leader's frames pass through untouched (they can be 24 MB); its
|
||||||
|
// camera 0 is global camera 0, so not even the index changes.
|
||||||
|
children_[0]->setFrameCallback([this](const Frame& f) {
|
||||||
|
if (in_trigger_ && shot_ts_ == 0) shot_ts_ = f.timestamp_ms;
|
||||||
|
if (callback_) callback_(f);
|
||||||
|
});
|
||||||
|
for (size_t k = 1; k < children_.size(); ++k) {
|
||||||
|
const int offset = offsets_[k];
|
||||||
|
children_[k]->setFrameCallback([this, offset](const Frame& f) {
|
||||||
|
if (!callback_) return;
|
||||||
|
Frame g = f;
|
||||||
|
g.cam_id = f.cam_id + offset;
|
||||||
|
if (in_trigger_ && shot_ts_ != 0) g.timestamp_ms = shot_ts_;
|
||||||
|
callback_(g);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::setProfile(int camera_index, const CameraProfile& profile) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
return c && c->setProfile(local, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t CompositeCameraSource::frameSeq() { return children_[0]->frameSeq(); }
|
||||||
|
|
||||||
|
std::vector<FrameStats> CompositeCameraSource::framesSince(uint64_t after) {
|
||||||
|
return children_[0]->framesSince(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::readFeature(int camera_index, const std::string& name,
|
||||||
|
double& value) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
return c && c->readFeature(local, name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the children have opened, so a camera missing from the bus shows as a
|
||||||
|
// shortfall (HardwarePresence) - while the indices stay those configured.
|
||||||
|
int CompositeCameraSource::cameraCount() const {
|
||||||
|
int n = 0;
|
||||||
|
for (const auto& c : children_) n += c->cameraCount();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CameraDeviceInfo> CompositeCameraSource::deviceInfo() {
|
||||||
|
std::vector<CameraDeviceInfo> out;
|
||||||
|
for (auto& c : children_) {
|
||||||
|
auto d = c->deviceInfo();
|
||||||
|
out.insert(out.end(), d.begin(), d.end());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CompositeCameraSource::offline(int camera_index) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
return c ? c->offline(local) : "no such camera";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::grabFrame(int camera_index, Frame& out, int timeout_ms) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
if (!c || !c->grabFrame(local, out, timeout_ms)) return false;
|
||||||
|
out.cam_id = camera_index;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::setFeature(int camera_index, const std::string& name,
|
||||||
|
const std::string& value) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
return c && c->setFeature(local, name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::featureRange(int camera_index, const std::string& name, double& min,
|
||||||
|
double& max) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
return c && c->featureRange(local, name, min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::restoreProfile(int camera_index) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
return c && c->restoreProfile(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CompositeCameraSource::resetCamera(int camera_index) {
|
||||||
|
int local = 0;
|
||||||
|
auto* c = route(camera_index, local);
|
||||||
|
return c && c->resetCamera(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -174,7 +174,8 @@ void ImagePipeline::process(const QueuedFrame& queued) {
|
||||||
}
|
}
|
||||||
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
|
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
|
||||||
const auto t0 = std::chrono::steady_clock::now();
|
const auto t0 = std::chrono::steady_clock::now();
|
||||||
if (!save(frame, out, file.string())) return;
|
ThermalStats stats;
|
||||||
|
if (!save(frame, out, file.string(), &stats)) return;
|
||||||
noteSaveTime(std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0)
|
noteSaveTime(std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0)
|
||||||
.count());
|
.count());
|
||||||
|
|
||||||
|
|
@ -187,10 +188,25 @@ void ImagePipeline::process(const QueuedFrame& queued) {
|
||||||
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
|
ev.heading_decideg = static_cast<int>(o.yaw_deg * 10);
|
||||||
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
|
ev.pitch_decideg = static_cast<int>(o.pitch_deg * 10);
|
||||||
ev.timestamp_ms = frame.timestamp_ms;
|
ev.timestamp_ms = frame.timestamp_ms;
|
||||||
|
if (frame.thermal) {
|
||||||
|
ThermalEventFields t;
|
||||||
|
t.cal = frame.thermal->cal;
|
||||||
|
t.missed = frame.thermal->missed_packets;
|
||||||
|
t.stats_ok = stats.valid;
|
||||||
|
t.tmax_decic = stats.tmax_decic;
|
||||||
|
t.tmed_decic = stats.tmed_decic;
|
||||||
|
t.hotx = stats.hotx;
|
||||||
|
t.hoty = stats.hoty;
|
||||||
|
ev.thermal = t;
|
||||||
|
if (!stats.valid)
|
||||||
|
LOG_WARN << "Thermal frame " << frame.timestamp_ms
|
||||||
|
<< " has no pixel in the valid range; saved, but without temperatures";
|
||||||
|
}
|
||||||
channel_.publishCamEvent(ev);
|
channel_.publishCamEvent(ev);
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(last_event_mutex_);
|
std::lock_guard<std::mutex> lock(last_event_mutex_);
|
||||||
last_event_ = ev;
|
last_event_ = ev;
|
||||||
|
if (ev.thermal) last_thermal_event_ = ev;
|
||||||
}
|
}
|
||||||
|
|
||||||
saved_.fetch_add(1);
|
saved_.fetch_add(1);
|
||||||
|
|
@ -198,15 +214,24 @@ void ImagePipeline::process(const QueuedFrame& queued) {
|
||||||
<< (known ? layoutName(frame.layout) : "legacy") << ')';
|
<< (known ? layoutName(frame.layout) : "legacy") << ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file) {
|
bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std::string& file,
|
||||||
|
ThermalStats* stats) {
|
||||||
int code = 0;
|
int code = 0;
|
||||||
bool rotate = rotateCode(out.rotation_deg, code);
|
bool rotate = rotateCode(out.rotation_deg, code);
|
||||||
const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width);
|
const int h = static_cast<int>(frame.height), w = static_cast<int>(frame.width);
|
||||||
auto* data = const_cast<uint8_t*>(frame.data.data());
|
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
|
cv::Mat img; // what gets encoded: 8-bit (CV_8U), or 12- or 16-bit values in CV_16U
|
||||||
int bits = 8;
|
int bits = 8;
|
||||||
if (frame.layout == PixelLayout::BayerRG8 || frame.layout == PixelLayout::BayerRG12p) {
|
float distance = static_cast<float>(out.jxl_distance);
|
||||||
|
if (frame.layout == PixelLayout::Mono16) {
|
||||||
|
// Thermal counts: every bit is measurement, so always lossless whatever
|
||||||
|
// the camera's jxl_distance says (the config loader refuses anything
|
||||||
|
// else for a thermal camera; this is the backstop).
|
||||||
|
img = cv::Mat(h, w, CV_16UC1, data);
|
||||||
|
bits = 16;
|
||||||
|
distance = 0.f;
|
||||||
|
} else if (frame.layout == PixelLayout::BayerRG8 || frame.layout == PixelLayout::BayerRG12p) {
|
||||||
cv::Mat mosaic;
|
cv::Mat mosaic;
|
||||||
if (frame.layout == PixelLayout::BayerRG8) {
|
if (frame.layout == PixelLayout::BayerRG8) {
|
||||||
mosaic = cv::Mat(h, w, CV_8UC1, data);
|
mosaic = cv::Mat(h, w, CV_8UC1, data);
|
||||||
|
|
@ -231,9 +256,14 @@ bool ImagePipeline::save(const Frame& frame, const CameraOutput& out, const std:
|
||||||
}
|
}
|
||||||
if (!img.isContinuous()) img = img.clone();
|
if (!img.isContinuous()) img = img.clone();
|
||||||
|
|
||||||
|
// On the image as stored, so the hot spot's position matches the file.
|
||||||
|
if (stats && frame.thermal && frame.layout == PixelLayout::Mono16)
|
||||||
|
*stats = thermalStats(reinterpret_cast<const uint16_t*>(img.data),
|
||||||
|
static_cast<uint32_t>(img.cols), static_cast<uint32_t>(img.rows),
|
||||||
|
*frame.thermal);
|
||||||
|
|
||||||
if (!JpegXlEncoder::encodeToFile(file, img.data, img.cols, img.rows, img.channels(), bits,
|
if (!JpegXlEncoder::encodeToFile(file, img.data, img.cols, img.rows, img.channels(), bits,
|
||||||
static_cast<float>(out.jxl_distance), out.jxl_effort,
|
distance, out.jxl_effort, params_.jxl_threads)) {
|
||||||
params_.jxl_threads)) {
|
|
||||||
LOG_ERROR << "Failed to encode " << file;
|
LOG_ERROR << "Failed to encode " << file;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -245,4 +275,9 @@ std::optional<CamEvent> ImagePipeline::lastEvent() const {
|
||||||
return last_event_;
|
return last_event_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<CamEvent> ImagePipeline::lastThermalEvent() const {
|
||||||
|
std::lock_guard<std::mutex> lock(last_event_mutex_);
|
||||||
|
return last_thermal_event_;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace fgc
|
} // namespace fgc
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
#include "fgc/IrbgrabApi.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
#include <dlfcn.h>
|
||||||
|
|
||||||
|
namespace fgc::irbgrab {
|
||||||
|
|
||||||
|
std::shared_ptr<const Api> load(const std::string& library, std::string& error) {
|
||||||
|
// Never dlclose()d: the SDK starts threads and loads plugins of its own.
|
||||||
|
void* lib = dlopen(library.c_str(), RTLD_NOW | RTLD_GLOBAL);
|
||||||
|
if (!lib) {
|
||||||
|
const char* e = dlerror();
|
||||||
|
error = "cannot load " + library + ": " + (e ? e : "unknown error");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto api = std::make_shared<Api>();
|
||||||
|
bool missing = false;
|
||||||
|
auto sym = [&](auto& fn, const char* name) {
|
||||||
|
fn = reinterpret_cast<std::remove_reference_t<decltype(fn)>>(dlsym(lib, name));
|
||||||
|
if (!fn && !missing) {
|
||||||
|
missing = true;
|
||||||
|
error = library + " has no " + name + " (not the IRBGRAB v4 SDK?)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sym(api->dll_init, "irbgrab_dll_init");
|
||||||
|
sym(api->dll_uninit, "irbgrab_dll_uninit");
|
||||||
|
sym(api->dll_version, "irbgrab_dll_version");
|
||||||
|
sym(api->dll_devicetypenames, "irbgrab_dll_devicetypenames");
|
||||||
|
sym(api->dev_create, "irbgrab_dev_create");
|
||||||
|
sym(api->dev_free, "irbgrab_dev_free");
|
||||||
|
sym(api->dev_search, "irbgrab_dev_search");
|
||||||
|
sym(api->dev_connect, "irbgrab_dev_connect");
|
||||||
|
sym(api->dev_disconnect, "irbgrab_dev_disconnect");
|
||||||
|
sym(api->dev_startgrab, "irbgrab_dev_startgrab");
|
||||||
|
sym(api->dev_stopgrab, "irbgrab_dev_stopgrab");
|
||||||
|
sym(api->dev_getparam, "irbgrab_dev_getparam");
|
||||||
|
sym(api->dev_setparam, "irbgrab_dev_setparam");
|
||||||
|
sym(api->dev_getdata, "irbgrab_dev_getdata");
|
||||||
|
sym(api->mem_free, "irbgrab_mem_free");
|
||||||
|
sym(api->mem_getdimension, "irbgrab_mem_getdimension");
|
||||||
|
sym(api->mem_getdataptr, "irbgrab_mem_getdataptr");
|
||||||
|
sym(api->mem_getinfo, "irbgrab_mem_getinfo");
|
||||||
|
sym(api->mem_getdvlut, "irbgrab_mem_getdvlut");
|
||||||
|
if (missing) return nullptr;
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string describe(Ret r) {
|
||||||
|
char b[16];
|
||||||
|
std::snprintf(b, sizeof(b), "0x%08X", static_cast<unsigned>(r));
|
||||||
|
std::string s = b;
|
||||||
|
if (r == kSuccess) s += " (ok)";
|
||||||
|
else if (r == kErrNotSupported) s += " (not supported)";
|
||||||
|
else if (r == kErrNotFound) s += " (not found)";
|
||||||
|
else if (r == kErrNucOrLicence) s += " (NUC in progress or invalid licence)";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgc::irbgrab
|
||||||
|
|
@ -0,0 +1,815 @@
|
||||||
|
#include "fgc/TarisCameraSource.h"
|
||||||
|
|
||||||
|
#include "fgc/Logger.h"
|
||||||
|
#include "fgc/RawImage.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cctype>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstring>
|
||||||
|
#include <deque>
|
||||||
|
#include <clocale>
|
||||||
|
#include <fstream>
|
||||||
|
#include <mutex>
|
||||||
|
#include <sstream>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
|
||||||
|
using namespace irbgrab;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
long long unixMs() {
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
long long steadyMs() {
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string lower(std::string s) {
|
||||||
|
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One line of a sysfs file, or "" if it cannot be read.
|
||||||
|
std::string sysfs(const std::string& path) {
|
||||||
|
std::ifstream f(path);
|
||||||
|
std::string s;
|
||||||
|
std::getline(f, s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SDK changes the process's C locale (seen on tower 111: LC_NUMERIC turned
|
||||||
|
// to a comma one under LANG=en_US.UTF-8). The program parses and prints numbers
|
||||||
|
// with the C library, so every SDK call that may do this runs inside one of
|
||||||
|
// these, which puts the locale back as it was.
|
||||||
|
class LocaleGuard {
|
||||||
|
public:
|
||||||
|
LocaleGuard() {
|
||||||
|
const char* now = std::setlocale(LC_ALL, nullptr);
|
||||||
|
saved_ = now ? now : "C";
|
||||||
|
}
|
||||||
|
~LocaleGuard() {
|
||||||
|
const char* now = std::setlocale(LC_ALL, nullptr);
|
||||||
|
if (!now || saved_ != now) std::setlocale(LC_ALL, saved_.c_str());
|
||||||
|
}
|
||||||
|
LocaleGuard(const LocaleGuard&) = delete;
|
||||||
|
LocaleGuard& operator=(const LocaleGuard&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string saved_;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr long long kLogEveryMs = 60000; // repeated trouble: one line a minute
|
||||||
|
constexpr long long kTelemetryEveryMs = 5000;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TarisCameraSource::Options TarisCameraSource::fromSpec(const CameraSpec& spec) {
|
||||||
|
Options o;
|
||||||
|
o.serial = spec.id;
|
||||||
|
o.calib_profile = spec.calib_profile;
|
||||||
|
o.frame_rate_hz = spec.frame_rate_hz;
|
||||||
|
o.max_frame_age_ms = spec.max_frame_age_ms;
|
||||||
|
o.library = spec.library;
|
||||||
|
o.interface = spec.interface;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
double TarisCameraSource::expectedKelvinPerCount(int calib_profile) {
|
||||||
|
switch (calib_profile) {
|
||||||
|
case 0: return 0.01; // FF (-40-120C)
|
||||||
|
case 1: return 0.02; // FF (0-600C)
|
||||||
|
default: return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TarisCameraSource::processHasNetRaw() {
|
||||||
|
std::ifstream f("/proc/self/status");
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(f, line)) {
|
||||||
|
if (line.rfind("CapEff:", 0) != 0) continue;
|
||||||
|
try {
|
||||||
|
const unsigned long long caps = std::stoull(line.substr(7), nullptr, 16);
|
||||||
|
return (caps >> 13) & 1; // CAP_NET_RAW
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TarisCameraSource::Impl {
|
||||||
|
Options o;
|
||||||
|
std::shared_ptr<const Api> api;
|
||||||
|
std::function<bool()> has_net_raw;
|
||||||
|
|
||||||
|
// ---- the worker's own (only it calls the SDK) ---------------------------
|
||||||
|
Handle dev = 0;
|
||||||
|
bool sdk_init = false;
|
||||||
|
bool connected = false;
|
||||||
|
int cal = -1; // active profile as the camera reports it
|
||||||
|
double kpc = 0.0; // measured from the LUT; 0 = not yet
|
||||||
|
long long last_frame_steady = 0;
|
||||||
|
double last_sdk_ts = -1.0; // MemInfo time of the last frame taken
|
||||||
|
int scale_tries = 0; // frames whose LUT was still empty
|
||||||
|
long long last_telemetry = 0;
|
||||||
|
long long nuc_or_licence = 0; // getdata 0x80000017 since the last log line
|
||||||
|
long long nuc_logged_ms = 0;
|
||||||
|
bool zeros_logged = false;
|
||||||
|
std::string last_offline_logged;
|
||||||
|
|
||||||
|
// ---- shared, under m -------------------------------------------------
|
||||||
|
std::mutex m;
|
||||||
|
std::condition_variable cv;
|
||||||
|
Frame latest;
|
||||||
|
uint64_t latest_seq = 0;
|
||||||
|
uint64_t delivered_seq = 0;
|
||||||
|
std::string why_offline = "not opened";
|
||||||
|
CameraDeviceInfo info;
|
||||||
|
std::deque<long long> arrivals; // steady ms of recent frames, for fps
|
||||||
|
long long missed_shots = 0;
|
||||||
|
long long missed_logged_ms = 0;
|
||||||
|
long long missed_since_log = 0;
|
||||||
|
long long packets_lost = 0;
|
||||||
|
int cal_wanted = -1; // a pending setFeature("CalibProfile")
|
||||||
|
bool cal_done = false;
|
||||||
|
bool cal_ok = false;
|
||||||
|
bool reset_wanted = false;
|
||||||
|
uint64_t connects = 0;
|
||||||
|
|
||||||
|
std::atomic<bool> running{false};
|
||||||
|
std::atomic<bool> gate{false};
|
||||||
|
FrameCallback callback;
|
||||||
|
std::thread worker;
|
||||||
|
|
||||||
|
explicit Impl(Options opts, std::shared_ptr<const Api> a, std::function<bool()> net_raw)
|
||||||
|
: o(std::move(opts)), api(std::move(a)), has_net_raw(std::move(net_raw)) {
|
||||||
|
if (!has_net_raw) has_net_raw = &TarisCameraSource::processHasNetRaw;
|
||||||
|
info.id = o.serial;
|
||||||
|
info.serial = o.serial;
|
||||||
|
info.model = "TarisIR";
|
||||||
|
}
|
||||||
|
|
||||||
|
void setOffline(const std::string& why) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
why_offline = why;
|
||||||
|
info.streaming = false;
|
||||||
|
}
|
||||||
|
if (why != last_offline_logged) {
|
||||||
|
LOG_WARN << "thermal camera " << o.serial << " offline: " << why;
|
||||||
|
last_offline_logged = why;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep up to ms, or less if close() wants the worker to stop or another
|
||||||
|
// thread has asked for a profile switch or a reset.
|
||||||
|
void pause(long long ms) {
|
||||||
|
std::unique_lock<std::mutex> l(m);
|
||||||
|
cv.wait_for(l, std::chrono::milliseconds(ms),
|
||||||
|
[this] { return !running.load() || cal_wanted >= 0 || reset_wanted; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- parameter helpers -------------------------------------------------
|
||||||
|
bool getInt32(uint32_t what, int32_t& v) {
|
||||||
|
return ok(api->dev_getparam(dev, what, &v, kTypeInt32));
|
||||||
|
}
|
||||||
|
bool getIdxInt32(uint32_t what, int32_t index, int32_t& v) {
|
||||||
|
IdxInt32 p{index, 0};
|
||||||
|
if (!ok(api->dev_getparam(dev, what, &p, kTypeIdxInt32))) return false;
|
||||||
|
v = p.value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool getSingle(uint32_t what, float& v) {
|
||||||
|
return ok(api->dev_getparam(dev, what, &v, kTypeSingle));
|
||||||
|
}
|
||||||
|
bool getString(uint32_t what, std::string& out) {
|
||||||
|
std::vector<char> buf(kCharBuffer, 0);
|
||||||
|
String s{buf.data(), kCharBuffer};
|
||||||
|
if (!ok(api->dev_getparam(dev, what, &s, kTypeString))) return false;
|
||||||
|
out.assign(buf.data(), strnlen(buf.data(), buf.size()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool getIdxString(uint32_t what, int32_t index, std::string& out) {
|
||||||
|
std::vector<char> buf(kCharBuffer, 0);
|
||||||
|
IdxString s{index, {buf.data(), kCharBuffer}};
|
||||||
|
if (!ok(api->dev_getparam(dev, what, &s, kTypeIdxString))) return false;
|
||||||
|
out.assign(buf.data(), strnlen(buf.data(), buf.size()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Ret setInt32(uint32_t what, int32_t v) { return api->dev_setparam(dev, what, &v, kTypeInt32); }
|
||||||
|
|
||||||
|
// Calib_Active reads as a plain or an indexed value (both work on 4.3.44).
|
||||||
|
// Right after a reconnect it can fail for a moment, so it is asked a few times.
|
||||||
|
int readCal() {
|
||||||
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
|
int32_t v = -1;
|
||||||
|
if (getInt32(param::kCalibActive, v) && v >= 0) return v;
|
||||||
|
if (getIdxInt32(param::kCalibActive, 0, v) && v >= 0) return v;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch the calibration profile and wait for the camera to finish.
|
||||||
|
// Calib_Active reports the new profile at once, but the frames keep the old
|
||||||
|
// scale until Calib_ChangeInProgress has run 2, 4, 2 and reached 0 (about
|
||||||
|
// 3 s on tower 111; at rest, before any switch, it reads 6). Nothing is
|
||||||
|
// polled meanwhile, so no frame of the wrong scale is taken.
|
||||||
|
bool switchCal(int profile, std::string& why) {
|
||||||
|
int32_t count = 0;
|
||||||
|
if (getInt32(param::kCalibCount, count) && (profile < 0 || profile >= count)) {
|
||||||
|
why = "calibration profile " + std::to_string(profile) + " does not exist (camera has " +
|
||||||
|
std::to_string(count) + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
IdxInt32 v{0, profile};
|
||||||
|
const Ret r = api->dev_setparam(dev, param::kCalibActive, &v, kTypeIdxInt32);
|
||||||
|
if (!ok(r)) {
|
||||||
|
why = "cannot set calibration profile " + std::to_string(profile) + ": " + describe(r);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const long long until = steadyMs() + o.calib_timeout_ms;
|
||||||
|
int32_t busy = 1;
|
||||||
|
while (steadyMs() < until) {
|
||||||
|
if (getInt32(param::kCalibChangeInProgress, busy) && busy == 0) break;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
}
|
||||||
|
if (busy != 0) {
|
||||||
|
why = "calibration profile change did not finish within " +
|
||||||
|
std::to_string(o.calib_timeout_ms) + " ms";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cal = readCal();
|
||||||
|
kpc = 0.0; // measure the new scale from the next frames' LUT
|
||||||
|
scale_tries = 0;
|
||||||
|
if (cal != profile) {
|
||||||
|
why = "camera reports profile " + std::to_string(cal) + " after switching to " +
|
||||||
|
std::to_string(profile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string name;
|
||||||
|
getIdxString(param::kCalibName, cal, name);
|
||||||
|
LOG_INFO << "thermal camera " << o.serial << ": calibration profile " << cal
|
||||||
|
<< (name.empty() ? "" : " (" + name + ")");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- connecting --------------------------------------------------------
|
||||||
|
std::string linkHint() const {
|
||||||
|
if (o.interface.empty()) return {};
|
||||||
|
const std::string base = "/sys/class/net/" + o.interface;
|
||||||
|
const std::string state = sysfs(base + "/operstate");
|
||||||
|
if (state.empty()) return "; interface " + o.interface + " does not exist";
|
||||||
|
std::string hint = "; " + o.interface + " is " + state;
|
||||||
|
const std::string mtu = sysfs(base + "/mtu");
|
||||||
|
if (!mtu.empty() && mtu != "1500") hint += ", MTU " + mtu + " (the camera needs 1500)";
|
||||||
|
return hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
void freeDevice() {
|
||||||
|
if (dev) api->dev_free(&dev);
|
||||||
|
dev = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void disconnect() {
|
||||||
|
if (connected) {
|
||||||
|
api->dev_stopgrab(dev, 0);
|
||||||
|
api->dev_disconnect(dev);
|
||||||
|
}
|
||||||
|
connected = false;
|
||||||
|
freeDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool connect(std::string& why) {
|
||||||
|
// The device type the camera package registered.
|
||||||
|
std::vector<char> names(kCharBuffer, 0);
|
||||||
|
Ret r = api->dll_devicetypenames(names.data(), kCharBuffer);
|
||||||
|
if (!ok(r)) {
|
||||||
|
why = "SDK lists no device types: " + describe(r);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int type = -1, i = 0;
|
||||||
|
std::stringstream ss(std::string(names.data(), strnlen(names.data(), names.size())));
|
||||||
|
for (std::string n; std::getline(ss, n, ';'); ++i)
|
||||||
|
if (lower(n).find("taris") != std::string::npos) { type = i; break; }
|
||||||
|
if (type < 0) {
|
||||||
|
why = "SDK has no TarisIR device type (camera package infratec-itcamtarisirmini "
|
||||||
|
"missing?)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<char> ini(kCharBuffer, 0);
|
||||||
|
r = api->dev_create(&dev, type, ini.data());
|
||||||
|
if (!ok(r)) {
|
||||||
|
dev = 0;
|
||||||
|
why = "cannot create the device: " + describe(r);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search first: it fills the SDK's adapter table that connect() needs.
|
||||||
|
int32_t count = 0;
|
||||||
|
r = api->dev_search(dev, &count);
|
||||||
|
if (!ok(r) || count <= 0) {
|
||||||
|
freeDevice();
|
||||||
|
why = "no thermal camera found on the network" + linkHint();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string chosen;
|
||||||
|
std::vector<std::string> seen;
|
||||||
|
for (int32_t k = 0; k < count && k < 32; ++k) {
|
||||||
|
std::string s;
|
||||||
|
if (!getIdxString(param::kSearchString, k, s)) break;
|
||||||
|
seen.push_back(s);
|
||||||
|
if (chosen.empty() && s.find(o.serial) != std::string::npos) chosen = s;
|
||||||
|
}
|
||||||
|
if (chosen.empty()) {
|
||||||
|
std::string list;
|
||||||
|
for (const auto& s : seen) list += (list.empty() ? "" : ", ") + s;
|
||||||
|
freeDevice();
|
||||||
|
why = "camera " + o.serial + " not found (found: " + (list.empty() ? "none" : list) + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SN:<serial>|<MAC>|<IP>#<adapter>. The adapter index moves when Docker or
|
||||||
|
// VPN interfaces come and go, so if the one found does not connect, try
|
||||||
|
// them all.
|
||||||
|
std::vector<std::string> candidates{chosen};
|
||||||
|
const std::string base = chosen.substr(0, chosen.rfind('#'));
|
||||||
|
for (int a = 0; a < 16; ++a) {
|
||||||
|
std::string c = base + "#" + std::to_string(a);
|
||||||
|
if (c != chosen) candidates.push_back(c);
|
||||||
|
}
|
||||||
|
Ret last = 0;
|
||||||
|
for (const auto& c : candidates) {
|
||||||
|
std::vector<char> buf(kCharBuffer, 0);
|
||||||
|
std::memcpy(buf.data(), c.data(), std::min<size_t>(c.size(), kCharBuffer - 1));
|
||||||
|
last = api->dev_connect(dev, buf.data());
|
||||||
|
if (ok(last)) {
|
||||||
|
connected = true;
|
||||||
|
LOG_INFO << "thermal camera connected via " << c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!connected) {
|
||||||
|
freeDevice();
|
||||||
|
why = "cannot connect to " + chosen + ": " + describe(last) + linkHint();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile: it sets the counts' scale, so it is checked before any frame.
|
||||||
|
cal = readCal();
|
||||||
|
if (cal < 0) {
|
||||||
|
disconnect();
|
||||||
|
why = "cannot read the active calibration profile";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (cal != o.calib_profile) {
|
||||||
|
LOG_INFO << "thermal camera on calibration profile " << cal << ", switching to "
|
||||||
|
<< o.calib_profile;
|
||||||
|
std::string err;
|
||||||
|
if (!switchCal(o.calib_profile, err)) {
|
||||||
|
disconnect();
|
||||||
|
why = err;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float rate = static_cast<float>(o.frame_rate_hz);
|
||||||
|
r = api->dev_setparam(dev, param::kFramerateHz, &rate, kTypeSingle);
|
||||||
|
if (!ok(r))
|
||||||
|
LOG_WARN << "thermal camera: cannot set " << o.frame_rate_hz
|
||||||
|
<< " Hz: " << describe(r) << "; it runs at its own rate";
|
||||||
|
|
||||||
|
// Polling, before and again after starting: the SDK resets it on startgrab.
|
||||||
|
setInt32(param::kPollingEnable, 1);
|
||||||
|
r = api->dev_startgrab(dev, 0);
|
||||||
|
if (!ok(r)) {
|
||||||
|
disconnect();
|
||||||
|
why = "cannot start grabbing: " + describe(r);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setInt32(param::kPollingEnable, 1);
|
||||||
|
|
||||||
|
std::string model;
|
||||||
|
getString(param::kCameraName, model);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
if (!model.empty()) info.model = model;
|
||||||
|
++connects;
|
||||||
|
}
|
||||||
|
kpc = 0.0;
|
||||||
|
scale_tries = 0;
|
||||||
|
last_sdk_ts = -1.0;
|
||||||
|
zeros_logged = false;
|
||||||
|
last_frame_steady = steadyMs(); // grace for the first frame
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- frames ------------------------------------------------------------
|
||||||
|
// Take the newest frame from the SDK, if there is one.
|
||||||
|
void poll() {
|
||||||
|
// As before every getdata in the vendor's examples: without it the SDK
|
||||||
|
// now and then answers 0x80000001 instead of a frame.
|
||||||
|
setInt32(param::kPollingEnable, 1);
|
||||||
|
Handle mem = 0;
|
||||||
|
const Ret r = api->dev_getdata(dev, kMemIrDigitFrame, &mem);
|
||||||
|
if (!ok(r)) {
|
||||||
|
if (r == kErrNucOrLicence) {
|
||||||
|
++nuc_or_licence;
|
||||||
|
const long long now = steadyMs();
|
||||||
|
if (now - nuc_logged_ms >= kLogEveryMs) {
|
||||||
|
LOG_WARN << "thermal camera: " << describe(r) << " x" << nuc_or_licence
|
||||||
|
<< " in the last minute; if this persists, check the licence in "
|
||||||
|
"/var/lib/infratec/.license before the NUC";
|
||||||
|
nuc_logged_ms = now;
|
||||||
|
nuc_or_licence = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
struct MemGuard {
|
||||||
|
const Api* api;
|
||||||
|
Handle* h;
|
||||||
|
~MemGuard() { if (*h) api->mem_free(h); }
|
||||||
|
} guard{api.get(), &mem};
|
||||||
|
|
||||||
|
uint32_t w = 0, h = 0, type = 0;
|
||||||
|
Handle ptr = 0;
|
||||||
|
int32_t bytes = 0;
|
||||||
|
if (!ok(api->mem_getdimension(mem, &w, &h, &type)) ||
|
||||||
|
!ok(api->mem_getdataptr(mem, &ptr, &bytes)) || !ptr || w == 0 || h == 0)
|
||||||
|
return;
|
||||||
|
const size_t need = static_cast<size_t>(w) * h * 2;
|
||||||
|
if (bytes < 0 || static_cast<size_t>(bytes) < need) return;
|
||||||
|
|
||||||
|
// Polling hands out the newest frame again until a newer one arrives. The
|
||||||
|
// camera's frame counter stays 0 (TarisIR mini, SDK 4.3.44), so a new
|
||||||
|
// frame is told by its time.
|
||||||
|
MemInfo mi{};
|
||||||
|
mi.struct_size = sizeof(MemInfo);
|
||||||
|
if (!ok(api->mem_getinfo(mem, &mi))) {
|
||||||
|
mi.image_num = -1;
|
||||||
|
mi.timestamp_ms = -1.0;
|
||||||
|
}
|
||||||
|
if (mi.timestamp_ms >= 0.0 && mi.timestamp_ms == last_sdk_ts) return; // seen it
|
||||||
|
last_sdk_ts = mi.timestamp_ms;
|
||||||
|
|
||||||
|
const auto* counts = reinterpret_cast<const uint16_t*>(ptr);
|
||||||
|
// The first frames after starting are all zero.
|
||||||
|
if (std::all_of(counts, counts + static_cast<size_t>(w) * h,
|
||||||
|
[](uint16_t c) { return c == 0; })) {
|
||||||
|
if (!zeros_logged) LOG_INFO << "thermal camera: discarding zero frames after start";
|
||||||
|
zeros_logged = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kpc == 0.0) measureScale(mem);
|
||||||
|
|
||||||
|
Frame f;
|
||||||
|
f.width = w;
|
||||||
|
f.height = h;
|
||||||
|
f.channels = 1;
|
||||||
|
f.layout = PixelLayout::Mono16;
|
||||||
|
f.timestamp_ms = unixMs();
|
||||||
|
f.cam_id = 0;
|
||||||
|
f.data.assign(reinterpret_cast<const uint8_t*>(ptr),
|
||||||
|
reinterpret_cast<const uint8_t*>(ptr) + need);
|
||||||
|
f.brightness = sampleBrightness(f.data.data(), f.data.size(), f.layout, w, h);
|
||||||
|
ThermalFrameInfo t;
|
||||||
|
t.cal = cal;
|
||||||
|
t.kelvin_per_count = kpc > 0.0 ? kpc : expectedKelvinPerCount(cal);
|
||||||
|
t.missed_packets = mi.missed_packets;
|
||||||
|
t.image_num = mi.image_num;
|
||||||
|
f.thermal = t;
|
||||||
|
|
||||||
|
const long long now = steadyMs();
|
||||||
|
last_frame_steady = now;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
latest = std::move(f);
|
||||||
|
++latest_seq;
|
||||||
|
++info.frames_delivered;
|
||||||
|
packets_lost += std::max(0, mi.missed_packets);
|
||||||
|
info.frames_dropped = missed_shots + packets_lost;
|
||||||
|
info.width = w;
|
||||||
|
info.height = h;
|
||||||
|
info.payload_bytes = static_cast<long long>(need);
|
||||||
|
info.streaming = true;
|
||||||
|
why_offline.clear();
|
||||||
|
arrivals.push_back(now);
|
||||||
|
while (!arrivals.empty() && now - arrivals.front() > 10000) arrivals.pop_front();
|
||||||
|
if (arrivals.size() > 1)
|
||||||
|
info.actual_fps = (arrivals.size() - 1) * 1000.0 /
|
||||||
|
std::max<long long>(1, arrivals.back() - arrivals.front());
|
||||||
|
}
|
||||||
|
cv.notify_all();
|
||||||
|
last_offline_logged.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The counts' scale, from the LUT at emissivity and transmission 1, where it
|
||||||
|
// is a straight line through zero. The first frames after connecting come
|
||||||
|
// with an empty table, so it is asked again on later ones; frames meanwhile
|
||||||
|
// use the documented scale, and if the table stays empty, so does the run.
|
||||||
|
static constexpr int kScaleTries = 50;
|
||||||
|
void measureScale(Handle mem) {
|
||||||
|
std::vector<float> lut(65536, 0.f);
|
||||||
|
const Ret r = api->mem_getdvlut(mem, lut.data(), 1.f, 1.f, 293.15f, 293.15f);
|
||||||
|
const double expected = expectedKelvinPerCount(cal);
|
||||||
|
double measured = 0.0;
|
||||||
|
if (ok(r)) measured = (static_cast<double>(lut[40000]) - lut[30000]) / 10000.0;
|
||||||
|
if (!(measured > 0.0) && ++scale_tries < kScaleTries) return; // try the next frame
|
||||||
|
LOG_DEBUG << "thermal camera LUT after " << scale_tries << " empty: " << describe(r)
|
||||||
|
<< ", [0] " << lut[0] << " [29315] " << lut[29315] << " [65280] "
|
||||||
|
<< lut[65280] << " [65535] " << lut[65535] << " K";
|
||||||
|
if (!(measured > 0.0)) {
|
||||||
|
kpc = expected > 0.0 ? expected : -1.0; // -1: unknown, and not asked again
|
||||||
|
LOG_WARN << "thermal camera: no usable LUT (" << describe(r) << "); taking the "
|
||||||
|
<< "documented " << expected << " K per count for profile " << cal;
|
||||||
|
} else {
|
||||||
|
kpc = measured;
|
||||||
|
if (expected > 0.0 && std::abs(kpc - expected) > expected * 0.01)
|
||||||
|
LOG_WARN << "thermal camera: profile " << cal << " has " << kpc
|
||||||
|
<< " K per count, not the documented " << expected;
|
||||||
|
else
|
||||||
|
LOG_INFO << "thermal camera: profile " << cal << ", " << kpc << " K per count";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void telemetry() {
|
||||||
|
float k = 0.f;
|
||||||
|
if (getSingle(param::kCameraTempHousing, k) && k > 0.f) {
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
info.temperature_c = k - 273.15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the worker ------------------------------------------------------
|
||||||
|
void run() {
|
||||||
|
if (!api) {
|
||||||
|
std::string err;
|
||||||
|
LocaleGuard keep;
|
||||||
|
api = irbgrab::load(o.library, err);
|
||||||
|
if (!api) {
|
||||||
|
setOffline(err + " (InfraTec SDK not installed?)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!has_net_raw()) {
|
||||||
|
setOffline("this binary may not open raw sockets (no cap_net_raw): run deploy.sh, "
|
||||||
|
"which sets it, or sudo setcap cap_net_raw+ep build/fire_gimbal_control");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ret r;
|
||||||
|
{
|
||||||
|
LocaleGuard keep;
|
||||||
|
r = api->dll_init();
|
||||||
|
}
|
||||||
|
if (!ok(r)) {
|
||||||
|
setOffline("SDK init failed: " + describe(r));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sdk_init = true;
|
||||||
|
std::vector<char> ver(kCharBuffer, 0);
|
||||||
|
if (ok(api->dll_version(ver.data(), kCharBuffer)))
|
||||||
|
LOG_INFO << "IRBGRAB SDK " << ver.data();
|
||||||
|
|
||||||
|
long long retry = o.retry_min_ms;
|
||||||
|
const long long poll_ms =
|
||||||
|
std::max<long long>(5, static_cast<long long>(500.0 / o.frame_rate_hz));
|
||||||
|
while (running) {
|
||||||
|
LocaleGuard keep_locale; // every SDK call in this pass, polling included
|
||||||
|
if (!connected) {
|
||||||
|
{
|
||||||
|
// Connecting is what a reset asks for; it must not keep the
|
||||||
|
// retry pause from pausing.
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
reset_wanted = false;
|
||||||
|
}
|
||||||
|
std::string why;
|
||||||
|
bool up;
|
||||||
|
{
|
||||||
|
LocaleGuard keep;
|
||||||
|
up = connect(why);
|
||||||
|
}
|
||||||
|
if (up) {
|
||||||
|
retry = o.retry_min_ms;
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
why_offline = "waiting for the first frame";
|
||||||
|
} else {
|
||||||
|
setOffline(why);
|
||||||
|
pause(retry);
|
||||||
|
retry = std::min<long long>(retry * 2, o.retry_max_ms);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serviceRequests();
|
||||||
|
if (!connected) continue;
|
||||||
|
poll();
|
||||||
|
|
||||||
|
const long long now = steadyMs();
|
||||||
|
if (now - last_frame_steady > o.stall_ms) {
|
||||||
|
setOffline("no frame for " + std::to_string(o.stall_ms) + " ms; reconnecting");
|
||||||
|
disconnect();
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
++info.restarts;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (now - last_telemetry >= kTelemetryEveryMs) {
|
||||||
|
telemetry();
|
||||||
|
last_telemetry = now;
|
||||||
|
}
|
||||||
|
pause(poll_ms);
|
||||||
|
}
|
||||||
|
LocaleGuard keep;
|
||||||
|
disconnect();
|
||||||
|
if (sdk_init) api->dll_uninit();
|
||||||
|
sdk_init = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A profile switch or a reset asked for from another thread.
|
||||||
|
void serviceRequests() {
|
||||||
|
int want = -1;
|
||||||
|
bool reset = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
want = cal_wanted;
|
||||||
|
reset = reset_wanted;
|
||||||
|
reset_wanted = false;
|
||||||
|
}
|
||||||
|
if (reset) {
|
||||||
|
LOG_INFO << "thermal camera: reset requested, reconnecting";
|
||||||
|
disconnect();
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
++info.restarts;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (want < 0) return;
|
||||||
|
std::string why;
|
||||||
|
bool done = switchCal(want, why);
|
||||||
|
if (done) o.calib_profile = want; // a reconnect keeps it
|
||||||
|
else LOG_WARN << "thermal camera: " << why;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
cal_wanted = -1;
|
||||||
|
cal_done = true;
|
||||||
|
cal_ok = done;
|
||||||
|
// Frames from before the switch are on the old scale: never hand one out.
|
||||||
|
delivered_seq = latest_seq;
|
||||||
|
}
|
||||||
|
cv.notify_all();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TarisCameraSource::TarisCameraSource(Options opts, std::shared_ptr<const irbgrab::Api> api,
|
||||||
|
std::function<bool()> has_net_raw)
|
||||||
|
: impl_(std::make_unique<Impl>(std::move(opts), std::move(api), std::move(has_net_raw))) {}
|
||||||
|
|
||||||
|
TarisCameraSource::~TarisCameraSource() { close(); }
|
||||||
|
|
||||||
|
void TarisCameraSource::open() {
|
||||||
|
if (impl_->running.exchange(true)) return;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(impl_->m);
|
||||||
|
impl_->why_offline = "connecting";
|
||||||
|
}
|
||||||
|
impl_->worker = std::thread([this] { impl_->run(); });
|
||||||
|
LOG_INFO << "thermal camera " << impl_->o.serial << ": connecting in the background";
|
||||||
|
}
|
||||||
|
|
||||||
|
void TarisCameraSource::close() {
|
||||||
|
if (!impl_->running.exchange(false)) return;
|
||||||
|
impl_->cv.notify_all();
|
||||||
|
if (impl_->worker.joinable()) impl_->worker.join();
|
||||||
|
std::lock_guard<std::mutex> l(impl_->m);
|
||||||
|
impl_->why_offline = "closed";
|
||||||
|
impl_->info.streaming = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TarisCameraSource::start() { impl_->gate = true; }
|
||||||
|
void TarisCameraSource::stop() { impl_->gate = false; }
|
||||||
|
|
||||||
|
void TarisCameraSource::setFrameCallback(FrameCallback cb) { impl_->callback = std::move(cb); }
|
||||||
|
|
||||||
|
bool TarisCameraSource::trigger() {
|
||||||
|
Impl& d = *impl_;
|
||||||
|
if (!d.gate || !d.callback) return false;
|
||||||
|
Frame f;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(d.m);
|
||||||
|
const bool fresh = d.latest_seq > d.delivered_seq &&
|
||||||
|
unixMs() - d.latest.timestamp_ms <= d.o.max_frame_age_ms;
|
||||||
|
if (!fresh) {
|
||||||
|
++d.missed_shots;
|
||||||
|
++d.missed_since_log;
|
||||||
|
d.info.frames_dropped = d.missed_shots + d.packets_lost;
|
||||||
|
const long long now = steadyMs();
|
||||||
|
if (now - d.missed_logged_ms >= kLogEveryMs) {
|
||||||
|
LOG_WARN << "thermal camera: no fresh frame for " << d.missed_since_log
|
||||||
|
<< " picture(s) in the last minute"
|
||||||
|
<< (d.why_offline.empty() ? "" : " (" + d.why_offline + ")");
|
||||||
|
d.missed_logged_ms = now;
|
||||||
|
d.missed_since_log = 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
f = d.latest;
|
||||||
|
d.delivered_seq = d.latest_seq;
|
||||||
|
}
|
||||||
|
d.callback(f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CameraDeviceInfo> TarisCameraSource::deviceInfo() {
|
||||||
|
std::lock_guard<std::mutex> l(impl_->m);
|
||||||
|
return {impl_->info};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string TarisCameraSource::offline(int camera_index) {
|
||||||
|
if (camera_index != 0) return "no such camera";
|
||||||
|
std::lock_guard<std::mutex> l(impl_->m);
|
||||||
|
return impl_->why_offline;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TarisCameraSource::grabFrame(int camera_index, Frame& out, int timeout_ms) {
|
||||||
|
if (camera_index != 0) return false;
|
||||||
|
Impl& d = *impl_;
|
||||||
|
std::unique_lock<std::mutex> l(d.m);
|
||||||
|
const uint64_t after = d.latest_seq;
|
||||||
|
if (!d.cv.wait_for(l, std::chrono::milliseconds(timeout_ms),
|
||||||
|
[&] { return d.latest_seq > after || !d.running; }) ||
|
||||||
|
d.latest_seq <= after)
|
||||||
|
return false;
|
||||||
|
out = d.latest;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TarisCameraSource::readFeature(int camera_index, const std::string& name, double& value) {
|
||||||
|
if (camera_index != 0) return false;
|
||||||
|
std::lock_guard<std::mutex> l(impl_->m);
|
||||||
|
if (name == "CalibProfile") {
|
||||||
|
if (!impl_->latest.thermal) return false;
|
||||||
|
value = impl_->latest.thermal->cal;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (name == "KelvinPerCount") {
|
||||||
|
if (!impl_->latest.thermal) return false;
|
||||||
|
value = impl_->latest.thermal->kelvin_per_count;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (name == "HousingTemperature") {
|
||||||
|
if (impl_->info.temperature_c == 0.0) return false;
|
||||||
|
value = impl_->info.temperature_c;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TarisCameraSource::setFeature(int camera_index, const std::string& name,
|
||||||
|
const std::string& value) {
|
||||||
|
if (camera_index != 0 || name != "CalibProfile") return false;
|
||||||
|
int profile = -1;
|
||||||
|
try {
|
||||||
|
size_t used = 0;
|
||||||
|
profile = std::stoi(value, &used);
|
||||||
|
if (used != value.size()) return false;
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Impl& d = *impl_;
|
||||||
|
std::unique_lock<std::mutex> l(d.m);
|
||||||
|
if (!d.running || !d.why_offline.empty()) return false;
|
||||||
|
d.cal_wanted = profile;
|
||||||
|
d.cal_done = false;
|
||||||
|
d.cv.notify_all();
|
||||||
|
if (!d.cv.wait_for(l, std::chrono::milliseconds(d.o.calib_timeout_ms + 5000),
|
||||||
|
[&] { return d.cal_done || !d.running; }))
|
||||||
|
return false;
|
||||||
|
return d.cal_done && d.cal_ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TarisCameraSource::resetCamera(int camera_index) {
|
||||||
|
if (camera_index != 0) return false;
|
||||||
|
Impl& d = *impl_;
|
||||||
|
std::unique_lock<std::mutex> l(d.m);
|
||||||
|
if (!d.running) return false;
|
||||||
|
const uint64_t before = d.connects;
|
||||||
|
const uint64_t seq = d.latest_seq;
|
||||||
|
d.reset_wanted = true;
|
||||||
|
d.cv.notify_all();
|
||||||
|
// Back once it has connected again and delivered a frame.
|
||||||
|
return d.cv.wait_for(l, std::chrono::seconds(45), [&] {
|
||||||
|
return (d.connects > before && d.latest_seq > seq) || !d.running;
|
||||||
|
}) && d.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -63,6 +63,9 @@
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
|
#include "fgc/CompositeCameraSource.h"
|
||||||
|
#include "fgc/TarisCameraSource.h"
|
||||||
|
|
||||||
#if FGC_WITH_MQTT
|
#if FGC_WITH_MQTT
|
||||||
#include "fgc/MqttControlChannel.h"
|
#include "fgc/MqttControlChannel.h"
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -420,6 +423,9 @@ struct Application::Impl {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One source for the colour cameras (Vimba, or the mock), one per thermal
|
||||||
|
// camera, behind a CompositeCameraSource when there is more than one. The
|
||||||
|
// colour source leads (CameraConfig::cameras puts its cameras first).
|
||||||
std::unique_ptr<ICameraSource> makeCamera() {
|
std::unique_ptr<ICameraSource> makeCamera() {
|
||||||
bool mock = !realCameraWanted();
|
bool mock = !realCameraWanted();
|
||||||
if (mock && !cfg.features.enable_camera) {
|
if (mock && !cfg.features.enable_camera) {
|
||||||
|
|
@ -431,7 +437,27 @@ struct Application::Impl {
|
||||||
LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera";
|
LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera";
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
const auto active = cfg.camera.active();
|
std::vector<const CameraSpec*> colour, thermal;
|
||||||
|
for (const auto* c : cfg.camera.active()) (c->thermal() ? thermal : colour).push_back(c);
|
||||||
|
|
||||||
|
// With the camera count each source is configured with: Vimba reports
|
||||||
|
// none until open(), and the thermal camera's index must not move.
|
||||||
|
std::vector<std::unique_ptr<ICameraSource>> sources;
|
||||||
|
std::vector<int> counts;
|
||||||
|
if (!colour.empty() || thermal.empty()) {
|
||||||
|
sources.push_back(makeColourCamera(colour, mock));
|
||||||
|
counts.push_back(colour.empty() ? 1 : static_cast<int>(colour.size()));
|
||||||
|
}
|
||||||
|
for (const auto* c : thermal) {
|
||||||
|
sources.push_back(makeThermalCamera(*c));
|
||||||
|
counts.push_back(1);
|
||||||
|
}
|
||||||
|
if (sources.size() == 1) return std::move(sources.front());
|
||||||
|
return std::make_unique<CompositeCameraSource>(std::move(sources), std::move(counts));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<ICameraSource> makeColourCamera(const std::vector<const CameraSpec*>& active,
|
||||||
|
bool mock) {
|
||||||
if (mock) {
|
if (mock) {
|
||||||
const int n = active.empty() ? 1 : static_cast<int>(active.size());
|
const int n = active.empty() ? 1 : static_cast<int>(active.size());
|
||||||
if (opts.mock_camera_full.value_or(cfg.features.mock_camera_full)) {
|
if (opts.mock_camera_full.value_or(cfg.features.mock_camera_full)) {
|
||||||
|
|
@ -453,6 +479,19 @@ struct Application::Impl {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A thermal camera needs no build option: its SDK is loaded at run time, and
|
||||||
|
// a tower without it just has that camera offline. Only a mock or disabled
|
||||||
|
// camera run gets synthetic counts instead.
|
||||||
|
std::unique_ptr<ICameraSource> makeThermalCamera(const CameraSpec& c) {
|
||||||
|
if (!cfg.features.enable_camera || opts.mock_camera.value_or(cfg.features.mock_camera)) {
|
||||||
|
LOG_INFO << "[mock] thermal camera '" << c.name << "': synthetic 640x480 counts";
|
||||||
|
return std::make_unique<MockCameraSource>(1, 640, 480, PixelLayout::Mono16);
|
||||||
|
}
|
||||||
|
LOG_INFO << "thermal camera '" << c.name << "': TarisIR " << c.id << ", profile "
|
||||||
|
<< c.calib_profile << ", " << c.frame_rate_hz << " Hz";
|
||||||
|
return std::make_unique<TarisCameraSource>(TarisCameraSource::fromSpec(c));
|
||||||
|
}
|
||||||
|
|
||||||
// Load every profile of every camera up front. Two reasons: a profile nobody
|
// Load every profile of every camera up front. Two reasons: a profile nobody
|
||||||
// is using today still fails at startup rather than the first time someone
|
// is using today still fails at startup rather than the first time someone
|
||||||
// switches to it, and a settings XML edited in the Viewer is detected and
|
// switches to it, and a settings XML edited in the Viewer is detected and
|
||||||
|
|
@ -464,6 +503,14 @@ struct Application::Impl {
|
||||||
|
|
||||||
for (const auto& c : cfg.camera.cameras) {
|
for (const auto& c : cfg.camera.cameras) {
|
||||||
if (!c.enabled) continue;
|
if (!c.enabled) continue;
|
||||||
|
if (c.thermal()) {
|
||||||
|
// No settings file: the thermal camera is set up from its config
|
||||||
|
// keys, and its profile only names a timing block.
|
||||||
|
active_profile[c.name] = c.profile;
|
||||||
|
LOG_INFO << "camera '" << c.name << "': thermal, no settings XML; timing '"
|
||||||
|
<< c.profile << "'";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
std::map<std::string, fgc::ProfileLoadResult> results;
|
std::map<std::string, fgc::ProfileLoadResult> results;
|
||||||
auto profiles = fgc::loadCameraProfiles(c.profile_dir, results);
|
auto profiles = fgc::loadCameraProfiles(c.profile_dir, results);
|
||||||
|
|
||||||
|
|
@ -500,6 +547,26 @@ struct Application::Impl {
|
||||||
// Switch one camera's profile by name. With no camera named, applies to the
|
// Switch one camera's profile by name. With no camera named, applies to the
|
||||||
// single configured camera. Returns false (and warns) if unknown.
|
// single configured camera. Returns false (and warns) if unknown.
|
||||||
bool applyProfile(const std::string& cam_name, const std::string& name) {
|
bool applyProfile(const std::string& cam_name, const std::string& name) {
|
||||||
|
for (const auto& c : cfg.camera.cameras) {
|
||||||
|
if (c.name != cam_name || !c.thermal()) continue;
|
||||||
|
// A thermal camera's profile is its timing block and nothing else.
|
||||||
|
if (!c.enabled) {
|
||||||
|
LOG_WARN << "camera '" << cam_name << "' is configured but not enabled";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name != c.profile && c.timing.find(name) == c.timing.end()) {
|
||||||
|
std::string known = c.profile;
|
||||||
|
for (const auto& [n, t] : c.timing)
|
||||||
|
if (n != c.profile) known += ", " + n;
|
||||||
|
LOG_WARN << "unknown profile '" << name << "' for camera '" << cam_name
|
||||||
|
<< "' (available: " << known << ')';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
active_profile[cam_name] = name;
|
||||||
|
configureSettle();
|
||||||
|
LOG_INFO << "camera '" << cam_name << "' profile -> " << name << " (timing only)";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
auto ci = camera_profiles.find(cam_name);
|
auto ci = camera_profiles.find(cam_name);
|
||||||
if (ci == camera_profiles.end()) {
|
if (ci == camera_profiles.end()) {
|
||||||
std::string known;
|
std::string known;
|
||||||
|
|
@ -551,6 +618,15 @@ struct Application::Impl {
|
||||||
rule = rule || t.settle_rule == "brightness";
|
rule = rule || t.settle_rule == "brightness";
|
||||||
}
|
}
|
||||||
scheduler->setSettleWait(settle, timeout);
|
scheduler->setSettleWait(settle, timeout);
|
||||||
|
// The thermal camera hands over the newest frame it has, up to
|
||||||
|
// max_frame_age_ms old: older than the settle wait, and it may have been
|
||||||
|
// taken while the gimbal still moved.
|
||||||
|
for (const auto* c : cfg.camera.active())
|
||||||
|
if (c->thermal() && c->max_frame_age_ms > settle)
|
||||||
|
LOG_WARN << "camera '" << c->name << "': a frame may be up to "
|
||||||
|
<< c->max_frame_age_ms << " ms old, but the scan waits only " << settle
|
||||||
|
<< " ms after standstill - lower max_frame_age_ms, or the thermal "
|
||||||
|
"image may be blurred by the last move";
|
||||||
SettleRule::Params p;
|
SettleRule::Params p;
|
||||||
if (rule && camera) {
|
if (rule && camera) {
|
||||||
double v = 0;
|
double v = 0;
|
||||||
|
|
@ -1037,8 +1113,10 @@ struct Application::Impl {
|
||||||
// Live per-camera identity/telemetry/stats.
|
// Live per-camera identity/telemetry/stats.
|
||||||
s.capture.devices.clear();
|
s.capture.devices.clear();
|
||||||
if (camera) {
|
if (camera) {
|
||||||
|
int index = 0;
|
||||||
for (const auto& d : camera->deviceInfo()) {
|
for (const auto& d : camera->deviceInfo()) {
|
||||||
CameraDeviceView v;
|
CameraDeviceView v;
|
||||||
|
v.offline = camera->offline(index++);
|
||||||
v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming;
|
v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming;
|
||||||
v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes;
|
v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes;
|
||||||
v.exposure_us = d.exposure_us; v.gain_db = d.gain_db;
|
v.exposure_us = d.exposure_us; v.gain_db = d.gain_db;
|
||||||
|
|
@ -1067,6 +1145,14 @@ struct Application::Impl {
|
||||||
s.capture.last_pitch_deg = ev->pitch_decideg / 10.0;
|
s.capture.last_pitch_deg = ev->pitch_decideg / 10.0;
|
||||||
s.capture.last_ts_ms = ev->timestamp_ms;
|
s.capture.last_ts_ms = ev->timestamp_ms;
|
||||||
}
|
}
|
||||||
|
if (auto ev = pipeline->lastThermalEvent(); ev && ev->thermal) {
|
||||||
|
s.capture.has_thermal = true;
|
||||||
|
s.capture.thermal_cal = ev->thermal->cal;
|
||||||
|
s.capture.thermal_stats_ok = ev->thermal->stats_ok;
|
||||||
|
s.capture.thermal_max_c = ev->thermal->tmax_decic / 10.0;
|
||||||
|
s.capture.thermal_med_c = ev->thermal->tmed_decic / 10.0;
|
||||||
|
s.capture.thermal_ts_ms = ev->timestamp_ms;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Connectivity ---
|
// --- Connectivity ---
|
||||||
|
|
@ -1337,6 +1423,14 @@ struct Application::Impl {
|
||||||
h.save_ms_mean = t.mean_ms;
|
h.save_ms_mean = t.mean_ms;
|
||||||
h.save_ms_max = t.max_ms;
|
h.save_ms_max = t.max_ms;
|
||||||
}
|
}
|
||||||
|
if (camera) {
|
||||||
|
// Only a camera the scan can do without is ever offline (the
|
||||||
|
// thermal one); the colour camera failing stops the run instead.
|
||||||
|
const auto active = cfg.camera.active();
|
||||||
|
for (int i = 0; i < camera->cameraCount() && i < static_cast<int>(active.size()); ++i)
|
||||||
|
if (!camera->offline(i).empty())
|
||||||
|
h.cams_offline += (h.cams_offline.empty() ? "" : ",") + active[i]->label;
|
||||||
|
}
|
||||||
h.held_s = thermal_.heldForSaveMs() / 1000;
|
h.held_s = thermal_.heldForSaveMs() / 1000;
|
||||||
h.interval_s = scheduler ? scheduler->intervalMs() / 1000.0 : 0.0;
|
h.interval_s = scheduler ? scheduler->intervalMs() / 1000.0 : 0.0;
|
||||||
h.timestamp_ms = nowEpochMs();
|
h.timestamp_ms = nowEpochMs();
|
||||||
|
|
@ -2077,12 +2171,77 @@ struct Application::Impl {
|
||||||
// profile. For a stream that has stopped delivering and will not restart -
|
// profile. For a stream that has stopped delivering and will not restart -
|
||||||
// the state tower 111's camera was in on 2026-09-24, which only this cleared.
|
// the state tower 111's camera was in on 2026-09-24, which only this cleared.
|
||||||
// Manual mode only (allowedIn), so there is no scan to pause around it.
|
// Manual mode only (allowedIn), so there is no scan to pause around it.
|
||||||
void resetCameras() {
|
// With a camera named (block name or label), only that one: the thermal
|
||||||
|
// camera reconnects without rebooting the colour one.
|
||||||
|
void resetCameras(const std::string& which = "") {
|
||||||
|
const auto active = cfg.camera.active();
|
||||||
|
int only = -1;
|
||||||
|
auto lowered = [](std::string v) {
|
||||||
|
std::transform(v.begin(), v.end(), v.begin(),
|
||||||
|
[](unsigned char ch) { return std::tolower(ch); });
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
if (!which.empty()) {
|
||||||
|
for (size_t i = 0; i < active.size(); ++i)
|
||||||
|
if (lowered(active[i]->name) == lowered(which) ||
|
||||||
|
lowered(active[i]->label) == lowered(which))
|
||||||
|
only = static_cast<int>(i);
|
||||||
|
if (only < 0) {
|
||||||
|
LOG_WARN << "camera reset: no active camera '" << which << "'";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
camera->stop();
|
camera->stop();
|
||||||
for (int i = 0; i < camera->cameraCount(); ++i)
|
for (int i = 0; i < camera->cameraCount(); ++i) {
|
||||||
LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile"
|
if (only >= 0 && i != only) continue;
|
||||||
|
const std::string name = i < static_cast<int>(active.size())
|
||||||
|
? active[i]->name
|
||||||
|
: std::to_string(i);
|
||||||
|
LOG_INFO << "camera " << name
|
||||||
|
<< (camera->resetCamera(i) ? ": reset, back on its profile"
|
||||||
: ": reset FAILED (see above)");
|
: ": reset FAILED (see above)");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `camera calib [<profile>]`: the thermal camera's calibration profile,
|
||||||
|
// which sets the scale of its counts. Every frame says which it was taken
|
||||||
|
// on, so switching is safe for the archive; it is manual-mode only because
|
||||||
|
// the camera delivers nothing for the ~3 s it takes.
|
||||||
|
void handleCalib(const Command& c) {
|
||||||
|
int index = -1, i = 0;
|
||||||
|
for (const auto* s : cfg.camera.active()) {
|
||||||
|
if (s->thermal()) { index = i; break; }
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (index < 0) {
|
||||||
|
LOG_WARN << "camera calib: no thermal camera configured";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::string why = camera->offline(index);
|
||||||
|
if (!why.empty()) {
|
||||||
|
LOG_WARN << "camera calib: the thermal camera is offline (" << why << ")";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!c.has_value) {
|
||||||
|
double v = 0, k = 0;
|
||||||
|
if (camera->readFeature(index, "CalibProfile", v) &&
|
||||||
|
camera->readFeature(index, "KelvinPerCount", k))
|
||||||
|
LOG_INFO << "thermal calibration profile " << static_cast<int>(v) << ", " << k
|
||||||
|
<< " K per count";
|
||||||
|
else
|
||||||
|
LOG_INFO << "thermal calibration profile: no frame yet";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int profile = static_cast<int>(c.value);
|
||||||
|
LOG_INFO << "thermal camera: switching to calibration profile " << profile << " ...";
|
||||||
|
if (camera->setFeature(index, "CalibProfile", std::to_string(profile)))
|
||||||
|
LOG_INFO << "thermal camera on calibration profile " << profile
|
||||||
|
<< " (for this run; [Camera." << cfg.camera.active()[index]->name
|
||||||
|
<< "] calib_profile sets it at startup)";
|
||||||
|
else
|
||||||
|
LOG_WARN << "thermal camera: switching to calibration profile " << profile
|
||||||
|
<< " FAILED (see above)";
|
||||||
|
}
|
||||||
|
|
||||||
// The one way the mode changes - `mode ...`, the a / p / m keys, [Startup] mode, and a
|
// The one way the mode changes - `mode ...`, the a / p / m keys, [Startup] mode, and a
|
||||||
// test pausing the scan all come here. Returns false, having said why and
|
// test pausing the scan all come here. Returns false, having said why and
|
||||||
|
|
@ -3160,8 +3319,9 @@ struct Application::Impl {
|
||||||
} else if (c.verb == "exit") {
|
} else if (c.verb == "exit") {
|
||||||
running = false;
|
running = false;
|
||||||
} else if (c.verb == "camera") {
|
} else if (c.verb == "camera") {
|
||||||
if (c.device == "reset") resetCameras();
|
if (c.device == "reset") resetCameras(c.option);
|
||||||
else LOG_WARN << "usage: camera reset";
|
else if (c.device == "calib") handleCalib(c);
|
||||||
|
else LOG_WARN << "usage: camera reset [<camera>] | camera calib [<profile>]";
|
||||||
} else if (c.verb == "snapshot") {
|
} else if (c.verb == "snapshot") {
|
||||||
takeSnapshot();
|
takeSnapshot();
|
||||||
} else if (c.verb == "capture") {
|
} else if (c.verb == "capture") {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#include "fgc/ChannelTopics.h"
|
#include "fgc/ChannelTopics.h"
|
||||||
|
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
@ -9,23 +10,47 @@ std::string towerTopic(const std::string& tower, const std::string& leaf) {
|
||||||
return "GGS/FWT/" + tower + "/" + leaf;
|
return "GGS/FWT/" + tower + "/" + leaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string camEventJson(const CamEvent& e) {
|
|
||||||
return "{ \"fwt\":\"" + e.tower + "\", \"cam\":\"" + e.camera +
|
|
||||||
"\", \"hdg\":" + std::to_string(e.heading_decideg) +
|
|
||||||
", \"pit\":" + std::to_string(e.pitch_decideg) +
|
|
||||||
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
// Fixed-point, with a '.' whatever the C locale says. printf would follow
|
||||||
|
// LC_NUMERIC, and the thermal camera's SDK sets that to a comma locale when it
|
||||||
|
// loads (seen on tower 111: "hotx":0,4336) - which is no longer JSON.
|
||||||
std::string num(double v, int decimals) {
|
std::string num(double v, int decimals) {
|
||||||
char b[32];
|
if (!std::isfinite(v)) return "null";
|
||||||
std::snprintf(b, sizeof(b), "%.*f", decimals, v);
|
long long scale = 1;
|
||||||
return b;
|
for (int i = 0; i < decimals; ++i) scale *= 10;
|
||||||
|
const long long n = std::llround(v * static_cast<double>(scale));
|
||||||
|
const bool neg = n < 0;
|
||||||
|
const unsigned long long a = neg ? 0ULL - static_cast<unsigned long long>(n)
|
||||||
|
: static_cast<unsigned long long>(n);
|
||||||
|
std::string out = (neg ? "-" : "") + std::to_string(a / static_cast<unsigned long long>(scale));
|
||||||
|
if (decimals > 0) {
|
||||||
|
std::string frac = std::to_string(a % static_cast<unsigned long long>(scale));
|
||||||
|
out += "." + std::string(static_cast<size_t>(decimals) - frac.size(), '0') + frac;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
std::string camEventJson(const CamEvent& e) {
|
||||||
|
std::string out = "{ \"fwt\":" + jsonString(e.tower) + ", \"cam\":" + jsonString(e.camera) +
|
||||||
|
", \"hdg\":" + std::to_string(e.heading_decideg) +
|
||||||
|
", \"pit\":" + std::to_string(e.pitch_decideg) +
|
||||||
|
", \"time\":" + std::to_string(e.timestamp_ms);
|
||||||
|
if (e.thermal) {
|
||||||
|
const ThermalEventFields& t = *e.thermal;
|
||||||
|
auto opt = [&t](const std::string& v) { return t.stats_ok ? v : std::string("null"); };
|
||||||
|
out += ", \"cal\":" + std::to_string(t.cal) +
|
||||||
|
", \"missed\":" + std::to_string(t.missed) +
|
||||||
|
", \"tmax\":" + opt(std::to_string(t.tmax_decic)) +
|
||||||
|
", \"tmed\":" + opt(std::to_string(t.tmed_decic)) +
|
||||||
|
", \"hotx\":" + opt(num(t.hotx, 4)) +
|
||||||
|
", \"hoty\":" + opt(num(t.hoty, 4));
|
||||||
|
}
|
||||||
|
return out + " }";
|
||||||
|
}
|
||||||
|
|
||||||
std::string healthJson(const HealthReport& h) {
|
std::string healthJson(const HealthReport& h) {
|
||||||
auto opt = [](bool ok, double v, int d) { return ok ? num(v, d) : std::string("null"); };
|
auto opt = [](bool ok, double v, int d) { return ok ? num(v, d) : std::string("null"); };
|
||||||
return "{\"fwt\":" + jsonString(h.tower) + ",\"state\":" + jsonString(h.state) +
|
return "{\"fwt\":" + jsonString(h.tower) + ",\"state\":" + jsonString(h.state) +
|
||||||
|
|
@ -40,6 +65,7 @@ std::string healthJson(const HealthReport& h) {
|
||||||
",\"saved_last_min\":" + std::to_string(h.save_count_last_min) +
|
",\"saved_last_min\":" + std::to_string(h.save_count_last_min) +
|
||||||
",\"save_ms_mean\":" + opt(h.save_count_last_min > 0, h.save_ms_mean, 0) +
|
",\"save_ms_mean\":" + opt(h.save_count_last_min > 0, h.save_ms_mean, 0) +
|
||||||
",\"save_ms_max\":" + opt(h.save_count_last_min > 0, h.save_ms_max, 0) +
|
",\"save_ms_max\":" + opt(h.save_count_last_min > 0, h.save_ms_max, 0) +
|
||||||
|
",\"cams_offline\":" + jsonString(h.cams_offline) +
|
||||||
",\"held_s\":" + std::to_string(h.held_s) +
|
",\"held_s\":" + std::to_string(h.held_s) +
|
||||||
",\"interval_s\":" + num(h.interval_s, 1) +
|
",\"interval_s\":" + num(h.interval_s, 1) +
|
||||||
",\"time\":" + std::to_string(h.timestamp_ms) + "}";
|
",\"time\":" + std::to_string(h.timestamp_ms) + "}";
|
||||||
|
|
|
||||||
|
|
@ -383,9 +383,17 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
c.profile_dir = get(kv, pre + "profile_dir");
|
c.profile_dir = get(kv, pre + "profile_dir");
|
||||||
c.profile = get(kv, pre + "profile", c.profile);
|
c.profile = get(kv, pre + "profile", c.profile);
|
||||||
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);
|
// A thermal camera's counts are measurements: lossless unless someone
|
||||||
|
// asks otherwise, and asking is refused below.
|
||||||
|
c.jxl_distance = getDouble(kv, pre + "jxl_distance",
|
||||||
|
c.driver == "taris" ? 0.0 : 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));
|
c.demosaic = lowered(get(kv, pre + "demosaic", cfg.camera.demosaic));
|
||||||
|
c.calib_profile = getInt(kv, pre + "calib_profile", c.calib_profile);
|
||||||
|
c.frame_rate_hz = getDouble(kv, pre + "frame_rate_hz", c.frame_rate_hz);
|
||||||
|
c.max_frame_age_ms = getInt(kv, pre + "max_frame_age_ms", c.max_frame_age_ms);
|
||||||
|
c.library = get(kv, pre + "library", c.library);
|
||||||
|
c.interface = get(kv, pre + "interface");
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
@ -407,13 +415,28 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
"'fixed' or 'brightness'");
|
"'fixed' or 'brightness'");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.driver != "vimba")
|
if (c.driver != "vimba" && c.driver != "taris")
|
||||||
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' (got '" +
|
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' or 'taris' (got '" +
|
||||||
c.driver + "'); no other backend is implemented yet");
|
c.driver + "')");
|
||||||
if (c.enabled && c.id.empty())
|
if (c.enabled && c.id.empty())
|
||||||
throw std::runtime_error("Camera." + name + ".id is required (USB DEV_ id or GigE IP)");
|
throw std::runtime_error("Camera." + name + ".id is required (" +
|
||||||
if (c.profile_dir.empty())
|
(c.thermal() ? "the camera's serial number"
|
||||||
|
: "USB DEV_ id or GigE IP") + ")");
|
||||||
|
if (!c.thermal() && c.profile_dir.empty())
|
||||||
throw std::runtime_error("Camera." + name + ".profile_dir is required");
|
throw std::runtime_error("Camera." + name + ".profile_dir is required");
|
||||||
|
if (c.thermal()) {
|
||||||
|
if (c.jxl_distance != 0.0)
|
||||||
|
throw std::runtime_error("Camera." + name + ".jxl_distance must be 0: a thermal "
|
||||||
|
"camera's counts are always saved lossless");
|
||||||
|
if (c.calib_profile < 0)
|
||||||
|
throw std::runtime_error("Camera." + name + ".calib_profile must be >= 0");
|
||||||
|
if (!(c.frame_rate_hz > 0.0 && c.frame_rate_hz <= 50.0))
|
||||||
|
throw std::runtime_error("Camera." + name + ".frame_rate_hz must be in (0, 50]");
|
||||||
|
if (c.max_frame_age_ms <= 0)
|
||||||
|
throw std::runtime_error("Camera." + name + ".max_frame_age_ms must be > 0");
|
||||||
|
if (c.library.empty())
|
||||||
|
throw std::runtime_error("Camera." + name + ".library must not be empty");
|
||||||
|
}
|
||||||
if (c.jxl_distance < 0.0)
|
if (c.jxl_distance < 0.0)
|
||||||
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)
|
||||||
|
|
@ -431,6 +454,27 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
||||||
}
|
}
|
||||||
cfg.camera.cameras.push_back(std::move(c));
|
cfg.camera.cameras.push_back(std::move(c));
|
||||||
}
|
}
|
||||||
|
// A thermal camera takes the newest frame it already has, so it needs no
|
||||||
|
// settle wait of its own; without a timing block it must not hold the scan
|
||||||
|
// to the 1000 ms default (the scan waits for the longest camera).
|
||||||
|
for (auto& c : cfg.camera.cameras)
|
||||||
|
if (c.thermal() && c.timing.find(c.profile) == c.timing.end())
|
||||||
|
c.timing[c.profile] = CameraProfileTiming{0, 0, "fixed"};
|
||||||
|
// Colour cameras first (see CameraConfig::cameras): the keep rule reads camera
|
||||||
|
// 0, and a thermal camera has none of the features it needs.
|
||||||
|
std::stable_partition(cfg.camera.cameras.begin(), cfg.camera.cameras.end(),
|
||||||
|
[](const CameraSpec& c) { return !c.thermal(); });
|
||||||
|
// The label is the output folder, and the cameras of one picture share its
|
||||||
|
// timestamp: two with one label would write the same file.
|
||||||
|
for (size_t i = 0; i < cfg.camera.cameras.size(); ++i)
|
||||||
|
for (size_t j = i + 1; j < cfg.camera.cameras.size(); ++j) {
|
||||||
|
const auto& a = cfg.camera.cameras[i];
|
||||||
|
const auto& b = cfg.camera.cameras[j];
|
||||||
|
if (a.enabled && b.enabled && a.label == b.label)
|
||||||
|
throw std::runtime_error("Camera." + a.name + " and Camera." + b.name +
|
||||||
|
" both have label '" + a.label +
|
||||||
|
"': each camera needs its own (its output folder)");
|
||||||
|
}
|
||||||
|
|
||||||
std::string out = get(kv, "Paths.output_dir");
|
std::string out = get(kv, "Paths.output_dir");
|
||||||
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);
|
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);
|
||||||
|
|
@ -770,6 +814,7 @@ AppConfig ConfigLoader::loadFromFile(const std::string& path, const std::string&
|
||||||
}
|
}
|
||||||
// Same for each camera's profile directory.
|
// Same for each camera's profile directory.
|
||||||
for (auto& c : cfg.camera.cameras) {
|
for (auto& c : cfg.camera.cameras) {
|
||||||
|
if (c.profile_dir.empty()) continue; // a thermal camera has none
|
||||||
std::filesystem::path pd(paths::expandUser(c.profile_dir));
|
std::filesystem::path pd(paths::expandUser(c.profile_dir));
|
||||||
if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd;
|
if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd;
|
||||||
c.profile_dir = pd.lexically_normal().string();
|
c.profile_dir = pd.lexically_normal().string();
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,17 @@ const std::vector<HelpSection>& helpCatalog() {
|
||||||
"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 colour camera is configured. For the thermal",
|
||||||
|
"camera it only picks the timing block.",
|
||||||
"Example: set camera profile night"}},
|
"Example: set camera profile night"}},
|
||||||
{"camera reset", "Reboot the cameras and restore their profiles.", {
|
{"camera reset [<camera>]", "Reboot the cameras and restore their profiles.", {
|
||||||
"For a stream that has stopped and will not restart. Takes seconds."}},
|
"For a stream that has stopped and will not restart. Takes seconds.",
|
||||||
|
"Name one camera (block name or label) to reset only that one."}},
|
||||||
|
{"camera calib [<profile>]", "The thermal camera's calibration profile.", {
|
||||||
|
"Bare: which profile, and its K per count. With a number: switch (about",
|
||||||
|
"3 s, no thermal frames meanwhile). Profile 0 is -40..120 degC at",
|
||||||
|
"0.01 K per count, 1 is 0..600 degC at 0.02 K. Every CamEvent says",
|
||||||
|
"which one its image was taken on (cal)."}},
|
||||||
}},
|
}},
|
||||||
{"Positioning", "Yaw is a true bearing (0 = north); pitch is elevation (0 = level).", {
|
{"Positioning", "Yaw is a true bearing (0 = north); pitch is elevation (0 = level).", {
|
||||||
{"gimbal move <yaw>[,<pitch>]", "Point at a bearing/elevation in degrees.", {
|
{"gimbal move <yaw>[,<pitch>]", "Point at a bearing/elevation in degrees.", {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
#include "fgc/RawImage.h"
|
#include "fgc/RawImage.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
namespace fgc {
|
namespace fgc {
|
||||||
|
|
||||||
const char* layoutName(PixelLayout layout) {
|
const char* layoutName(PixelLayout layout) {
|
||||||
|
|
@ -8,6 +10,7 @@ const char* layoutName(PixelLayout layout) {
|
||||||
case PixelLayout::RGB8: return "RGB8";
|
case PixelLayout::RGB8: return "RGB8";
|
||||||
case PixelLayout::BayerRG8: return "BayerRG8";
|
case PixelLayout::BayerRG8: return "BayerRG8";
|
||||||
case PixelLayout::BayerRG12p: return "BayerRG12p";
|
case PixelLayout::BayerRG12p: return "BayerRG12p";
|
||||||
|
case PixelLayout::Mono16: return "Mono16";
|
||||||
case PixelLayout::Unknown: break;
|
case PixelLayout::Unknown: break;
|
||||||
}
|
}
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
|
|
@ -15,7 +18,7 @@ const char* layoutName(PixelLayout layout) {
|
||||||
|
|
||||||
PixelLayout layoutFromName(const std::string& name) {
|
PixelLayout layoutFromName(const std::string& name) {
|
||||||
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
|
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
|
||||||
PixelLayout::BayerRG12p})
|
PixelLayout::BayerRG12p, PixelLayout::Mono16})
|
||||||
if (name == layoutName(l)) return l;
|
if (name == layoutName(l)) return l;
|
||||||
return PixelLayout::Unknown;
|
return PixelLayout::Unknown;
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +33,7 @@ int layoutBits(PixelLayout layout) {
|
||||||
case PixelLayout::RGB8:
|
case PixelLayout::RGB8:
|
||||||
case PixelLayout::BayerRG8: return 8;
|
case PixelLayout::BayerRG8: return 8;
|
||||||
case PixelLayout::BayerRG12p: return 12;
|
case PixelLayout::BayerRG12p: return 12;
|
||||||
|
case PixelLayout::Mono16: return 16;
|
||||||
case PixelLayout::Unknown: break;
|
case PixelLayout::Unknown: break;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -42,6 +46,7 @@ size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height) {
|
||||||
case PixelLayout::BayerRG8: return n;
|
case PixelLayout::BayerRG8: return n;
|
||||||
case PixelLayout::RGB8: return n * 3;
|
case PixelLayout::RGB8: return n * 3;
|
||||||
case PixelLayout::BayerRG12p: return (n * 3 + 1) / 2;
|
case PixelLayout::BayerRG12p: return (n * 3 + 1) / 2;
|
||||||
|
case PixelLayout::Mono16: return n * 2;
|
||||||
case PixelLayout::Unknown: break;
|
case PixelLayout::Unknown: break;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -49,6 +54,12 @@ size_t layoutFrameBytes(PixelLayout layout, uint32_t width, uint32_t height) {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
inline uint16_t px16(const uint8_t* in, size_t i) {
|
||||||
|
uint16_t v;
|
||||||
|
std::memcpy(&v, in + i * 2, sizeof v); // host order, possibly unaligned
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
inline uint16_t px12p(const uint8_t* in, size_t i) {
|
inline uint16_t px12p(const uint8_t* in, size_t i) {
|
||||||
const uint8_t* p = in + (i >> 1) * 3;
|
const uint8_t* p = in + (i >> 1) * 3;
|
||||||
return (i & 1) == 0 ? static_cast<uint16_t>(p[0] | ((p[1] & 0x0F) << 8))
|
return (i & 1) == 0 ? static_cast<uint16_t>(p[0] | ((p[1] & 0x0F) << 8))
|
||||||
|
|
@ -79,7 +90,9 @@ BrightnessSample sampleBrightness(const uint8_t* frame, size_t bytes, PixelLayou
|
||||||
double sum = 0;
|
double sum = 0;
|
||||||
size_t hi = 0;
|
size_t hi = 0;
|
||||||
for (size_t i = 0; i < values; i += stride) {
|
for (size_t i = 0; i < values; i += stride) {
|
||||||
double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0 : frame[i];
|
double v = layout == PixelLayout::BayerRG12p ? px12p(frame, i) / 16.0
|
||||||
|
: layout == PixelLayout::Mono16 ? px16(frame, i) / 256.0
|
||||||
|
: frame[i];
|
||||||
sum += v;
|
sum += v;
|
||||||
hi += v >= 250.0;
|
hi += v >= 250.0;
|
||||||
++s.samples;
|
++s.samples;
|
||||||
|
|
|
||||||
|
|
@ -1298,8 +1298,12 @@ void TestRunner::testCamera(TestReport& r) {
|
||||||
f.channels != run.channels) {
|
f.channels != run.channels) {
|
||||||
run.consistent = false;
|
run.consistent = false;
|
||||||
}
|
}
|
||||||
if (f.data.size() != static_cast<size_t>(f.width) * f.height * f.channels)
|
// By layout where it is known: packed 12-bit and 16-bit counts are
|
||||||
run.consistent = false;
|
// not one byte per value.
|
||||||
|
const size_t expect = f.layout != PixelLayout::Unknown
|
||||||
|
? layoutFrameBytes(f.layout, f.width, f.height)
|
||||||
|
: static_cast<size_t>(f.width) * f.height * f.channels;
|
||||||
|
if (f.data.size() != expect) run.consistent = false;
|
||||||
++run.got;
|
++run.got;
|
||||||
}
|
}
|
||||||
const double secs = std::chrono::duration<double>(
|
const double secs = std::chrono::duration<double>(
|
||||||
|
|
@ -1316,6 +1320,17 @@ void TestRunner::testCamera(TestReport& r) {
|
||||||
|
|
||||||
if (!want_dark && !want_bright) continue;
|
if (!want_dark && !want_bright) continue;
|
||||||
|
|
||||||
|
// A thermal camera has no exposure to force: dark and bright are checks
|
||||||
|
// for a colour camera.
|
||||||
|
{
|
||||||
|
double elo = 0, ehi = 0;
|
||||||
|
if (!cam->featureRange(i, "ExposureTime", elo, ehi)) {
|
||||||
|
if (want_dark) sd.notes.push_back(cn + ": not applicable (no exposure control)");
|
||||||
|
if (want_bright) sb.notes.push_back(cn + ": not applicable (no exposure control)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// From here the camera is off its profile. It is put back below on every
|
// From here the camera is off its profile. It is put back below on every
|
||||||
// path out of these phases, and a failure to put it back is a failure of
|
// path out of these phases, and a failure to put it back is a failure of
|
||||||
// the test.
|
// the test.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
#include "fgc/ThermalStats.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fgc {
|
||||||
|
|
||||||
|
int countsToDeciC(uint32_t count, double kelvin_per_count) {
|
||||||
|
return static_cast<int>(std::lround((count * kelvin_per_count - 273.15) * 10.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThermalStats thermalStats(const uint16_t* counts, uint32_t width, uint32_t height,
|
||||||
|
const ThermalFrameInfo& info) {
|
||||||
|
ThermalStats s;
|
||||||
|
if (!counts || width == 0 || height == 0) return s;
|
||||||
|
|
||||||
|
// A histogram gives the median in one pass with no copy of the frame, and
|
||||||
|
// 64k bins are cheaper than sorting 300k values.
|
||||||
|
std::vector<uint32_t> hist(65536, 0);
|
||||||
|
const size_t n = static_cast<size_t>(width) * height;
|
||||||
|
size_t at = 0;
|
||||||
|
uint16_t max = 0;
|
||||||
|
for (size_t i = 0; i < n; ++i) {
|
||||||
|
const uint16_t c = counts[i];
|
||||||
|
if (c == 0 || c > info.valid_count_max) continue;
|
||||||
|
++hist[c];
|
||||||
|
++s.valid_pixels;
|
||||||
|
if (c > max) { max = c; at = i; }
|
||||||
|
}
|
||||||
|
if (s.valid_pixels == 0) return s;
|
||||||
|
|
||||||
|
// The lower median: the ((n-1)/2)-th value in sorted order.
|
||||||
|
const size_t want = (s.valid_pixels - 1) / 2;
|
||||||
|
size_t seen = 0;
|
||||||
|
uint32_t med = 0;
|
||||||
|
for (uint32_t c = 1; c < hist.size(); ++c) {
|
||||||
|
seen += hist[c];
|
||||||
|
if (seen > want) { med = c; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
s.valid = true;
|
||||||
|
s.max_count = max;
|
||||||
|
s.median_count = static_cast<uint16_t>(med);
|
||||||
|
s.tmax_decic = countsToDeciC(max, info.kelvin_per_count);
|
||||||
|
s.tmed_decic = countsToDeciC(med, info.kelvin_per_count);
|
||||||
|
s.hotx = (static_cast<double>(at % width) + 0.5) / width;
|
||||||
|
s.hoty = (static_cast<double>(at / width) + 0.5) / height;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
|
|
@ -379,6 +379,15 @@ Element sensorsPanel(const SensorsView& s) {
|
||||||
group(s.host, std::move(cpu_badge))}));
|
group(s.host, std::move(cpu_badge))}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "max 301.5 °C median 18.7 °C cal 0" for the last thermal picture.
|
||||||
|
Element thermalLine(const CaptureView& c) {
|
||||||
|
if (!c.thermal_stats_ok)
|
||||||
|
return text("no valid pixels cal " + std::to_string(c.thermal_cal)) | color(Color::Yellow);
|
||||||
|
return hbox({text("max " + num(c.thermal_max_c, 1) + " \xC2\xB0" "C") | bold,
|
||||||
|
text(" median " + num(c.thermal_med_c, 1) + " \xC2\xB0" "C") | dim,
|
||||||
|
text(" cal " + std::to_string(c.thermal_cal)) | dim});
|
||||||
|
}
|
||||||
|
|
||||||
Element cameraPanel(const CaptureView& c) {
|
Element cameraPanel(const CaptureView& c) {
|
||||||
std::string labels;
|
std::string labels;
|
||||||
for (size_t i = 0; i < c.labels.size(); ++i)
|
for (size_t i = 0; i < c.labels.size(); ++i)
|
||||||
|
|
@ -417,6 +426,12 @@ Element cameraPanel(const CaptureView& c) {
|
||||||
} else {
|
} else {
|
||||||
rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim));
|
rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim));
|
||||||
}
|
}
|
||||||
|
if (c.has_thermal) rows.push_back(labelRow("thermal", thermalLine(c)));
|
||||||
|
for (size_t i = 0; i < c.devices.size(); ++i)
|
||||||
|
if (!c.devices[i].offline.empty())
|
||||||
|
rows.push_back(labelRow("offline", text((i < c.labels.size() ? c.labels[i] : "?") +
|
||||||
|
" " + c.devices[i].offline) |
|
||||||
|
color(Color::Yellow)));
|
||||||
return panel("[c] CAMERAS", Color::Blue, vbox(std::move(rows)));
|
return panel("[c] CAMERAS", Color::Blue, vbox(std::move(rows)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -442,12 +457,15 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
||||||
for (size_t i = 0; i < c.devices.size(); ++i) {
|
for (size_t i = 0; i < c.devices.size(); ++i) {
|
||||||
const CameraDeviceView& d = c.devices[i];
|
const CameraDeviceView& d = c.devices[i];
|
||||||
std::string label = i < c.labels.size() ? c.labels[i] : ("cam" + std::to_string(i));
|
std::string label = i < c.labels.size() ? c.labels[i] : ("cam" + std::to_string(i));
|
||||||
Element st = d.streaming ? (text("streaming") | color(Color::Green))
|
Element st = !d.offline.empty() ? (text("offline") | color(Color::Red))
|
||||||
|
: d.streaming ? (text("streaming") | color(Color::Green))
|
||||||
: (text("stopped") | color(Color::Yellow));
|
: (text("stopped") | color(Color::Yellow));
|
||||||
rows.push_back(hbox({text(label + " ") | bold,
|
rows.push_back(hbox({text(label + " ") | bold,
|
||||||
text(d.model.empty() ? "?" : d.model) | color(Color::Cyan),
|
text(d.model.empty() ? "?" : d.model) | color(Color::Cyan),
|
||||||
text(" " + d.id) | dim, filler(), st}));
|
text(" " + d.id) | dim, filler(), st}));
|
||||||
rows.push_back(row(" serial", text(d.serial.empty() ? "—" : d.serial) | dim));
|
rows.push_back(row(" serial", text(d.serial.empty() ? "—" : d.serial) | dim));
|
||||||
|
if (!d.offline.empty())
|
||||||
|
rows.push_back(row(" offline", text(d.offline) | color(Color::Red)));
|
||||||
rows.push_back(row(" frame", text(std::to_string(d.width) + "x" + std::to_string(d.height) +
|
rows.push_back(row(" frame", text(std::to_string(d.width) + "x" + std::to_string(d.height) +
|
||||||
" " + num(d.payload_bytes / 1048576.0, 1) + " MB")));
|
" " + num(d.payload_bytes / 1048576.0, 1) + " MB")));
|
||||||
rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " +
|
rows.push_back(row(" exposure", text(num(d.exposure_us, 0) + " us gain " +
|
||||||
|
|
@ -465,8 +483,17 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
||||||
// --- Cameras: one block each, everything explicit ---
|
// --- Cameras: one block each, everything explicit ---
|
||||||
const CameraConfigView& cf = c.config;
|
const CameraConfigView& cf = c.config;
|
||||||
rows.push_back(separator());
|
rows.push_back(separator());
|
||||||
|
{
|
||||||
|
// Which backends: "Vimba X", "Vimba X + TarisIR".
|
||||||
|
std::string backends;
|
||||||
|
auto add = [&backends](const std::string& b) {
|
||||||
|
if (backends.find(b) == std::string::npos) backends += (backends.empty() ? "" : " + ") + b;
|
||||||
|
};
|
||||||
|
for (const auto& v : cf.cameras)
|
||||||
|
if (v.enabled) add(v.driver == "taris" ? "TarisIR" : "Vimba X");
|
||||||
rows.push_back(hbox({text("CAMERAS") | bold | color(Color::Blue),
|
rows.push_back(hbox({text("CAMERAS") | bold | color(Color::Blue),
|
||||||
text(cf.mock ? " MOCK (simulated)" : " Vimba X") | dim}));
|
text(cf.mock ? " MOCK (simulated)" : " " + backends) | dim}));
|
||||||
|
}
|
||||||
if (cf.cameras.empty()) rows.push_back(text("(no cameras configured)") | dim);
|
if (cf.cameras.empty()) rows.push_back(text("(no cameras configured)") | dim);
|
||||||
for (const auto& v : cf.cameras) {
|
for (const auto& v : cf.cameras) {
|
||||||
rows.push_back(hbox({
|
rows.push_back(hbox({
|
||||||
|
|
@ -486,7 +513,11 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
||||||
}
|
}
|
||||||
rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) +
|
rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) +
|
||||||
" ms after standstill before keeping a frame")));
|
" ms after standstill before keeping a frame")));
|
||||||
rows.push_back(row(" encoding", text("JPEG XL distance " + num(v.jxl_distance, 2) +
|
rows.push_back(row(" encoding",
|
||||||
|
text(v.driver == "taris"
|
||||||
|
? "JPEG XL lossless 16-bit counts effort " +
|
||||||
|
std::to_string(v.jxl_effort)
|
||||||
|
: "JPEG XL distance " + num(v.jxl_distance, 2) +
|
||||||
" effort " + std::to_string(v.jxl_effort))));
|
" effort " + std::to_string(v.jxl_effort))));
|
||||||
rows.push_back(row(" rotation", text(v.rotation_deg == 0
|
rows.push_back(row(" rotation", text(v.rotation_deg == 0
|
||||||
? std::string("none")
|
? std::string("none")
|
||||||
|
|
@ -503,6 +534,7 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
|
||||||
formatDegrees(c.last_pitch_deg) + " " +
|
formatDegrees(c.last_pitch_deg) + " " +
|
||||||
formatTimeAgo(now, c.last_ts_ms))));
|
formatTimeAgo(now, c.last_ts_ms))));
|
||||||
}
|
}
|
||||||
|
if (c.has_thermal) rows.push_back(row("last thermal", thermalLine(c)));
|
||||||
|
|
||||||
// --- Scan grid ---
|
// --- Scan grid ---
|
||||||
rows.push_back(separator());
|
rows.push_back(separator());
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ add_executable(fgc_tests
|
||||||
test_hostmetrics.cpp
|
test_hostmetrics.cpp
|
||||||
test_testrunner.cpp
|
test_testrunner.cpp
|
||||||
test_rawimage.cpp
|
test_rawimage.cpp
|
||||||
|
test_thermalstats.cpp
|
||||||
|
test_composite.cpp
|
||||||
|
test_tariscamerasource.cpp
|
||||||
test_settlerule.cpp
|
test_settlerule.cpp
|
||||||
test_thermalguard.cpp
|
test_thermalguard.cpp
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
#include <doctest/doctest.h>
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <clocale>
|
||||||
|
|
||||||
#include "fgc/ChannelHistory.h"
|
#include "fgc/ChannelHistory.h"
|
||||||
#include "fgc/ChannelTopics.h"
|
#include "fgc/ChannelTopics.h"
|
||||||
|
|
||||||
|
|
@ -82,4 +84,52 @@ TEST_CASE("topic and payload builders match the documented wire format") {
|
||||||
CHECK(j.find("\"time\":1719312345678") != std::string::npos);
|
CHECK(j.find("\"time\":1719312345678") != std::string::npos);
|
||||||
// The quality gate is gone; nothing should still be publishing a verdict.
|
// The quality gate is gone; nothing should still be publishing a verdict.
|
||||||
CHECK(j.find("degraded") == std::string::npos);
|
CHECK(j.find("degraded") == std::string::npos);
|
||||||
|
// A colour camera's event carries none of the thermal fields.
|
||||||
|
CHECK(j.find("\"cal\"") == std::string::npos);
|
||||||
|
CHECK(j == "{ \"fwt\":\"Tower\", \"cam\":\"RGB\", \"hdg\":1373, \"pit\":300, "
|
||||||
|
"\"time\":1719312345678 }");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a thermal CamEvent carries profile, packets, temperatures and hot spot") {
|
||||||
|
CamEvent c;
|
||||||
|
c.tower = "Tower"; c.camera = "TIR";
|
||||||
|
c.heading_decideg = 1234; c.pitch_decideg = -30; c.timestamp_ms = 1791234567890LL;
|
||||||
|
ThermalEventFields t;
|
||||||
|
t.cal = 0; t.missed = 2; t.stats_ok = true;
|
||||||
|
t.tmax_decic = 3015; t.tmed_decic = 187; t.hotx = 0.73594; t.hoty = 0.5833;
|
||||||
|
c.thermal = t;
|
||||||
|
CHECK(camEventJson(c) ==
|
||||||
|
"{ \"fwt\":\"Tower\", \"cam\":\"TIR\", \"hdg\":1234, \"pit\":-30, "
|
||||||
|
"\"time\":1791234567890, \"cal\":0, \"missed\":2, \"tmax\":3015, \"tmed\":187, "
|
||||||
|
"\"hotx\":0.7359, \"hoty\":0.5833 }");
|
||||||
|
|
||||||
|
// Without valid pixels the profile and packets still go out, the rest as null.
|
||||||
|
c.thermal->stats_ok = false;
|
||||||
|
const std::string j = camEventJson(c);
|
||||||
|
CHECK(j.find("\"cal\":0, \"missed\":2, \"tmax\":null, \"tmed\":null, \"hotx\":null, "
|
||||||
|
"\"hoty\":null }") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("numbers in the JSON keep their '.' under a comma locale") {
|
||||||
|
// The thermal camera's SDK switches the C locale to one with a decimal comma.
|
||||||
|
const std::string before = std::setlocale(LC_NUMERIC, nullptr);
|
||||||
|
const bool comma = std::setlocale(LC_NUMERIC, "de_DE.UTF-8") != nullptr;
|
||||||
|
CamEvent c;
|
||||||
|
c.tower = "T"; c.camera = "TIR";
|
||||||
|
ThermalEventFields t;
|
||||||
|
t.stats_ok = true; t.hotx = 0.43359; t.hoty = 1.0;
|
||||||
|
c.thermal = t;
|
||||||
|
const std::string j = camEventJson(c);
|
||||||
|
std::setlocale(LC_NUMERIC, before.c_str());
|
||||||
|
CHECK(j.find("\"hotx\":0.4336, \"hoty\":1.0000") != std::string::npos);
|
||||||
|
if (!comma) MESSAGE("no de_DE locale here: checked under the C locale only");
|
||||||
|
HealthReport h;
|
||||||
|
h.interval_s = -2.25;
|
||||||
|
CHECK(healthJson(h).find("\"interval_s\":-2.3") != std::string::npos); // rounds half away
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("CamEvent names are escaped") {
|
||||||
|
CamEvent c;
|
||||||
|
c.tower = "a\"b"; c.camera = "RGB";
|
||||||
|
CHECK(camEventJson(c).find("\"fwt\":\"a\\\"b\"") != std::string::npos);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
#include "fgc/CompositeCameraSource.h"
|
||||||
|
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace fgc;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// A source of `count` cameras whose trigger() delivers one frame per camera,
|
||||||
|
// stamped `ts`, when `ready` - and reports nothing otherwise.
|
||||||
|
struct FakeSource : ICameraSource {
|
||||||
|
int count;
|
||||||
|
long long ts;
|
||||||
|
bool counts_after_open = false; // as Vimba: no cameras until open()
|
||||||
|
bool opened = false;
|
||||||
|
bool ready = true;
|
||||||
|
bool throws_on_open = false;
|
||||||
|
int triggers = 0;
|
||||||
|
bool started = false;
|
||||||
|
std::string why_offline;
|
||||||
|
FrameCallback cb;
|
||||||
|
|
||||||
|
FakeSource(int n, long long t) : count(n), ts(t) {}
|
||||||
|
|
||||||
|
void open() override {
|
||||||
|
if (throws_on_open) throw std::runtime_error("no camera");
|
||||||
|
opened = true;
|
||||||
|
}
|
||||||
|
void close() override {}
|
||||||
|
void start() override { started = true; }
|
||||||
|
void stop() override { started = false; }
|
||||||
|
bool trigger() override {
|
||||||
|
++triggers;
|
||||||
|
if (!ready) return false;
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
Frame f;
|
||||||
|
f.cam_id = i;
|
||||||
|
f.timestamp_ms = ts + i;
|
||||||
|
cb(f);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
void setFrameCallback(FrameCallback c) override { cb = std::move(c); }
|
||||||
|
int cameraCount() const override { return counts_after_open && !opened ? 0 : count; }
|
||||||
|
bool readFeature(int i, const std::string& name, double& v) override {
|
||||||
|
v = i * 100 + ts;
|
||||||
|
return name == "X";
|
||||||
|
}
|
||||||
|
std::string offline(int) override { return why_offline; }
|
||||||
|
std::vector<CameraDeviceInfo> deviceInfo() override {
|
||||||
|
std::vector<CameraDeviceInfo> out(count);
|
||||||
|
for (int i = 0; i < count; ++i) out[i].id = std::to_string(ts) + ":" + std::to_string(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
bool grabFrame(int i, Frame& out, int) override {
|
||||||
|
out = Frame{};
|
||||||
|
out.cam_id = i;
|
||||||
|
out.timestamp_ms = ts + 50;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Rig {
|
||||||
|
FakeSource* colour = nullptr;
|
||||||
|
FakeSource* thermal = nullptr;
|
||||||
|
std::unique_ptr<CompositeCameraSource> src;
|
||||||
|
std::vector<Frame> frames;
|
||||||
|
|
||||||
|
explicit Rig(int colour_count = 1) {
|
||||||
|
auto c = std::make_unique<FakeSource>(colour_count, 1000);
|
||||||
|
auto t = std::make_unique<FakeSource>(1, 5000);
|
||||||
|
colour = c.get();
|
||||||
|
thermal = t.get();
|
||||||
|
std::vector<std::unique_ptr<ICameraSource>> v;
|
||||||
|
v.push_back(std::move(c));
|
||||||
|
v.push_back(std::move(t));
|
||||||
|
src = std::make_unique<CompositeCameraSource>(std::move(v));
|
||||||
|
src->setFrameCallback([this](const Frame& f) { frames.push_back(f); });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("the composite numbers the cameras leader first, and counts them all") {
|
||||||
|
Rig r(2);
|
||||||
|
CHECK(r.src->cameraCount() == 3);
|
||||||
|
REQUIRE(r.src->trigger());
|
||||||
|
REQUIRE(r.frames.size() == 3);
|
||||||
|
CHECK(r.frames[0].cam_id == 0);
|
||||||
|
CHECK(r.frames[1].cam_id == 1);
|
||||||
|
CHECK(r.frames[2].cam_id == 2); // the thermal camera
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("one trigger gives every frame the leader's time") {
|
||||||
|
Rig r;
|
||||||
|
REQUIRE(r.src->trigger());
|
||||||
|
REQUIRE(r.frames.size() == 2);
|
||||||
|
CHECK(r.frames[0].timestamp_ms == 1000);
|
||||||
|
CHECK(r.frames[1].timestamp_ms == 1000); // not its own 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the followers are asked only once the leader has delivered") {
|
||||||
|
Rig r;
|
||||||
|
r.colour->ready = false;
|
||||||
|
CHECK_FALSE(r.src->trigger());
|
||||||
|
CHECK_FALSE(r.src->trigger());
|
||||||
|
CHECK(r.thermal->triggers == 0); // no thermal picture without its colour one
|
||||||
|
CHECK(r.frames.empty());
|
||||||
|
r.colour->ready = true;
|
||||||
|
CHECK(r.src->trigger());
|
||||||
|
CHECK(r.thermal->triggers == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a follower with nothing to give costs only its own picture") {
|
||||||
|
Rig r;
|
||||||
|
r.thermal->ready = false;
|
||||||
|
CHECK(r.src->trigger());
|
||||||
|
REQUIRE(r.frames.size() == 1);
|
||||||
|
CHECK(r.frames[0].cam_id == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("calls about one camera reach the source that has it") {
|
||||||
|
Rig r(2);
|
||||||
|
double v = 0;
|
||||||
|
REQUIRE(r.src->readFeature(1, "X", v));
|
||||||
|
CHECK(v == 1100); // colour source, its camera 1
|
||||||
|
REQUIRE(r.src->readFeature(2, "X", v));
|
||||||
|
CHECK(v == 5000); // thermal source, its camera 0
|
||||||
|
CHECK_FALSE(r.src->readFeature(3, "X", v));
|
||||||
|
|
||||||
|
r.thermal->why_offline = "unplugged";
|
||||||
|
CHECK(r.src->offline(0).empty());
|
||||||
|
CHECK(r.src->offline(2) == "unplugged");
|
||||||
|
|
||||||
|
const auto d = r.src->deviceInfo();
|
||||||
|
REQUIRE(d.size() == 3);
|
||||||
|
CHECK(d[2].id == "5000:0");
|
||||||
|
|
||||||
|
// A grab outside a trigger keeps its own time, under the global index.
|
||||||
|
Frame f;
|
||||||
|
REQUIRE(r.src->grabFrame(2, f, 10));
|
||||||
|
CHECK(f.cam_id == 2);
|
||||||
|
CHECK(f.timestamp_ms == 5050);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the indices are the configured ones, also before the leader has opened") {
|
||||||
|
// On tower 111 the thermal frame came out as camera 0 - "RGB", overwriting
|
||||||
|
// the colour file of the same time - because Vimba counts no camera before
|
||||||
|
// open() and the offsets were taken from that.
|
||||||
|
auto c = std::make_unique<FakeSource>(1, 1000);
|
||||||
|
auto t = std::make_unique<FakeSource>(1, 5000);
|
||||||
|
c->counts_after_open = true;
|
||||||
|
FakeSource* colour = c.get();
|
||||||
|
std::vector<std::unique_ptr<ICameraSource>> v;
|
||||||
|
v.push_back(std::move(c));
|
||||||
|
v.push_back(std::move(t));
|
||||||
|
CompositeCameraSource src(std::move(v), {1, 1});
|
||||||
|
std::vector<Frame> frames;
|
||||||
|
src.setFrameCallback([&](const Frame& f) { frames.push_back(f); });
|
||||||
|
CHECK(src.cameraCount() == 1); // what is open: the thermal camera only
|
||||||
|
src.open();
|
||||||
|
CHECK(colour->opened);
|
||||||
|
CHECK(src.cameraCount() == 2);
|
||||||
|
REQUIRE(src.trigger());
|
||||||
|
REQUIRE(frames.size() == 2);
|
||||||
|
CHECK(frames[0].cam_id == 0);
|
||||||
|
CHECK(frames[1].cam_id == 1);
|
||||||
|
CHECK(src.offline(1).empty());
|
||||||
|
CHECK_THROWS(CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>>{}, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("start and stop reach every source") {
|
||||||
|
Rig r;
|
||||||
|
r.src->start();
|
||||||
|
CHECK(r.colour->started);
|
||||||
|
CHECK(r.thermal->started);
|
||||||
|
r.src->stop();
|
||||||
|
CHECK_FALSE(r.thermal->started);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a follower that will not open does not stop the run; the leader does") {
|
||||||
|
{
|
||||||
|
Rig r;
|
||||||
|
r.thermal->throws_on_open = true;
|
||||||
|
CHECK_NOTHROW(r.src->open());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Rig r;
|
||||||
|
r.colour->throws_on_open = true;
|
||||||
|
CHECK_THROWS(r.src->open());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -275,7 +275,7 @@ TEST_CASE("Camera blocks reject what cannot work") {
|
||||||
};
|
};
|
||||||
CHECK_NOTHROW(ConfigLoader::fromMap(base({})));
|
CHECK_NOTHROW(ConfigLoader::fromMap(base({})));
|
||||||
|
|
||||||
// No second backend exists yet; accepting the key silently would be worse.
|
// An unknown backend; accepting the key silently would be worse.
|
||||||
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.driver", "thermal"}})));
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.driver", "thermal"}})));
|
||||||
// An enabled camera with no id cannot be opened.
|
// An enabled camera with no id cannot be opened.
|
||||||
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.profile_dir", "/tmp/p"}}));
|
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.profile_dir", "/tmp/p"}}));
|
||||||
|
|
@ -288,6 +288,93 @@ TEST_CASE("Camera blocks reject what cannot work") {
|
||||||
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.standard.settle_wait", "500"}})));
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.standard.settle_wait", "500"}})));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A thermal camera needs no settings XML and is always lossless") {
|
||||||
|
AppConfig c = ConfigLoader::fromMap({
|
||||||
|
{"Camera.jxl_distance", "0.8"},
|
||||||
|
{"Camera.rgb.id", "DEV_1"},
|
||||||
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
||||||
|
{"Camera.tir.driver", "taris"},
|
||||||
|
{"Camera.tir.id", "2017744"},
|
||||||
|
{"Camera.tir.label", "TIR"},
|
||||||
|
{"Camera.tir.calib_profile", "1"},
|
||||||
|
{"Camera.tir.frame_rate_hz", "9"},
|
||||||
|
{"Camera.tir.interface", "enp1s0"},
|
||||||
|
{"Camera.tir.standard.settle_wait_ms", "200"},
|
||||||
|
});
|
||||||
|
REQUIRE(c.camera.cameras.size() == 2);
|
||||||
|
const CameraSpec& tir = c.camera.cameras[1];
|
||||||
|
CHECK(tir.thermal());
|
||||||
|
CHECK_FALSE(c.camera.cameras[0].thermal());
|
||||||
|
CHECK(tir.label == "TIR");
|
||||||
|
CHECK(tir.profile_dir.empty());
|
||||||
|
CHECK(tir.calib_profile == 1);
|
||||||
|
CHECK(tir.frame_rate_hz == doctest::Approx(9.0));
|
||||||
|
CHECK(tir.max_frame_age_ms == 400);
|
||||||
|
CHECK(tir.library == "libirbgrab_x86_64-linux.so");
|
||||||
|
CHECK(tir.interface == "enp1s0");
|
||||||
|
// [Camera] jxl_distance is for colour cameras; counts default to lossless.
|
||||||
|
CHECK(tir.jxl_distance == 0.0);
|
||||||
|
CHECK(c.camera.cameras[0].jxl_distance == doctest::Approx(0.8));
|
||||||
|
CHECK(c.camera.maxSettleWaitMs() == 1000); // the colour camera's default is longer
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A thermal camera without a timing block does not slow the scan") {
|
||||||
|
AppConfig c = ConfigLoader::fromMap({
|
||||||
|
{"Camera.rgb.id", "DEV_1"},
|
||||||
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
||||||
|
{"Camera.rgb.standard.settle_wait_ms", "500"},
|
||||||
|
{"Camera.rgb.standard.frame_timeout_ms", "700"},
|
||||||
|
{"Camera.tir.driver", "taris"},
|
||||||
|
{"Camera.tir.id", "2017744"},
|
||||||
|
});
|
||||||
|
CHECK(c.camera.maxSettleWaitMs() == 500);
|
||||||
|
CHECK(c.camera.maxFrameTimeoutMs() == 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Colour cameras come first whatever the block names") {
|
||||||
|
// Camera 0 is the one the keep rule reads; a thermal camera cannot be it.
|
||||||
|
AppConfig c = ConfigLoader::fromMap({
|
||||||
|
{"Camera.a_tir.driver", "taris"},
|
||||||
|
{"Camera.a_tir.id", "2017744"},
|
||||||
|
{"Camera.rgb.id", "DEV_1"},
|
||||||
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
||||||
|
{"Camera.nir.id", "DEV_2"},
|
||||||
|
{"Camera.nir.profile_dir", "/tmp/p"},
|
||||||
|
});
|
||||||
|
REQUIRE(c.camera.cameras.size() == 3);
|
||||||
|
CHECK(c.camera.cameras[0].name == "nir");
|
||||||
|
CHECK(c.camera.cameras[1].name == "rgb");
|
||||||
|
CHECK(c.camera.cameras[2].name == "a_tir");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Thermal camera blocks reject what cannot work") {
|
||||||
|
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"},
|
||||||
|
{"Camera.tir.driver", "taris"},
|
||||||
|
{"Camera.tir.id", "2017744"}};
|
||||||
|
for (auto& [k, v] : extra) kv[k] = v;
|
||||||
|
return kv;
|
||||||
|
};
|
||||||
|
CHECK_NOTHROW(ConfigLoader::fromMap(base({})));
|
||||||
|
// Counts are measurements: lossy coding would change them.
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.jxl_distance", "0.5"}})));
|
||||||
|
CHECK_NOTHROW(ConfigLoader::fromMap(base({{"Camera.tir.jxl_distance", "0"}})));
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.id", ""}})));
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.calib_profile", "-1"}})));
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.frame_rate_hz", "0"}})));
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.frame_rate_hz", "60"}})));
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.max_frame_age_ms", "0"}})));
|
||||||
|
// An empty value means the default, as for every key.
|
||||||
|
CHECK(ConfigLoader::fromMap(base({{"Camera.tir.library", ""}})).camera.cameras[1].library ==
|
||||||
|
"libirbgrab_x86_64-linux.so");
|
||||||
|
// Two cameras, one output folder: their pictures would overwrite each other.
|
||||||
|
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.label", "X"}, {"Camera.tir.label", "X"}})));
|
||||||
|
// A disabled thermal camera needs no serial, like any other.
|
||||||
|
CHECK_NOTHROW(ConfigLoader::fromMap(
|
||||||
|
base({{"Camera.tir.id", ""}, {"Camera.tir.enabled", "false"}})));
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") {
|
TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") {
|
||||||
const std::string in =
|
const std::string in =
|
||||||
"[General]\n"
|
"[General]\n"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include <doctest/doctest.h>
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cstring>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
@ -65,6 +66,24 @@ Frame bayerFrame(PixelLayout layout, uint32_t w, uint32_t h, int r, int g, int b
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A thermal frame of counts: `room` everywhere, `hot` at (hx, hy).
|
||||||
|
Frame thermalFrame(uint32_t w, uint32_t h, uint16_t room, uint16_t hot, uint32_t hx, uint32_t hy,
|
||||||
|
long long ts, bool with_info = true) {
|
||||||
|
Frame f;
|
||||||
|
f.width = w; f.height = h; f.layout = PixelLayout::Mono16; f.channels = 1; f.timestamp_ms = ts;
|
||||||
|
std::vector<uint16_t> px(static_cast<size_t>(w) * h, room);
|
||||||
|
px[static_cast<size_t>(hy) * w + hx] = hot;
|
||||||
|
f.data.resize(px.size() * 2);
|
||||||
|
std::memcpy(f.data.data(), px.data(), f.data.size());
|
||||||
|
if (with_info) {
|
||||||
|
ThermalFrameInfo info;
|
||||||
|
info.cal = 0;
|
||||||
|
info.missed_packets = 3;
|
||||||
|
f.thermal = info;
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
bool waitSaved(const ImagePipeline& p, long long n, int ms = 20000) {
|
bool waitSaved(const ImagePipeline& p, long long n, int ms = 20000) {
|
||||||
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
|
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
|
||||||
while (p.imagesSaved() < n && std::chrono::steady_clock::now() < end)
|
while (p.imagesSaved() < n && std::chrono::steady_clock::now() < end)
|
||||||
|
|
@ -167,6 +186,86 @@ TEST_CASE("rotation applies to raw frames too") {
|
||||||
CHECK(interiorMean(d, 48, 64, 0) == doctest::Approx(3200).epsilon(0.01));
|
CHECK(interiorMean(d, 48, 64, 0) == doctest::Approx(3200).epsilon(0.01));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("thermal counts are saved 16-bit lossless with their temperatures in the event") {
|
||||||
|
TempDir t;
|
||||||
|
FakeChannel ch;
|
||||||
|
auto p = params(t, 0.8); // a lossy setting must not reach the counts
|
||||||
|
p.cameras = {{"TIR", 0, 0.8, 1, true}};
|
||||||
|
ImagePipeline pipe(ch, [] { return Orientation{123.4f, -3.0f}; }, p);
|
||||||
|
pipe.start();
|
||||||
|
const Frame f = thermalFrame(64, 48, 29315, 57315, 40, 12, 4242);
|
||||||
|
pipe.submit(f);
|
||||||
|
REQUIRE(waitSaved(pipe, 1));
|
||||||
|
pipe.stop();
|
||||||
|
|
||||||
|
const std::string file = onlyFile(t);
|
||||||
|
CHECK(fs::path(file).parent_path().filename() == "TIR");
|
||||||
|
CHECK(fs::path(file).filename() == "4242.jxl");
|
||||||
|
const auto d = decode(file, 1);
|
||||||
|
CHECK(d.bits == 16);
|
||||||
|
CHECK(d.channels == 1);
|
||||||
|
REQUIRE(d.px.size() == 64u * 48);
|
||||||
|
CHECK(std::memcmp(d.px.data(), f.data.data(), f.data.size()) == 0); // bit-exact
|
||||||
|
|
||||||
|
REQUIRE(ch.count() == 1);
|
||||||
|
const CamEvent& e = ch.events[0];
|
||||||
|
CHECK(e.camera == "TIR");
|
||||||
|
CHECK(e.heading_decideg == 1234);
|
||||||
|
REQUIRE(e.thermal);
|
||||||
|
CHECK(e.thermal->cal == 0);
|
||||||
|
CHECK(e.thermal->missed == 3);
|
||||||
|
CHECK(e.thermal->stats_ok);
|
||||||
|
CHECK(e.thermal->tmax_decic == 3000);
|
||||||
|
CHECK(e.thermal->tmed_decic == 200);
|
||||||
|
CHECK(e.thermal->hotx == doctest::Approx(40.5 / 64));
|
||||||
|
CHECK(e.thermal->hoty == doctest::Approx(12.5 / 48));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the thermal hot spot is where it is in the stored, rotated image") {
|
||||||
|
TempDir t;
|
||||||
|
FakeChannel ch;
|
||||||
|
auto p = params(t);
|
||||||
|
p.cameras = {{"TIR", 90, 0.0, 1, true}};
|
||||||
|
ImagePipeline pipe(ch, nullptr, p);
|
||||||
|
pipe.start();
|
||||||
|
// 64 x 48, hot at (40, 12). Rotated 90 deg clockwise it is 48 x 64, and a
|
||||||
|
// pixel at (x, y) moves to (H-1-y, x) = (35, 40).
|
||||||
|
pipe.submit(thermalFrame(64, 48, 29315, 40000, 40, 12, 5));
|
||||||
|
REQUIRE(waitSaved(pipe, 1));
|
||||||
|
pipe.stop();
|
||||||
|
const auto d = decode(onlyFile(t), 1);
|
||||||
|
REQUIRE(d.px.size() == 48u * 64);
|
||||||
|
CHECK(d.px[40 * 48 + 35] == 40000);
|
||||||
|
REQUIRE(ch.events[0].thermal);
|
||||||
|
CHECK(ch.events[0].thermal->hotx == doctest::Approx(35.5 / 48));
|
||||||
|
CHECK(ch.events[0].thermal->hoty == doctest::Approx(40.5 / 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a Mono16 frame without thermal info is saved, but its event has no thermal fields") {
|
||||||
|
TempDir t;
|
||||||
|
FakeChannel ch;
|
||||||
|
ImagePipeline pipe(ch, nullptr, params(t));
|
||||||
|
pipe.start();
|
||||||
|
pipe.submit(thermalFrame(32, 16, 1000, 2000, 1, 1, 6, false));
|
||||||
|
REQUIRE(waitSaved(pipe, 1));
|
||||||
|
pipe.stop();
|
||||||
|
CHECK(decode(onlyFile(t), 1).bits == 16);
|
||||||
|
REQUIRE(ch.count() == 1);
|
||||||
|
CHECK_FALSE(ch.events[0].thermal);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("an all-zero thermal frame is saved, its temperatures unknown") {
|
||||||
|
TempDir t;
|
||||||
|
FakeChannel ch;
|
||||||
|
ImagePipeline pipe(ch, nullptr, params(t));
|
||||||
|
pipe.start();
|
||||||
|
pipe.submit(thermalFrame(32, 16, 0, 0, 0, 0, 7));
|
||||||
|
REQUIRE(waitSaved(pipe, 1));
|
||||||
|
pipe.stop();
|
||||||
|
REQUIRE(ch.events[0].thermal);
|
||||||
|
CHECK_FALSE(ch.events[0].thermal->stats_ok);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("several workers save every frame") {
|
TEST_CASE("several workers save every frame") {
|
||||||
TempDir t;
|
TempDir t;
|
||||||
FakeChannel ch;
|
FakeChannel ch;
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,21 @@ TEST_CASE("12-bit lossless decodes exactly, extremes included") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("16-bit single-channel lossless decodes exactly (thermal counts)") {
|
||||||
|
TempDir t;
|
||||||
|
// Counts around room temperature, plus the full range's ends.
|
||||||
|
auto src = image(96, 64, 1, 65535, 11);
|
||||||
|
for (size_t i = 0; i < src.size(); i += 3) src[i] = static_cast<uint16_t>(28000 + i % 2500);
|
||||||
|
src.front() = 0;
|
||||||
|
src.back() = 65535;
|
||||||
|
REQUIRE(JpegXlEncoder::encodeToFile(t.file("ll16.jxl"), src.data(), 96, 64, 1, 16, 0.0f, 1, 2));
|
||||||
|
const auto d = decode(t.file("ll16.jxl"), 1);
|
||||||
|
CHECK(d.bits == 16);
|
||||||
|
CHECK(d.channels == 1);
|
||||||
|
CHECK(d.original_profile);
|
||||||
|
CHECK(d.px == src);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("lossy keeps the bit depth and stays close") {
|
TEST_CASE("lossy keeps the bit depth and stays close") {
|
||||||
TempDir t;
|
TempDir t;
|
||||||
SUBCASE("12-bit colour") {
|
SUBCASE("12-bit colour") {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ std::vector<uint8_t> pack12p(const std::vector<uint16_t>& v) {
|
||||||
|
|
||||||
TEST_CASE("layout names round-trip, and unknown formats are refused") {
|
TEST_CASE("layout names round-trip, and unknown formats are refused") {
|
||||||
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
|
for (PixelLayout l : {PixelLayout::Mono8, PixelLayout::RGB8, PixelLayout::BayerRG8,
|
||||||
PixelLayout::BayerRG12p})
|
PixelLayout::BayerRG12p, PixelLayout::Mono16})
|
||||||
CHECK(layoutFromName(layoutName(l)) == l);
|
CHECK(layoutFromName(layoutName(l)) == l);
|
||||||
CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled
|
CHECK(layoutFromName("BayerRG12") == PixelLayout::Unknown); // unpacked: not handled
|
||||||
CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown);
|
CHECK(layoutFromName("YCbCr422_8_CbYCrY") == PixelLayout::Unknown);
|
||||||
|
|
@ -45,6 +45,19 @@ TEST_CASE("layout properties") {
|
||||||
CHECK(layoutFrameBytes(PixelLayout::BayerRG8, 5328, 3040) == 16197120);
|
CHECK(layoutFrameBytes(PixelLayout::BayerRG8, 5328, 3040) == 16197120);
|
||||||
CHECK(layoutFrameBytes(PixelLayout::RGB8, 5328, 3040) == 48591360);
|
CHECK(layoutFrameBytes(PixelLayout::RGB8, 5328, 3040) == 48591360);
|
||||||
CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 3, 1) == 5); // odd pixel count rounds up
|
CHECK(layoutFrameBytes(PixelLayout::BayerRG12p, 3, 1) == 5); // odd pixel count rounds up
|
||||||
|
// The thermal camera: 640 x 480 counts, two bytes each.
|
||||||
|
CHECK_FALSE(layoutIsBayer(PixelLayout::Mono16));
|
||||||
|
CHECK(layoutBits(PixelLayout::Mono16) == 16);
|
||||||
|
CHECK(layoutFrameBytes(PixelLayout::Mono16, 640, 480) == 614400);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("brightness sample: 16-bit is reported on the 0..255 scale") {
|
||||||
|
std::vector<uint16_t> v(640 * 480, 0x8000);
|
||||||
|
const auto* b = reinterpret_cast<const uint8_t*>(v.data());
|
||||||
|
const auto s = sampleBrightness(b, v.size() * 2, PixelLayout::Mono16, 640, 480);
|
||||||
|
REQUIRE(s.valid());
|
||||||
|
CHECK(s.mean == doctest::Approx(128.0));
|
||||||
|
CHECK(s.clipped == doctest::Approx(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("unpack12p matches GenICam 12p, by hand") {
|
TEST_CASE("unpack12p matches GenICam 12p, by hand") {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
#include "fgc/TarisCameraSource.h"
|
||||||
|
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace fgc;
|
||||||
|
using namespace fgc::irbgrab;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// A stand-in for the IRBGRAB SDK and one TarisIR mini behind it. The source
|
||||||
|
// calls it from its worker thread; the test changes it from its own.
|
||||||
|
struct FakeSdk {
|
||||||
|
std::mutex m;
|
||||||
|
std::vector<std::string> search{"SN:2017744|00:0e:c6:aa:bb:cc|192.168.0.200#0"};
|
||||||
|
std::string accept = "#0"; // connect succeeds for strings ending like this
|
||||||
|
std::vector<std::string> connect_attempts;
|
||||||
|
int cal = 0;
|
||||||
|
int calib_sets = 0;
|
||||||
|
float rate = 0.f;
|
||||||
|
bool grabbing = false;
|
||||||
|
int inits = 0;
|
||||||
|
int connects = 0;
|
||||||
|
// The newest frame: counts, and the camera's number for it.
|
||||||
|
std::vector<uint16_t> px;
|
||||||
|
int64_t image_num = 0; // ours; the camera reports 0 for every frame
|
||||||
|
int lut_calls = 0; // the first LUTs after connecting are empty
|
||||||
|
uint32_t w = 8, h = 4;
|
||||||
|
Ret no_frame = kErrNotFound; // what getdata says before any frame
|
||||||
|
std::atomic<bool> frozen{false}; // stop producing frames (stall)
|
||||||
|
|
||||||
|
void push(uint16_t v, uint16_t hot = 0) {
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
px.assign(static_cast<size_t>(w) * h, v);
|
||||||
|
if (hot) px[5] = hot;
|
||||||
|
++image_num;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
FakeSdk* g = nullptr;
|
||||||
|
|
||||||
|
void copyText(const std::string& s, char* buf, int len) {
|
||||||
|
std::memset(buf, 0, static_cast<size_t>(len));
|
||||||
|
std::memcpy(buf, s.data(), std::min<size_t>(s.size(), static_cast<size_t>(len) - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ret fInit() { std::lock_guard<std::mutex> l(g->m); ++g->inits; return kSuccess; }
|
||||||
|
void fUninit() {}
|
||||||
|
Ret fVersion(char* b, int n) { copyText("4.3.44", b, n); return kSuccess; }
|
||||||
|
Ret fTypes(char* b, int n) { copyText("Simulator;TARISirmini", b, n); return kSuccess; }
|
||||||
|
Ret fCreate(Handle* d, int32_t type, char*) { *d = type == 1 ? 42 : 0; return type == 1 ? kSuccess : kErrNotFound; }
|
||||||
|
Ret fFree(Handle* d) { *d = 0; return kSuccess; }
|
||||||
|
Ret fSearch(Handle, int32_t* n) {
|
||||||
|
std::lock_guard<std::mutex> l(g->m);
|
||||||
|
*n = static_cast<int32_t>(g->search.size());
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
Ret fConnect(Handle, char* s) {
|
||||||
|
std::lock_guard<std::mutex> l(g->m);
|
||||||
|
const std::string str = s;
|
||||||
|
g->connect_attempts.push_back(str);
|
||||||
|
const bool ok = str.size() >= g->accept.size() &&
|
||||||
|
str.compare(str.size() - g->accept.size(), g->accept.size(), g->accept) == 0;
|
||||||
|
if (ok) { ++g->connects; g->lut_calls = 0; }
|
||||||
|
return ok ? kSuccess : kTypeErr | 0x10;
|
||||||
|
}
|
||||||
|
Ret fDisconnect(Handle) { return kSuccess; }
|
||||||
|
Ret fStart(Handle, int32_t) { std::lock_guard<std::mutex> l(g->m); g->grabbing = true; return kSuccess; }
|
||||||
|
Ret fStop(Handle, int32_t) { std::lock_guard<std::mutex> l(g->m); g->grabbing = false; return kSuccess; }
|
||||||
|
Ret fGetParam(Handle, uint32_t what, void* data, uint32_t type) {
|
||||||
|
std::lock_guard<std::mutex> l(g->m);
|
||||||
|
if (what == param::kSearchString && type == kTypeIdxString) {
|
||||||
|
auto* p = static_cast<IdxString*>(data);
|
||||||
|
if (p->index < 0 || p->index >= static_cast<int>(g->search.size())) return kErrNotFound;
|
||||||
|
copyText(g->search[p->index], p->value.text, p->value.len);
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
if (what == param::kCalibActive && type == kTypeIdxInt32) {
|
||||||
|
static_cast<IdxInt32*>(data)->value = g->cal;
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
if (what == param::kCalibCount && type == kTypeInt32) { *static_cast<int32_t*>(data) = 2; return kSuccess; }
|
||||||
|
if (what == param::kCalibChangeInProgress && type == kTypeInt32) { *static_cast<int32_t*>(data) = 0; return kSuccess; }
|
||||||
|
if (what == param::kCalibName && type == kTypeIdxString) {
|
||||||
|
auto* p = static_cast<IdxString*>(data);
|
||||||
|
copyText(p->index == 0 ? "FF (-40-120C) #4101" : "FF (0-600C) #4102", p->value.text, p->value.len);
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
if (what == param::kCameraName && type == kTypeString) {
|
||||||
|
auto* p = static_cast<String*>(data);
|
||||||
|
copyText("TARISirmini", p->text, p->len);
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
if (what == param::kCameraTempHousing && type == kTypeSingle) { *static_cast<float*>(data) = 308.15f; return kSuccess; }
|
||||||
|
return kErrNotSupported;
|
||||||
|
}
|
||||||
|
Ret fSetParam(Handle, uint32_t what, void* data, uint32_t type) {
|
||||||
|
std::lock_guard<std::mutex> l(g->m);
|
||||||
|
if (what == param::kCalibActive && type == kTypeIdxInt32) {
|
||||||
|
g->cal = static_cast<IdxInt32*>(data)->value;
|
||||||
|
++g->calib_sets;
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
if (what == param::kFramerateHz && type == kTypeSingle) { g->rate = *static_cast<float*>(data); return kSuccess; }
|
||||||
|
if (what == param::kPollingEnable) return kSuccess;
|
||||||
|
return kErrNotSupported;
|
||||||
|
}
|
||||||
|
// A memory object is a copy of the newest frame.
|
||||||
|
struct Mem {
|
||||||
|
std::vector<uint16_t> px;
|
||||||
|
uint32_t w, h;
|
||||||
|
int64_t num;
|
||||||
|
int cal;
|
||||||
|
};
|
||||||
|
Ret fGetData(Handle, int32_t what, Handle* mem) {
|
||||||
|
std::lock_guard<std::mutex> l(g->m);
|
||||||
|
if (what != kMemIrDigitFrame || !g->grabbing || g->px.empty() || g->frozen) return g->no_frame;
|
||||||
|
*mem = reinterpret_cast<Handle>(new Mem{g->px, g->w, g->h, g->image_num, g->cal});
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
Ret fMemFree(Handle* mem) { delete reinterpret_cast<Mem*>(*mem); *mem = 0; return kSuccess; }
|
||||||
|
Ret fDim(Handle mem, uint32_t* w, uint32_t* h, uint32_t* t) {
|
||||||
|
auto* m = reinterpret_cast<Mem*>(mem);
|
||||||
|
*w = m->w; *h = m->h; *t = 0;
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
Ret fPtr(Handle mem, Handle* p, int32_t* bytes) {
|
||||||
|
auto* m = reinterpret_cast<Mem*>(mem);
|
||||||
|
*p = reinterpret_cast<Handle>(m->px.data());
|
||||||
|
*bytes = static_cast<int32_t>(m->px.size() * 2);
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
Ret fInfo(Handle mem, void* info) {
|
||||||
|
// As the TarisIR mini: no frame counter, but each frame has its own time.
|
||||||
|
auto* mi = static_cast<MemInfo*>(info);
|
||||||
|
mi->image_num = 0;
|
||||||
|
mi->timestamp_ms = 200.0 * static_cast<double>(reinterpret_cast<Mem*>(mem)->num);
|
||||||
|
mi->missed_packets = 1;
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
Ret fLut(Handle mem, void* lut, float, float, float, float) {
|
||||||
|
const double kpc = reinterpret_cast<Mem*>(mem)->cal == 1 ? 0.02 : 0.01;
|
||||||
|
auto* l = static_cast<float*>(lut);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g->m);
|
||||||
|
if (++g->lut_calls <= 2) { // still empty, and "ok", as on the tower
|
||||||
|
for (int i = 0; i < 65536; ++i) l[i] = 0.f;
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 65536; ++i) l[i] = static_cast<float>(i * kpc);
|
||||||
|
return kSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<const Api> fakeApi() {
|
||||||
|
auto a = std::make_shared<Api>();
|
||||||
|
a->dll_init = fInit; a->dll_uninit = fUninit; a->dll_version = fVersion;
|
||||||
|
a->dll_devicetypenames = fTypes; a->dev_create = fCreate; a->dev_free = fFree;
|
||||||
|
a->dev_search = fSearch; a->dev_connect = fConnect; a->dev_disconnect = fDisconnect;
|
||||||
|
a->dev_startgrab = fStart; a->dev_stopgrab = fStop; a->dev_getparam = fGetParam;
|
||||||
|
a->dev_setparam = fSetParam; a->dev_getdata = fGetData; a->mem_free = fMemFree;
|
||||||
|
a->mem_getdimension = fDim; a->mem_getdataptr = fPtr; a->mem_getinfo = fInfo;
|
||||||
|
a->mem_getdvlut = fLut;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
TarisCameraSource::Options fastOptions() {
|
||||||
|
TarisCameraSource::Options o;
|
||||||
|
o.serial = "2017744";
|
||||||
|
o.frame_rate_hz = 50; // polls every 10 ms
|
||||||
|
o.retry_min_ms = 20;
|
||||||
|
o.retry_max_ms = 40;
|
||||||
|
o.stall_ms = 2000;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each call of pred() counts: trigger() is not idempotent.
|
||||||
|
template <class F> bool waitFor(F pred, int ms = 3000) {
|
||||||
|
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
|
||||||
|
for (;;) {
|
||||||
|
if (pred()) return true;
|
||||||
|
if (std::chrono::steady_clock::now() >= end) return false;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
FakeSdk sdk;
|
||||||
|
TarisCameraSource cam;
|
||||||
|
std::mutex m;
|
||||||
|
std::vector<Frame> frames;
|
||||||
|
|
||||||
|
explicit Harness(TarisCameraSource::Options o = fastOptions(), bool net_raw = true)
|
||||||
|
: cam((g = &sdk, o), fakeApi(), [net_raw] { return net_raw; }) {
|
||||||
|
cam.setFrameCallback([this](const Frame& f) {
|
||||||
|
std::lock_guard<std::mutex> l(m);
|
||||||
|
frames.push_back(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
~Harness() { cam.close(); g = nullptr; }
|
||||||
|
bool online() { return waitFor([this] { return cam.offline(0).empty(); }); }
|
||||||
|
// Trigger until a frame comes or the time is up.
|
||||||
|
bool shot(int ms = 2000) { return waitFor([this] { return cam.trigger(); }, ms); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("the thermal camera connects, trying adapters until one answers") {
|
||||||
|
Harness t;
|
||||||
|
t.sdk.accept = "#3"; // the adapter index has moved since the search
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(29315, 57315);
|
||||||
|
REQUIRE(t.online());
|
||||||
|
std::lock_guard<std::mutex> l(t.sdk.m);
|
||||||
|
REQUIRE(t.sdk.connect_attempts.size() >= 4);
|
||||||
|
CHECK(t.sdk.connect_attempts.front() == "SN:2017744|00:0e:c6:aa:bb:cc|192.168.0.200#0");
|
||||||
|
CHECK(t.sdk.connect_attempts.back().substr(t.sdk.connect_attempts.back().size() - 2) == "#3");
|
||||||
|
CHECK(t.sdk.rate == doctest::Approx(50.f));
|
||||||
|
CHECK(t.sdk.grabbing);
|
||||||
|
CHECK(t.sdk.calib_sets == 0); // already on the wanted profile
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a fresh frame is delivered once, as counts with their scale") {
|
||||||
|
Harness t;
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(29315, 57315);
|
||||||
|
REQUIRE(t.shot());
|
||||||
|
CHECK_FALSE(t.cam.trigger()); // the same frame is not handed out twice
|
||||||
|
REQUIRE(t.frames.size() == 1);
|
||||||
|
const Frame& f = t.frames[0];
|
||||||
|
CHECK(f.layout == PixelLayout::Mono16);
|
||||||
|
CHECK(f.width == 8);
|
||||||
|
CHECK(f.height == 4);
|
||||||
|
REQUIRE(f.data.size() == 64);
|
||||||
|
uint16_t px5 = 0;
|
||||||
|
std::memcpy(&px5, f.data.data() + 10, 2);
|
||||||
|
CHECK(px5 == 57315);
|
||||||
|
REQUIRE(f.thermal);
|
||||||
|
CHECK(f.thermal->cal == 0);
|
||||||
|
CHECK(f.thermal->kelvin_per_count == doctest::Approx(0.01)); // documented, then measured
|
||||||
|
CHECK(f.thermal->missed_packets == 1);
|
||||||
|
|
||||||
|
t.sdk.push(29400);
|
||||||
|
CHECK(t.shot());
|
||||||
|
double v = 0;
|
||||||
|
CHECK(t.cam.readFeature(0, "CalibProfile", v));
|
||||||
|
CHECK(v == 0);
|
||||||
|
CHECK(waitFor([&] { return t.cam.readFeature(0, "HousingTemperature", v); }, 7000));
|
||||||
|
CHECK(v == doctest::Approx(35.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the zero frames after starting are never delivered") {
|
||||||
|
Harness t;
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(0);
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
CHECK_FALSE(t.cam.trigger());
|
||||||
|
t.sdk.push(29315);
|
||||||
|
CHECK(t.shot());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a frame older than max_frame_age_ms is not kept") {
|
||||||
|
auto o = fastOptions();
|
||||||
|
o.max_frame_age_ms = 50;
|
||||||
|
Harness t(o);
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(29315);
|
||||||
|
REQUIRE(t.online());
|
||||||
|
t.sdk.frozen = true; // no newer frame arrives
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(150));
|
||||||
|
CHECK_FALSE(t.cam.trigger());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("delivery stops while stopped, the camera keeps grabbing") {
|
||||||
|
Harness t;
|
||||||
|
t.cam.open();
|
||||||
|
t.sdk.push(29315);
|
||||||
|
REQUIRE(t.online());
|
||||||
|
CHECK_FALSE(t.cam.trigger()); // not started
|
||||||
|
t.cam.start();
|
||||||
|
CHECK(t.cam.trigger());
|
||||||
|
t.cam.stop();
|
||||||
|
t.sdk.push(29400);
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||||
|
CHECK_FALSE(t.cam.trigger());
|
||||||
|
std::lock_guard<std::mutex> l(t.sdk.m);
|
||||||
|
CHECK(t.sdk.grabbing);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the configured calibration profile is set on connect") {
|
||||||
|
auto o = fastOptions();
|
||||||
|
o.calib_profile = 1;
|
||||||
|
Harness t(o);
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(14658);
|
||||||
|
REQUIRE(t.shot());
|
||||||
|
CHECK(t.sdk.calib_sets == 1);
|
||||||
|
REQUIRE(t.frames[0].thermal);
|
||||||
|
CHECK(t.frames[0].thermal->cal == 1);
|
||||||
|
CHECK(t.frames[0].thermal->kelvin_per_count == doctest::Approx(0.02));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("switching the profile while running changes the scale of later frames") {
|
||||||
|
Harness t;
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(29315);
|
||||||
|
REQUIRE(t.shot());
|
||||||
|
REQUIRE(t.cam.setFeature(0, "CalibProfile", "1"));
|
||||||
|
CHECK_FALSE(t.cam.setFeature(0, "CalibProfile", "7")); // the camera has two
|
||||||
|
CHECK_FALSE(t.cam.setFeature(0, "CalibProfile", "x"));
|
||||||
|
t.sdk.push(14658);
|
||||||
|
REQUIRE(t.shot());
|
||||||
|
REQUIRE(t.frames.back().thermal);
|
||||||
|
CHECK(t.frames.back().thermal->cal == 1);
|
||||||
|
CHECK(t.frames.back().thermal->kelvin_per_count == doctest::Approx(0.02));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a camera not on the network is offline, says why, and is found later") {
|
||||||
|
Harness t;
|
||||||
|
t.sdk.search = {"SN:999|00:00:00:00:00:01|192.168.0.201#0"};
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(29315);
|
||||||
|
CHECK(waitFor([&] { return t.cam.offline(0).find("not found") != std::string::npos; }));
|
||||||
|
CHECK_FALSE(t.cam.trigger());
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> l(t.sdk.m);
|
||||||
|
t.sdk.search = {"SN:2017744|00:0e:c6:aa:bb:cc|192.168.0.200#0"};
|
||||||
|
}
|
||||||
|
CHECK(t.online());
|
||||||
|
CHECK(t.shot());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("without CAP_NET_RAW the camera stays offline and the SDK is not touched") {
|
||||||
|
Harness t(fastOptions(), false);
|
||||||
|
t.cam.open();
|
||||||
|
CHECK(waitFor([&] { return t.cam.offline(0).find("cap_net_raw") != std::string::npos; }));
|
||||||
|
CHECK(t.sdk.inits == 0);
|
||||||
|
CHECK_FALSE(t.cam.trigger());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a camera that goes quiet is reconnected") {
|
||||||
|
auto o = fastOptions();
|
||||||
|
o.stall_ms = 100;
|
||||||
|
Harness t(o);
|
||||||
|
t.cam.open();
|
||||||
|
t.cam.start();
|
||||||
|
t.sdk.push(29315);
|
||||||
|
REQUIRE(t.online());
|
||||||
|
t.sdk.frozen = true;
|
||||||
|
CHECK(waitFor([&] { return t.cam.deviceInfo()[0].restarts >= 1; }));
|
||||||
|
t.sdk.frozen = false;
|
||||||
|
t.sdk.push(29400);
|
||||||
|
CHECK(t.shot());
|
||||||
|
std::lock_guard<std::mutex> l(t.sdk.m);
|
||||||
|
CHECK(t.sdk.connects >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a missing SDK library leaves the camera offline, not the program down") {
|
||||||
|
auto o = fastOptions();
|
||||||
|
o.library = "libdoes_not_exist_irbgrab.so";
|
||||||
|
TarisCameraSource cam(o, nullptr, [] { return true; });
|
||||||
|
cam.open();
|
||||||
|
CHECK(waitFor([&] { return cam.offline(0).find("cannot load") != std::string::npos; }));
|
||||||
|
CHECK_FALSE(cam.trigger());
|
||||||
|
cam.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the documented scales") {
|
||||||
|
CHECK(TarisCameraSource::expectedKelvinPerCount(0) == 0.01);
|
||||||
|
CHECK(TarisCameraSource::expectedKelvinPerCount(1) == 0.02);
|
||||||
|
CHECK(TarisCameraSource::expectedKelvinPerCount(2) == 0.0);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "fgc/HardwareChecks.h"
|
#include "fgc/HardwareChecks.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include "fgc/CompositeCameraSource.h"
|
||||||
#include "fgc/mock/MockCameraSource.h"
|
#include "fgc/mock/MockCameraSource.h"
|
||||||
#include "fgc/mock/MockImuSource.h"
|
#include "fgc/mock/MockImuSource.h"
|
||||||
#include "fgc/mock/MockMotorController.h"
|
#include "fgc/mock/MockMotorController.h"
|
||||||
|
|
@ -203,6 +204,32 @@ TEST_CASE("a healthy camera passes frames, forced dark and forced bright, and is
|
||||||
CHECK(st.pct_high < 1.0);
|
CHECK(st.pct_high < 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("with a thermal camera beside it, both deliver frames and only colour is forced") {
|
||||||
|
std::vector<std::unique_ptr<ICameraSource>> v;
|
||||||
|
v.push_back(std::make_unique<MockCameraSource>());
|
||||||
|
v.push_back(std::make_unique<MockCameraSource>(1, 640, 480, PixelLayout::Mono16));
|
||||||
|
CompositeCameraSource cam(std::move(v));
|
||||||
|
MockMotorController motor;
|
||||||
|
motor.start();
|
||||||
|
HardwareTestContext hw;
|
||||||
|
hw.camera = &cam;
|
||||||
|
hw.configured_cameras = {"rgb", "tir"};
|
||||||
|
hw.frame_timeout_ms = {200, 200};
|
||||||
|
TestRunner tr(motor, nullptr, unitGeometry(), T_CAM_ALL, fastProfile(), ".", hw);
|
||||||
|
auto rep = runToCompletion(tr);
|
||||||
|
REQUIRE(rep);
|
||||||
|
// 16-bit counts are two bytes a pixel, and that is a whole frame.
|
||||||
|
CHECK(section(*rep, "cam_frames")->pass);
|
||||||
|
// No exposure to force on a thermal camera: noted, not failed.
|
||||||
|
const TestSection* dark = section(*rep, "cam_dark");
|
||||||
|
REQUIRE(dark);
|
||||||
|
CHECK(dark->pass);
|
||||||
|
CHECK(std::any_of(dark->notes.begin(), dark->notes.end(), [](const std::string& n) {
|
||||||
|
return n.find("tir: not applicable") != std::string::npos;
|
||||||
|
}));
|
||||||
|
CHECK(rep->all_pass);
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("a camera that delivers nothing fails the frames leaf") {
|
TEST_CASE("a camera that delivers nothing fails the frames leaf") {
|
||||||
MockCameraSource cam;
|
MockCameraSource cam;
|
||||||
cam.setFault(MockCameraSource::Fault::NoFrames);
|
cam.setFault(MockCameraSource::Fault::NoFrames);
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,9 @@ TEST_CASE("Health payload: unknown readings are null, numbers rounded") {
|
||||||
CHECK(j.find("\"throttle_events_last_min\":14") != std::string::npos);
|
CHECK(j.find("\"throttle_events_last_min\":14") != std::string::npos);
|
||||||
CHECK(j.find("\"interval_s\":10.0") != std::string::npos);
|
CHECK(j.find("\"interval_s\":10.0") != std::string::npos);
|
||||||
CHECK(j.find("\"time\":1700000000000") != std::string::npos);
|
CHECK(j.find("\"time\":1700000000000") != std::string::npos);
|
||||||
|
CHECK(j.find("\"cams_offline\":\"\"") != std::string::npos); // all cameras delivering
|
||||||
|
h.cams_offline = "TIR";
|
||||||
|
CHECK(healthJson(h).find("\"cams_offline\":\"TIR\"") != std::string::npos);
|
||||||
CHECK(j.front() == '{');
|
CHECK(j.front() == '{');
|
||||||
CHECK(j.back() == '}');
|
CHECK(j.back() == '}');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
#include "fgc/ThermalStats.h"
|
||||||
|
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace fgc;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 20.00 degC at the default 0.01 K per count.
|
||||||
|
constexpr uint16_t kRoom = 29315;
|
||||||
|
|
||||||
|
std::vector<uint16_t> flat(uint32_t w, uint32_t h, uint16_t v) {
|
||||||
|
return std::vector<uint16_t>(static_cast<size_t>(w) * h, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("counts convert to tenths of a degree Celsius under either profile") {
|
||||||
|
CHECK(countsToDeciC(27315, 0.01) == 0); // 273.15 K
|
||||||
|
CHECK(countsToDeciC(kRoom, 0.01) == 200); // 20.0 degC
|
||||||
|
CHECK(countsToDeciC(23315, 0.01) == -400); // -40.0 degC, sky
|
||||||
|
CHECK(countsToDeciC(57315, 0.01) == 3000); // 300.0 degC
|
||||||
|
// Profile 1 doubles the step: the same temperature is half the count.
|
||||||
|
CHECK(countsToDeciC(14658, 0.02) == 200); // 293.16 K -> 20.01 rounds to 20.0
|
||||||
|
CHECK(countsToDeciC(28658, 0.02) == 3000); // 573.16 K
|
||||||
|
// Rounded to nearest, not truncated: 20.06 degC.
|
||||||
|
CHECK(countsToDeciC(29321, 0.01) == 201);
|
||||||
|
CHECK(countsToDeciC(29319, 0.01) == 200); // 20.04 degC
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("maximum, median and hot spot of a scene") {
|
||||||
|
const uint32_t w = 8, h = 4;
|
||||||
|
auto px = flat(w, h, kRoom);
|
||||||
|
px[2 * w + 5] = 57315; // 300 degC at (5, 2)
|
||||||
|
ThermalFrameInfo info;
|
||||||
|
const auto s = thermalStats(px.data(), w, h, info);
|
||||||
|
REQUIRE(s.valid);
|
||||||
|
CHECK(s.valid_pixels == w * h);
|
||||||
|
CHECK(s.max_count == 57315);
|
||||||
|
CHECK(s.tmax_decic == 3000);
|
||||||
|
CHECK(s.median_count == kRoom); // one outlier does not move the median
|
||||||
|
CHECK(s.tmed_decic == 200);
|
||||||
|
CHECK(s.hotx == doctest::Approx(5.5 / 8)); // pixel centre, normalised
|
||||||
|
CHECK(s.hoty == doctest::Approx(2.5 / 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the median is the lower middle value") {
|
||||||
|
const std::vector<uint16_t> px = {30000, 29000, 31000, 28000}; // sorted 28 29 30 31
|
||||||
|
const auto s = thermalStats(px.data(), 4, 1, ThermalFrameInfo{});
|
||||||
|
CHECK(s.median_count == 29000);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the first occurrence of the maximum is the hot spot") {
|
||||||
|
auto px = flat(4, 4, kRoom);
|
||||||
|
px[1 * 4 + 1] = 40000;
|
||||||
|
px[3 * 4 + 3] = 40000;
|
||||||
|
const auto s = thermalStats(px.data(), 4, 4, ThermalFrameInfo{});
|
||||||
|
CHECK(s.hotx == doctest::Approx(1.5 / 4));
|
||||||
|
CHECK(s.hoty == doctest::Approx(1.5 / 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("zero and wrapped counts take no part") {
|
||||||
|
auto px = flat(4, 2, kRoom);
|
||||||
|
px[0] = 0; // the camera's start-up frames are zeros
|
||||||
|
px[1] = 65535; // past the LUT's wrap: not a temperature
|
||||||
|
px[2] = 65281;
|
||||||
|
ThermalFrameInfo info;
|
||||||
|
const auto s = thermalStats(px.data(), 4, 2, info);
|
||||||
|
REQUIRE(s.valid);
|
||||||
|
CHECK(s.valid_pixels == 5);
|
||||||
|
CHECK(s.max_count == kRoom);
|
||||||
|
// At the limit itself the count still counts.
|
||||||
|
px[3] = info.valid_count_max;
|
||||||
|
CHECK(thermalStats(px.data(), 4, 2, info).max_count == info.valid_count_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("a frame with no valid pixel has no statistics") {
|
||||||
|
const auto zeros = flat(4, 4, 0);
|
||||||
|
CHECK_FALSE(thermalStats(zeros.data(), 4, 4, ThermalFrameInfo{}).valid);
|
||||||
|
CHECK_FALSE(thermalStats(nullptr, 4, 4, ThermalFrameInfo{}).valid);
|
||||||
|
CHECK_FALSE(thermalStats(zeros.data(), 0, 4, ThermalFrameInfo{}).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("the scale comes from the frame's profile") {
|
||||||
|
const auto px = flat(2, 2, 14658); // 20 degC under profile 1
|
||||||
|
ThermalFrameInfo info;
|
||||||
|
info.cal = 1;
|
||||||
|
info.kelvin_per_count = 0.02;
|
||||||
|
CHECK(thermalStats(px.data(), 2, 2, info).tmed_decic == 200);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue