Fix what the thermal camera's first run on tower 111 showed

- The thermal frame was numbered camera 0 - label RGB, and the colour file of
  the same time overwritten - because Vimba counts no camera before open().
  The composite now takes each source's configured camera count, and two
  enabled cameras may no longer share a label.
- The SDK switches LC_NUMERIC to de_DE when loaded, which made "hotx":0,4336.
  Its calls now run under a guard that restores the locale, and the JSON's
  numbers no longer go through printf.
- The camera's frame counter is always 0: new frames are told by their time,
  so the stream no longer stops after the first one.
- The LUT is empty for the first frame: it is asked again on later ones.
- The SDK is libirbgrab_x86_64-linux.so in the ld cache.
- The null channel traces the CamEvent payload it would have sent.

Tested on tower 111 with the TarisIR mini, the motors mocked: cap_net_raw is
enough, RGB/TIR pairs with one timestamp, the TIR file decodes to the counts
its CamEvent describes, profile switches 0-1-0. 536/536 in the 22.04 container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Peter Bochmann 2026-10-14 21:01:33 +02:00
parent 3d90af8490
commit 474e3cac8a
Signed by: peter.bochmann
GPG Key ID: 94E8C523489B8AC1
16 changed files with 253 additions and 58 deletions

View File

@ -78,7 +78,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `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`) | `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`) | `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`) | `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_linux.so` | The InfraTec SDK, found through the ld cache | | `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 | | `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 |

View File

@ -167,9 +167,13 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
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.~~ Done for the thermal camera (`driver = taris`, - ~~Support a second camera backend.~~ Done for the thermal camera (`driver = taris`,
`TarisCameraSource`, behind `CompositeCameraSource`). Open on the tower: whether `cap_net_raw` alone is `TarisCameraSource`, behind `CompositeCameraSource`), tested with the TarisIR mini on tower 111 on
enough for the SDK, whether `Framerate_Hz` is settable, and whether `MissedPackets` is per frame 2026-10-14 (docs/tower-installation.md, Phase 2b). Not yet tested: a pulled camera cable during a scan
(docs/tower-installation.md, Phase 2b). A NIR unit would be a third driver behind the same key. (the reconnect is only unit-tested), and a long unattended run. A NIR unit would be a third driver
behind the same key.
- 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 - 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 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. thermal image's own frame; projecting them onto terrain needs a per-camera offset nobody has measured yet.

View File

@ -939,9 +939,21 @@ 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 state and MTU. A `0x80000017` that keeps coming is the licence before it is the
NUC. NUC.
Still to confirm on the tower (2026-10-14): that `cap_net_raw` is enough (if Measured on tower 111 (2026-10-14, SDK 4.3.44, SN 2017744):
the SDK also wants `cap_net_admin`, add it in `scripts/fgc-setcap`), that the
frame rate is settable, and that `MissedPackets` counts per frame. - `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

View File

@ -24,9 +24,16 @@ namespace fgc {
// //
// Indices are global: child k's camera i is offset(k) + i. Calls about one // Indices are global: child k's camera i is offset(k) + i. Calls about one
// camera go to the child that has it. // 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 { class CompositeCameraSource : public ICameraSource {
public: public:
explicit CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children); explicit CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
std::vector<int> counts = {});
void open() override; void open() override;
void close() override; void close() override;
@ -55,6 +62,7 @@ private:
ICameraSource* route(int camera_index, int& local) const; ICameraSource* route(int camera_index, int& local) const;
std::vector<std::unique_ptr<ICameraSource>> children_; 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 std::vector<int> offsets_; // global index of each child's camera 0
FrameCallback callback_; FrameCallback callback_;
bool in_trigger_ = false; bool in_trigger_ = false;

View File

@ -222,7 +222,7 @@ struct CameraSpec {
// A frame older than this at capture is not kept. Must stay below the scan's // 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. // settle wait, or the frame may have been taken while the gimbal still moved.
int max_frame_age_ms = 400; int max_frame_age_ms = 400;
std::string library = "libirbgrab_linux.so"; // the SDK, found through the ld cache 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. // Mounting (hardware.ini): the camera's network port, for diagnostics only.
std::string interface; std::string interface;

View File

@ -32,7 +32,7 @@ public:
int calib_profile = 0; int calib_profile = 0;
double frame_rate_hz = 5.0; double frame_rate_hz = 5.0;
int max_frame_age_ms = 400; int max_frame_age_ms = 400;
std::string library = "libirbgrab_linux.so"; std::string library = "libirbgrab_x86_64-linux.so";
std::string interface; // for diagnostics only; may be empty std::string interface; // for diagnostics only; may be empty
int stall_ms = 5000; // connected but no frame this long: reconnect int stall_ms = 5000; // connected but no frame this long: reconnect
int retry_min_ms = 2000; // between connection attempts, doubling... int retry_min_ms = 2000; // between connection attempts, doubling...

View File

@ -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_;

View File

@ -6,13 +6,18 @@
namespace fgc { namespace fgc {
CompositeCameraSource::CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children) CompositeCameraSource::CompositeCameraSource(std::vector<std::unique_ptr<ICameraSource>> children,
: children_(std::move(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 (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; int next = 0;
for (const auto& c : children_) { for (int n : counts_) {
offsets_.push_back(next); offsets_.push_back(next);
next += c->cameraCount(); next += n;
} }
} }
@ -20,7 +25,7 @@ ICameraSource* CompositeCameraSource::route(int camera_index, int& local) const
for (size_t k = children_.size(); k-- > 0;) { for (size_t k = children_.size(); k-- > 0;) {
if (camera_index < offsets_[k]) continue; if (camera_index < offsets_[k]) continue;
local = camera_index - offsets_[k]; local = camera_index - offsets_[k];
return local < children_[k]->cameraCount() ? children_[k].get() : nullptr; return local < counts_[k] ? children_[k].get() : nullptr;
} }
return nullptr; return nullptr;
} }
@ -100,8 +105,12 @@ bool CompositeCameraSource::readFeature(int camera_index, const std::string& nam
return c && c->readFeature(local, name, value); 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 CompositeCameraSource::cameraCount() const {
return offsets_.back() + children_.back()->cameraCount(); int n = 0;
for (const auto& c : children_) n += c->cameraCount();
return n;
} }
std::vector<CameraDeviceInfo> CompositeCameraSource::deviceInfo() { std::vector<CameraDeviceInfo> CompositeCameraSource::deviceInfo() {

View File

@ -11,6 +11,7 @@
#include <condition_variable> #include <condition_variable>
#include <cstring> #include <cstring>
#include <deque> #include <deque>
#include <clocale>
#include <fstream> #include <fstream>
#include <mutex> #include <mutex>
#include <sstream> #include <sstream>
@ -46,6 +47,27 @@ std::string sysfs(const std::string& path) {
return 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 kLogEveryMs = 60000; // repeated trouble: one line a minute
constexpr long long kTelemetryEveryMs = 5000; constexpr long long kTelemetryEveryMs = 5000;
@ -97,7 +119,8 @@ struct TarisCameraSource::Impl {
int cal = -1; // active profile as the camera reports it int cal = -1; // active profile as the camera reports it
double kpc = 0.0; // measured from the LUT; 0 = not yet double kpc = 0.0; // measured from the LUT; 0 = not yet
long long last_frame_steady = 0; long long last_frame_steady = 0;
long long last_image_num = -1; 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 last_telemetry = 0;
long long nuc_or_licence = 0; // getdata 0x80000017 since the last log line long long nuc_or_licence = 0; // getdata 0x80000017 since the last log line
long long nuc_logged_ms = 0; long long nuc_logged_ms = 0;
@ -185,15 +208,23 @@ struct TarisCameraSource::Impl {
} }
Ret setInt32(uint32_t what, int32_t v) { return api->dev_setparam(dev, what, &v, kTypeInt32); } Ret setInt32(uint32_t what, int32_t v) { return api->dev_setparam(dev, what, &v, kTypeInt32); }
// Calib_Active is an indexed value; read it plainly if the camera prefers. // 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() { int readCal() {
for (int attempt = 0; attempt < 10; ++attempt) {
int32_t v = -1; int32_t v = -1;
if (getIdxInt32(param::kCalibActive, 0, v)) return v; if (getInt32(param::kCalibActive, v) && v >= 0) return v;
if (getInt32(param::kCalibActive, v)) return v; if (getIdxInt32(param::kCalibActive, 0, v) && v >= 0) return v;
std::this_thread::sleep_for(std::chrono::milliseconds(300));
}
return -1; return -1;
} }
// Switch the calibration profile and wait for the camera to finish. // 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) { bool switchCal(int profile, std::string& why) {
int32_t count = 0; int32_t count = 0;
if (getInt32(param::kCalibCount, count) && (profile < 0 || profile >= count)) { if (getInt32(param::kCalibCount, count) && (profile < 0 || profile >= count)) {
@ -219,7 +250,8 @@ struct TarisCameraSource::Impl {
return false; return false;
} }
cal = readCal(); cal = readCal();
kpc = 0.0; // measure the new scale from the next frame's LUT kpc = 0.0; // measure the new scale from the next frames' LUT
scale_tries = 0;
if (cal != profile) { if (cal != profile) {
why = "camera reports profile " + std::to_string(cal) + " after switching to " + why = "camera reports profile " + std::to_string(cal) + " after switching to " +
std::to_string(profile); std::to_string(profile);
@ -336,6 +368,11 @@ struct TarisCameraSource::Impl {
// Profile: it sets the counts' scale, so it is checked before any frame. // Profile: it sets the counts' scale, so it is checked before any frame.
cal = readCal(); cal = readCal();
if (cal < 0) {
disconnect();
why = "cannot read the active calibration profile";
return false;
}
if (cal != o.calib_profile) { if (cal != o.calib_profile) {
LOG_INFO << "thermal camera on calibration profile " << cal << ", switching to " LOG_INFO << "thermal camera on calibration profile " << cal << ", switching to "
<< o.calib_profile; << o.calib_profile;
@ -371,7 +408,8 @@ struct TarisCameraSource::Impl {
++connects; ++connects;
} }
kpc = 0.0; kpc = 0.0;
last_image_num = -1; scale_tries = 0;
last_sdk_ts = -1.0;
zeros_logged = false; zeros_logged = false;
last_frame_steady = steadyMs(); // grace for the first frame last_frame_steady = steadyMs(); // grace for the first frame
return true; return true;
@ -380,6 +418,9 @@ struct TarisCameraSource::Impl {
// ---- frames ------------------------------------------------------------ // ---- frames ------------------------------------------------------------
// Take the newest frame from the SDK, if there is one. // Take the newest frame from the SDK, if there is one.
void poll() { 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; Handle mem = 0;
const Ret r = api->dev_getdata(dev, kMemIrDigitFrame, &mem); const Ret r = api->dev_getdata(dev, kMemIrDigitFrame, &mem);
if (!ok(r)) { if (!ok(r)) {
@ -411,11 +452,17 @@ struct TarisCameraSource::Impl {
const size_t need = static_cast<size_t>(w) * h * 2; const size_t need = static_cast<size_t>(w) * h * 2;
if (bytes < 0 || static_cast<size_t>(bytes) < need) return; 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{}; MemInfo mi{};
mi.struct_size = sizeof(MemInfo); mi.struct_size = sizeof(MemInfo);
if (!ok(api->mem_getinfo(mem, &mi))) mi.image_num = -1; if (!ok(api->mem_getinfo(mem, &mi))) {
if (mi.image_num >= 0 && mi.image_num == last_image_num) return; // seen it mi.image_num = -1;
last_image_num = mi.image_num; 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); const auto* counts = reinterpret_cast<const uint16_t*>(ptr);
// The first frames after starting are all zero. // The first frames after starting are all zero.
@ -470,24 +517,33 @@ struct TarisCameraSource::Impl {
} }
// The counts' scale, from the LUT at emissivity and transmission 1, where it // The counts' scale, from the LUT at emissivity and transmission 1, where it
// is a straight line through zero. // 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) { void measureScale(Handle mem) {
std::vector<float> lut(65536, 0.f); 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 Ret r = api->mem_getdvlut(mem, lut.data(), 1.f, 1.f, 293.15f, 293.15f);
const double expected = expectedKelvinPerCount(cal); const double expected = expectedKelvinPerCount(cal);
if (!ok(r)) { double measured = 0.0;
LOG_WARN << "thermal camera: cannot read the LUT (" << describe(r) << "); assuming " if (ok(r)) measured = (static_cast<double>(lut[40000]) - lut[30000]) / 10000.0;
<< expected << " K per count for profile " << cal; if (!(measured > 0.0) && ++scale_tries < kScaleTries) return; // try the next frame
kpc = expected; LOG_DEBUG << "thermal camera LUT after " << scale_tries << " empty: " << describe(r)
return; << ", [0] " << lut[0] << " [29315] " << lut[29315] << " [65280] "
} << lut[65280] << " [65535] " << lut[65535] << " K";
kpc = (static_cast<double>(lut[10000]) - lut[0]) / 10000.0; 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) if (expected > 0.0 && std::abs(kpc - expected) > expected * 0.01)
LOG_WARN << "thermal camera: profile " << cal << " has " << kpc LOG_WARN << "thermal camera: profile " << cal << " has " << kpc
<< " K per count, not the documented " << expected; << " K per count, not the documented " << expected;
else else
LOG_INFO << "thermal camera: profile " << cal << ", " << kpc << " K per count"; LOG_INFO << "thermal camera: profile " << cal << ", " << kpc << " K per count";
} }
}
void telemetry() { void telemetry() {
float k = 0.f; float k = 0.f;
@ -501,6 +557,7 @@ struct TarisCameraSource::Impl {
void run() { void run() {
if (!api) { if (!api) {
std::string err; std::string err;
LocaleGuard keep;
api = irbgrab::load(o.library, err); api = irbgrab::load(o.library, err);
if (!api) { if (!api) {
setOffline(err + " (InfraTec SDK not installed?)"); setOffline(err + " (InfraTec SDK not installed?)");
@ -512,7 +569,11 @@ struct TarisCameraSource::Impl {
"which sets it, or sudo setcap cap_net_raw+ep build/fire_gimbal_control"); "which sets it, or sudo setcap cap_net_raw+ep build/fire_gimbal_control");
return; return;
} }
Ret r = api->dll_init(); Ret r;
{
LocaleGuard keep;
r = api->dll_init();
}
if (!ok(r)) { if (!ok(r)) {
setOffline("SDK init failed: " + describe(r)); setOffline("SDK init failed: " + describe(r));
return; return;
@ -526,6 +587,7 @@ struct TarisCameraSource::Impl {
const long long poll_ms = const long long poll_ms =
std::max<long long>(5, static_cast<long long>(500.0 / o.frame_rate_hz)); std::max<long long>(5, static_cast<long long>(500.0 / o.frame_rate_hz));
while (running) { while (running) {
LocaleGuard keep_locale; // every SDK call in this pass, polling included
if (!connected) { if (!connected) {
{ {
// Connecting is what a reset asks for; it must not keep the // Connecting is what a reset asks for; it must not keep the
@ -534,7 +596,12 @@ struct TarisCameraSource::Impl {
reset_wanted = false; reset_wanted = false;
} }
std::string why; std::string why;
if (connect(why)) { bool up;
{
LocaleGuard keep;
up = connect(why);
}
if (up) {
retry = o.retry_min_ms; retry = o.retry_min_ms;
std::lock_guard<std::mutex> l(m); std::lock_guard<std::mutex> l(m);
why_offline = "waiting for the first frame"; why_offline = "waiting for the first frame";
@ -563,6 +630,7 @@ struct TarisCameraSource::Impl {
} }
pause(poll_ms); pause(poll_ms);
} }
LocaleGuard keep;
disconnect(); disconnect();
if (sdk_init) api->dll_uninit(); if (sdk_init) api->dll_uninit();
sdk_init = false; sdk_init = false;

View File

@ -440,11 +440,20 @@ struct Application::Impl {
std::vector<const CameraSpec*> colour, thermal; std::vector<const CameraSpec*> colour, thermal;
for (const auto* c : cfg.camera.active()) (c->thermal() ? thermal : colour).push_back(c); 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<std::unique_ptr<ICameraSource>> sources;
if (!colour.empty() || thermal.empty()) sources.push_back(makeColourCamera(colour, mock)); std::vector<int> counts;
for (const auto* c : thermal) sources.push_back(makeThermalCamera(*c)); 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()); if (sources.size() == 1) return std::move(sources.front());
return std::make_unique<CompositeCameraSource>(std::move(sources)); return std::make_unique<CompositeCameraSource>(std::move(sources), std::move(counts));
} }
std::unique_ptr<ICameraSource> makeColourCamera(const std::vector<const CameraSpec*>& active, std::unique_ptr<ICameraSource> makeColourCamera(const std::vector<const CameraSpec*>& active,

View File

@ -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 {
@ -11,10 +12,23 @@ std::string towerTopic(const std::string& tower, const std::string& leaf) {
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

View File

@ -464,6 +464,17 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
// 0, and a thermal camera has none of the features it needs. // 0, and a thermal camera has none of the features it needs.
std::stable_partition(cfg.camera.cameras.begin(), cfg.camera.cameras.end(), std::stable_partition(cfg.camera.cameras.begin(), cfg.camera.cameras.end(),
[](const CameraSpec& c) { return !c.thermal(); }); [](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);

View File

@ -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"
@ -108,6 +110,24 @@ TEST_CASE("a thermal CamEvent carries profile, packets, temperatures and hot spo
"\"hoty\":null }") != std::string::npos); "\"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") { TEST_CASE("CamEvent names are escaped") {
CamEvent c; CamEvent c;
c.tower = "a\"b"; c.camera = "RGB"; c.tower = "a\"b"; c.camera = "RGB";

View File

@ -14,6 +14,8 @@ namespace {
struct FakeSource : ICameraSource { struct FakeSource : ICameraSource {
int count; int count;
long long ts; long long ts;
bool counts_after_open = false; // as Vimba: no cameras until open()
bool opened = false;
bool ready = true; bool ready = true;
bool throws_on_open = false; bool throws_on_open = false;
int triggers = 0; int triggers = 0;
@ -25,6 +27,7 @@ struct FakeSource : ICameraSource {
void open() override { void open() override {
if (throws_on_open) throw std::runtime_error("no camera"); if (throws_on_open) throw std::runtime_error("no camera");
opened = true;
} }
void close() override {} void close() override {}
void start() override { started = true; } void start() override { started = true; }
@ -41,7 +44,7 @@ struct FakeSource : ICameraSource {
return true; return true;
} }
void setFrameCallback(FrameCallback c) override { cb = std::move(c); } void setFrameCallback(FrameCallback c) override { cb = std::move(c); }
int cameraCount() const override { return count; } int cameraCount() const override { return counts_after_open && !opened ? 0 : count; }
bool readFeature(int i, const std::string& name, double& v) override { bool readFeature(int i, const std::string& name, double& v) override {
v = i * 100 + ts; v = i * 100 + ts;
return name == "X"; return name == "X";
@ -143,6 +146,32 @@ TEST_CASE("calls about one camera reach the source that has it") {
CHECK(f.timestamp_ms == 5050); 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") { TEST_CASE("start and stop reach every source") {
Rig r; Rig r;
r.src->start(); r.src->start();

View File

@ -310,7 +310,7 @@ TEST_CASE("A thermal camera needs no settings XML and is always lossless") {
CHECK(tir.calib_profile == 1); CHECK(tir.calib_profile == 1);
CHECK(tir.frame_rate_hz == doctest::Approx(9.0)); CHECK(tir.frame_rate_hz == doctest::Approx(9.0));
CHECK(tir.max_frame_age_ms == 400); CHECK(tir.max_frame_age_ms == 400);
CHECK(tir.library == "libirbgrab_linux.so"); CHECK(tir.library == "libirbgrab_x86_64-linux.so");
CHECK(tir.interface == "enp1s0"); CHECK(tir.interface == "enp1s0");
// [Camera] jxl_distance is for colour cameras; counts default to lossless. // [Camera] jxl_distance is for colour cameras; counts default to lossless.
CHECK(tir.jxl_distance == 0.0); CHECK(tir.jxl_distance == 0.0);
@ -367,7 +367,9 @@ TEST_CASE("Thermal camera blocks reject what cannot work") {
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.max_frame_age_ms", "0"}}))); CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.tir.max_frame_age_ms", "0"}})));
// An empty value means the default, as for every key. // An empty value means the default, as for every key.
CHECK(ConfigLoader::fromMap(base({{"Camera.tir.library", ""}})).camera.cameras[1].library == CHECK(ConfigLoader::fromMap(base({{"Camera.tir.library", ""}})).camera.cameras[1].library ==
"libirbgrab_linux.so"); "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. // A disabled thermal camera needs no serial, like any other.
CHECK_NOTHROW(ConfigLoader::fromMap( CHECK_NOTHROW(ConfigLoader::fromMap(
base({{"Camera.tir.id", ""}, {"Camera.tir.enabled", "false"}}))); base({{"Camera.tir.id", ""}, {"Camera.tir.enabled", "false"}})));

View File

@ -30,7 +30,8 @@ struct FakeSdk {
int connects = 0; int connects = 0;
// The newest frame: counts, and the camera's number for it. // The newest frame: counts, and the camera's number for it.
std::vector<uint16_t> px; std::vector<uint16_t> px;
int64_t image_num = 0; 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; uint32_t w = 8, h = 4;
Ret no_frame = kErrNotFound; // what getdata says before any frame Ret no_frame = kErrNotFound; // what getdata says before any frame
std::atomic<bool> frozen{false}; // stop producing frames (stall) std::atomic<bool> frozen{false}; // stop producing frames (stall)
@ -66,7 +67,7 @@ Ret fConnect(Handle, char* s) {
g->connect_attempts.push_back(str); g->connect_attempts.push_back(str);
const bool ok = str.size() >= g->accept.size() && const bool ok = str.size() >= g->accept.size() &&
str.compare(str.size() - g->accept.size(), g->accept.size(), g->accept) == 0; str.compare(str.size() - g->accept.size(), g->accept.size(), g->accept) == 0;
if (ok) ++g->connects; if (ok) { ++g->connects; g->lut_calls = 0; }
return ok ? kSuccess : kTypeErr | 0x10; return ok ? kSuccess : kTypeErr | 0x10;
} }
Ret fDisconnect(Handle) { return kSuccess; } Ret fDisconnect(Handle) { return kSuccess; }
@ -136,14 +137,23 @@ Ret fPtr(Handle mem, Handle* p, int32_t* bytes) {
return kSuccess; return kSuccess;
} }
Ret fInfo(Handle mem, void* info) { 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); auto* mi = static_cast<MemInfo*>(info);
mi->image_num = reinterpret_cast<Mem*>(mem)->num; mi->image_num = 0;
mi->timestamp_ms = 200.0 * static_cast<double>(reinterpret_cast<Mem*>(mem)->num);
mi->missed_packets = 1; mi->missed_packets = 1;
return kSuccess; return kSuccess;
} }
Ret fLut(Handle mem, void* lut, float, float, float, float) { Ret fLut(Handle mem, void* lut, float, float, float, float) {
const double kpc = reinterpret_cast<Mem*>(mem)->cal == 1 ? 0.02 : 0.01; const double kpc = reinterpret_cast<Mem*>(mem)->cal == 1 ? 0.02 : 0.01;
auto* l = static_cast<float*>(lut); 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); for (int i = 0; i < 65536; ++i) l[i] = static_cast<float>(i * kpc);
return kSuccess; return kSuccess;
} }
@ -235,7 +245,7 @@ TEST_CASE("a fresh frame is delivered once, as counts with their scale") {
CHECK(px5 == 57315); CHECK(px5 == 57315);
REQUIRE(f.thermal); REQUIRE(f.thermal);
CHECK(f.thermal->cal == 0); CHECK(f.thermal->cal == 0);
CHECK(f.thermal->kelvin_per_count == doctest::Approx(0.01)); // measured from the LUT CHECK(f.thermal->kelvin_per_count == doctest::Approx(0.01)); // documented, then measured
CHECK(f.thermal->missed_packets == 1); CHECK(f.thermal->missed_packets == 1);
t.sdk.push(29400); t.sdk.push(29400);