From 474e3cac8a216d3e28521e445c313aadce368155 Mon Sep 17 00:00:00 2001 From: Peter Bochmann Date: Wed, 14 Oct 2026 21:01:33 +0200 Subject: [PATCH] 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 --- docs/configuration.md | 2 +- docs/known-issues.md | 10 ++- docs/tower-installation.md | 18 +++- include/fgc/CompositeCameraSource.h | 10 ++- include/fgc/Config.h | 2 +- include/fgc/TarisCameraSource.h | 2 +- include/fgc/mock/NullControlChannel.h | 7 +- src/camera/CompositeCameraSource.cpp | 21 +++-- src/camera/TarisCameraSource.cpp | 118 ++++++++++++++++++++------ src/core/Application.cpp | 15 +++- src/core/ChannelTopics.cpp | 20 ++++- src/core/Config.cpp | 11 +++ tests/test_channelhistory.cpp | 20 +++++ tests/test_composite.cpp | 31 ++++++- tests/test_config.cpp | 6 +- tests/test_tariscamerasource.cpp | 18 +++- 16 files changed, 253 insertions(+), 58 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 4c9f794..5509f9b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,7 +78,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Camera.` (`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 ` | | `Camera.` (`taris`) | `frame_rate_hz` | 0 < x ≤ 50 | `5` | The rate the camera free-runs at | | `Camera.` (`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.` (`taris`) | `library` | string | `libirbgrab_linux.so` | The InfraTec SDK, found through the ld cache | +| `Camera.` (`taris`) | `library` | string | `libirbgrab_x86_64-linux.so` | The InfraTec SDK, found through the ld cache | | `Camera.` (`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) | `jxl_threads` | int 0..8 | `0` = 4 / `save_workers` | libjxl threads per saved frame | diff --git a/docs/known-issues.md b/docs/known-issues.md index 4462a45..00dd7fb 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -167,9 +167,13 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM an error. - 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`, - `TarisCameraSource`, behind `CompositeCameraSource`). Open on the tower: whether `cap_net_raw` alone is - enough for the SDK, whether `Framerate_Hz` is settable, and whether `MissedPackets` is per frame - (docs/tower-installation.md, Phase 2b). A NIR unit would be a third driver behind the same 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. +- 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. diff --git a/docs/tower-installation.md b/docs/tower-installation.md index abd48cb..eebca2f 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -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 NUC. -Still to confirm on the tower (2026-10-14): that `cap_net_raw` is enough (if -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. +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 diff --git a/include/fgc/CompositeCameraSource.h b/include/fgc/CompositeCameraSource.h index 4b4c34d..f015eae 100644 --- a/include/fgc/CompositeCameraSource.h +++ b/include/fgc/CompositeCameraSource.h @@ -24,9 +24,16 @@ namespace fgc { // // 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> children); + explicit CompositeCameraSource(std::vector> children, + std::vector counts = {}); void open() override; void close() override; @@ -55,6 +62,7 @@ private: ICameraSource* route(int camera_index, int& local) const; std::vector> children_; + std::vector counts_; // cameras each child is configured with std::vector offsets_; // global index of each child's camera 0 FrameCallback callback_; bool in_trigger_ = false; diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 8412a3f..3b49aff 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -222,7 +222,7 @@ struct CameraSpec { // 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_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. std::string interface; diff --git a/include/fgc/TarisCameraSource.h b/include/fgc/TarisCameraSource.h index 46f2bc0..6bd954c 100644 --- a/include/fgc/TarisCameraSource.h +++ b/include/fgc/TarisCameraSource.h @@ -32,7 +32,7 @@ public: int calib_profile = 0; double frame_rate_hz = 5.0; 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 int stall_ms = 5000; // connected but no frame this long: reconnect int retry_min_ms = 2000; // between connection attempts, doubling... diff --git a/include/fgc/mock/NullControlChannel.h b/include/fgc/mock/NullControlChannel.h index f8cfb66..f3d6d53 100644 --- a/include/fgc/mock/NullControlChannel.h +++ b/include/fgc/mock/NullControlChannel.h @@ -33,10 +33,9 @@ public: drop(kTopicStatusCode, payload); } void publishCamEvent(const CamEvent& e) override { - LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent cam=" << e.camera - << " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms - << " (null channel)"; - drop(kTopicCamEvent, camEventJson(e)); + const std::string payload = camEventJson(e); + LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent " << payload << " (null channel)"; + drop(kTopicCamEvent, payload); } void publishHealth(const HealthReport& h) override { ++health_published_; diff --git a/src/camera/CompositeCameraSource.cpp b/src/camera/CompositeCameraSource.cpp index b10d121..5dd52ba 100644 --- a/src/camera/CompositeCameraSource.cpp +++ b/src/camera/CompositeCameraSource.cpp @@ -6,13 +6,18 @@ namespace fgc { -CompositeCameraSource::CompositeCameraSource(std::vector> children) - : children_(std::move(children)) { +CompositeCameraSource::CompositeCameraSource(std::vector> children, + std::vector 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 (const auto& c : children_) { + for (int n : counts_) { 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;) { if (camera_index < offsets_[k]) continue; local = camera_index - offsets_[k]; - return local < children_[k]->cameraCount() ? children_[k].get() : nullptr; + return local < counts_[k] ? children_[k].get() : nullptr; } return nullptr; } @@ -100,8 +105,12 @@ bool CompositeCameraSource::readFeature(int camera_index, const std::string& nam 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 { - return offsets_.back() + children_.back()->cameraCount(); + int n = 0; + for (const auto& c : children_) n += c->cameraCount(); + return n; } std::vector CompositeCameraSource::deviceInfo() { diff --git a/src/camera/TarisCameraSource.cpp b/src/camera/TarisCameraSource.cpp index c9d8e4b..8e85918 100644 --- a/src/camera/TarisCameraSource.cpp +++ b/src/camera/TarisCameraSource.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,27 @@ std::string sysfs(const std::string& path) { 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; @@ -97,7 +119,8 @@ struct TarisCameraSource::Impl { 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; - 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 nuc_or_licence = 0; // getdata 0x80000017 since the last log line 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); } - // 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() { - int32_t v = -1; - if (getIdxInt32(param::kCalibActive, 0, v)) return v; - if (getInt32(param::kCalibActive, v)) return v; + 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)) { @@ -218,8 +249,9 @@ struct TarisCameraSource::Impl { std::to_string(o.calib_timeout_ms) + " ms"; return false; } - cal = readCal(); - kpc = 0.0; // measure the new scale from the next frame's LUT + 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); @@ -336,6 +368,11 @@ struct TarisCameraSource::Impl { // 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; @@ -371,7 +408,8 @@ struct TarisCameraSource::Impl { ++connects; } kpc = 0.0; - last_image_num = -1; + scale_tries = 0; + last_sdk_ts = -1.0; zeros_logged = false; last_frame_steady = steadyMs(); // grace for the first frame return true; @@ -380,6 +418,9 @@ struct TarisCameraSource::Impl { // ---- 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)) { @@ -411,11 +452,17 @@ struct TarisCameraSource::Impl { const size_t need = static_cast(w) * h * 2; if (bytes < 0 || static_cast(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; - if (mi.image_num >= 0 && mi.image_num == last_image_num) return; // seen it - last_image_num = mi.image_num; + 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(ptr); // The first frames after starting are all zero. @@ -470,23 +517,32 @@ struct TarisCameraSource::Impl { } // 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) { std::vector 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); - if (!ok(r)) { - LOG_WARN << "thermal camera: cannot read the LUT (" << describe(r) << "); assuming " - << expected << " K per count for profile " << cal; - kpc = expected; - return; + double measured = 0.0; + if (ok(r)) measured = (static_cast(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"; } - kpc = (static_cast(lut[10000]) - lut[0]) / 10000.0; - 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() { @@ -501,6 +557,7 @@ struct TarisCameraSource::Impl { void run() { if (!api) { std::string err; + LocaleGuard keep; api = irbgrab::load(o.library, err); if (!api) { 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"); return; } - Ret r = api->dll_init(); + Ret r; + { + LocaleGuard keep; + r = api->dll_init(); + } if (!ok(r)) { setOffline("SDK init failed: " + describe(r)); return; @@ -526,6 +587,7 @@ struct TarisCameraSource::Impl { const long long poll_ms = std::max(5, static_cast(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 @@ -534,7 +596,12 @@ struct TarisCameraSource::Impl { reset_wanted = false; } std::string why; - if (connect(why)) { + bool up; + { + LocaleGuard keep; + up = connect(why); + } + if (up) { retry = o.retry_min_ms; std::lock_guard l(m); why_offline = "waiting for the first frame"; @@ -563,6 +630,7 @@ struct TarisCameraSource::Impl { } pause(poll_ms); } + LocaleGuard keep; disconnect(); if (sdk_init) api->dll_uninit(); sdk_init = false; diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 1e2bc53..7d94ba2 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -440,11 +440,20 @@ struct Application::Impl { std::vector 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> sources; - if (!colour.empty() || thermal.empty()) sources.push_back(makeColourCamera(colour, mock)); - for (const auto* c : thermal) sources.push_back(makeThermalCamera(*c)); + std::vector counts; + if (!colour.empty() || thermal.empty()) { + sources.push_back(makeColourCamera(colour, mock)); + counts.push_back(colour.empty() ? 1 : static_cast(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(std::move(sources)); + return std::make_unique(std::move(sources), std::move(counts)); } std::unique_ptr makeColourCamera(const std::vector& active, diff --git a/src/core/ChannelTopics.cpp b/src/core/ChannelTopics.cpp index d61c1a7..042a726 100644 --- a/src/core/ChannelTopics.cpp +++ b/src/core/ChannelTopics.cpp @@ -1,6 +1,7 @@ #include "fgc/ChannelTopics.h" #include +#include #include namespace fgc { @@ -11,10 +12,23 @@ std::string towerTopic(const std::string& tower, const std::string& leaf) { 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) { - char b[32]; - std::snprintf(b, sizeof(b), "%.*f", decimals, v); - return b; + if (!std::isfinite(v)) return "null"; + long long scale = 1; + for (int i = 0; i < decimals; ++i) scale *= 10; + const long long n = std::llround(v * static_cast(scale)); + const bool neg = n < 0; + const unsigned long long a = neg ? 0ULL - static_cast(n) + : static_cast(n); + std::string out = (neg ? "-" : "") + std::to_string(a / static_cast(scale)); + if (decimals > 0) { + std::string frac = std::to_string(a % static_cast(scale)); + out += "." + std::string(static_cast(decimals) - frac.size(), '0') + frac; + } + return out; } } // namespace diff --git a/src/core/Config.cpp b/src/core/Config.cpp index f5fbfa9..8dd4b62 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -464,6 +464,17 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { // 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"); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); diff --git a/tests/test_channelhistory.cpp b/tests/test_channelhistory.cpp index 65a2aea..1720848 100644 --- a/tests/test_channelhistory.cpp +++ b/tests/test_channelhistory.cpp @@ -1,5 +1,7 @@ #include +#include + #include "fgc/ChannelHistory.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); } +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"; diff --git a/tests/test_composite.cpp b/tests/test_composite.cpp index ae3f716..e307430 100644 --- a/tests/test_composite.cpp +++ b/tests/test_composite.cpp @@ -14,6 +14,8 @@ namespace { 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; @@ -25,6 +27,7 @@ struct FakeSource : ICameraSource { void open() override { if (throws_on_open) throw std::runtime_error("no camera"); + opened = true; } void close() override {} void start() override { started = true; } @@ -41,7 +44,7 @@ struct FakeSource : ICameraSource { return true; } 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 { v = i * 100 + ts; return name == "X"; @@ -143,6 +146,32 @@ TEST_CASE("calls about one camera reach the source that has it") { 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(1, 1000); + auto t = std::make_unique(1, 5000); + c->counts_after_open = true; + FakeSource* colour = c.get(); + std::vector> v; + v.push_back(std::move(c)); + v.push_back(std::move(t)); + CompositeCameraSource src(std::move(v), {1, 1}); + std::vector 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>{}, {})); +} + TEST_CASE("start and stop reach every source") { Rig r; r.src->start(); diff --git a/tests/test_config.cpp b/tests/test_config.cpp index cf07cb8..249d17d 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -310,7 +310,7 @@ TEST_CASE("A thermal camera needs no settings XML and is always lossless") { 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_linux.so"); + 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); @@ -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"}}))); // An empty value means the default, as for every key. 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. CHECK_NOTHROW(ConfigLoader::fromMap( base({{"Camera.tir.id", ""}, {"Camera.tir.enabled", "false"}}))); diff --git a/tests/test_tariscamerasource.cpp b/tests/test_tariscamerasource.cpp index 19e1dcf..7576961 100644 --- a/tests/test_tariscamerasource.cpp +++ b/tests/test_tariscamerasource.cpp @@ -30,7 +30,8 @@ struct FakeSdk { int connects = 0; // The newest frame: counts, and the camera's number for it. std::vector 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; Ret no_frame = kErrNotFound; // what getdata says before any frame std::atomic frozen{false}; // stop producing frames (stall) @@ -66,7 +67,7 @@ Ret fConnect(Handle, char* 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; + if (ok) { ++g->connects; g->lut_calls = 0; } return ok ? kSuccess : kTypeErr | 0x10; } Ret fDisconnect(Handle) { return kSuccess; } @@ -136,14 +137,23 @@ Ret fPtr(Handle mem, Handle* p, int32_t* bytes) { 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(info); - mi->image_num = reinterpret_cast(mem)->num; + mi->image_num = 0; + mi->timestamp_ms = 200.0 * static_cast(reinterpret_cast(mem)->num); mi->missed_packets = 1; return kSuccess; } Ret fLut(Handle mem, void* lut, float, float, float, float) { const double kpc = reinterpret_cast(mem)->cal == 1 ? 0.02 : 0.01; auto* l = static_cast(lut); + { + std::lock_guard 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(i * kpc); return kSuccess; } @@ -235,7 +245,7 @@ TEST_CASE("a fresh frame is delivered once, as counts with their scale") { CHECK(px5 == 57315); REQUIRE(f.thermal); 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); t.sdk.push(29400);