#include "fgc/ImagePipeline.h" #include "fgc/mock/MockCameraSource.h" #include "jxl_test_decode.h" #include #include #include #include #include #include using namespace fgc; using fgc_test::decode; namespace fs = std::filesystem; namespace { struct FakeChannel : IControlChannel { std::mutex m; std::vector events; bool connect() override { return true; } void disconnect() override {} bool connected() const override { return true; } void publishStatus(int) override {} void publishCamEvent(const CamEvent& e) override { std::lock_guard l(m); events.push_back(e); } size_t count() { std::lock_guard l(m); return events.size(); } }; struct TempDir { fs::path path; TempDir() { path = fs::temp_directory_path() / ("fgc_pipe_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); fs::create_directories(path); } ~TempDir() { fs::remove_all(path); } }; // A uniformly coloured raw frame: every red site `r`, green `g`, blue `b` // (on the layout's own scale), in BayerRG order (R G / G B). Frame bayerFrame(PixelLayout layout, uint32_t w, uint32_t h, int r, int g, int b, long long ts) { Frame f; f.width = w; f.height = h; f.layout = layout; f.channels = 1; f.timestamp_ms = ts; std::vector m(static_cast(w) * h); for (uint32_t y = 0; y < h; ++y) for (uint32_t x = 0; x < w; ++x) m[static_cast(y) * w + x] = static_cast( y % 2 == 0 ? (x % 2 == 0 ? r : g) : (x % 2 == 0 ? g : b)); f.data.assign(layoutFrameBytes(layout, w, h), 0); if (layout == PixelLayout::BayerRG8) { for (size_t i = 0; i < m.size(); ++i) f.data[i] = static_cast(m[i]); } else { for (size_t i = 0, o = 0; i < m.size(); i += 2, o += 3) { f.data[o] = static_cast(m[i] & 0xFF); f.data[o + 1] = static_cast(((m[i] >> 8) & 0x0F) | ((m[i + 1] & 0x0F) << 4)); f.data[o + 2] = static_cast(m[i + 1] >> 4); } } 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 px(static_cast(w) * h, room); px[static_cast(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) { const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); while (p.imagesSaved() < n && std::chrono::steady_clock::now() < end) std::this_thread::sleep_for(std::chrono::milliseconds(5)); return p.imagesSaved() >= n; } // Mean of channel c over the interior (de-Bayering blurs the frame's edge). double interiorMean(const fgc_test::Decoded& d, int w, int h, int c) { double s = 0; size_t n = 0; for (int y = 4; y < h - 4; ++y) for (int x = 4; x < w - 4; ++x, ++n) s += d.px[(static_cast(y) * w + x) * 3 + c]; return s / n; } ImagePipeline::Params params(const TempDir& t, double distance = 0.0) { ImagePipeline::Params p; p.output_dir = t.path.string(); p.tower = "test"; p.cameras = {{"RGB", 0, distance, 1, true}}; p.jxl_threads = 2; return p; } std::string onlyFile(const TempDir& t) { for (auto& e : fs::recursive_directory_iterator(t.path)) if (e.path().extension() == ".jxl") return e.path().string(); return {}; } } // namespace TEST_CASE("a 12-bit raw frame is saved as a 12-bit colour file, colours in place") { TempDir t; FakeChannel ch; ImagePipeline pipe(ch, [] { return Orientation{12.5f, 3.0f}; }, params(t)); pipe.start(); // Red 3200, green 1600, blue 800 of 4095. pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3200, 1600, 800, 1000)); REQUIRE(waitSaved(pipe, 1)); pipe.stop(); const auto d = decode(onlyFile(t), 3); CHECK(d.bits == 12); REQUIRE(d.px.size() == 64u * 48 * 3); CHECK(interiorMean(d, 64, 48, 0) == doctest::Approx(3200).epsilon(0.01)); CHECK(interiorMean(d, 64, 48, 1) == doctest::Approx(1600).epsilon(0.01)); CHECK(interiorMean(d, 64, 48, 2) == doctest::Approx(800).epsilon(0.01)); REQUIRE(ch.count() == 1); CHECK(ch.events[0].heading_decideg == 125); CHECK(ch.events[0].timestamp_ms == 1000); } TEST_CASE("an 8-bit raw frame is saved as 8-bit colour, either de-Bayer method") { for (bool ea : {true, false}) { CAPTURE(ea); TempDir t; FakeChannel ch; auto p = params(t); p.cameras[0].edge_aware = ea; ImagePipeline pipe(ch, nullptr, p); pipe.start(); pipe.submit(bayerFrame(PixelLayout::BayerRG8, 64, 48, 200, 100, 50, 2000)); REQUIRE(waitSaved(pipe, 1)); pipe.stop(); const auto d = decode(onlyFile(t), 3); CHECK(d.bits == 8); CHECK(interiorMean(d, 64, 48, 0) == doctest::Approx(200).epsilon(0.02)); CHECK(interiorMean(d, 64, 48, 2) == doctest::Approx(50).epsilon(0.05)); } } TEST_CASE("RGB8 frames are saved as before") { TempDir t; FakeChannel ch; ImagePipeline pipe(ch, nullptr, params(t, 0.8)); MockCameraSource cam(1, 64, 48, PixelLayout::RGB8); cam.setFrameCallback([&pipe](const Frame& f) { pipe.submit(f); }); pipe.start(); REQUIRE(cam.trigger()); REQUIRE(waitSaved(pipe, 1)); pipe.stop(); const auto d = decode(onlyFile(t), 3); CHECK(d.bits == 8); CHECK(d.px.size() == 64u * 48 * 3); } TEST_CASE("rotation applies to raw frames too") { TempDir t; FakeChannel ch; auto p = params(t); p.cameras[0].rotation_deg = 90; ImagePipeline pipe(ch, nullptr, p); pipe.start(); pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3200, 1600, 800, 3000)); REQUIRE(waitSaved(pipe, 1)); pipe.stop(); const auto d = decode(onlyFile(t), 3); CHECK(d.px.size() == 48u * 64 * 3); // 48 wide, 64 high now CHECK(interiorMean(d, 48, 64, 0) == doctest::Approx(3200).epsilon(0.01)); } TEST_CASE("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") { TempDir t; FakeChannel ch; auto p = params(t, 1.0); p.workers = 3; p.jxl_threads = 1; ImagePipeline pipe(ch, nullptr, p); pipe.start(); for (int i = 0; i < 9; ++i) pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3000, 1500, 700, 5000 + i)); REQUIRE(waitSaved(pipe, 9)); pipe.stop(); CHECK(ch.count() == 9); size_t files = 0; for (auto& e : fs::recursive_directory_iterator(t.path)) files += e.path().extension() == ".jxl"; CHECK(files == 9); } TEST_CASE("save timing counts the pictures saved in the last minute and how long they took") { TempDir t; FakeChannel ch; ImagePipeline pipe(ch, nullptr, params(t, 1.0)); CHECK(pipe.saveTiming().count == 0); pipe.start(); for (int i = 0; i < 3; ++i) pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 3000, 1500, 700, 7000 + i)); REQUIRE(waitSaved(pipe, 3)); pipe.stop(); const auto s = pipe.saveTiming(); CHECK(s.count == 3); CHECK(s.mean_ms > 0.0); CHECK(s.max_ms >= s.mean_ms); } TEST_CASE("canAccept says no once queue_max frames are waiting, yes once they are saved") { TempDir t; FakeChannel ch; auto p = params(t, 1.0); p.queue_max = 2; ImagePipeline pipe(ch, nullptr, p); CHECK(pipe.canAccept()); pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 1)); CHECK(pipe.canAccept()); pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 2)); CHECK_FALSE(pipe.canAccept()); // not started: both still waiting CHECK(pipe.pending() == 2); pipe.submit(bayerFrame(PixelLayout::BayerRG8, 32, 32, 100, 100, 100, 3)); // never refused CHECK(pipe.pending() == 3); pipe.start(); REQUIRE(waitSaved(pipe, 3)); pipe.stop(); CHECK(pipe.canAccept()); CHECK(pipe.pending() == 0); } TEST_CASE("a frame shorter than its layout is dropped, not saved") { TempDir t; FakeChannel ch; ImagePipeline pipe(ch, nullptr, params(t)); pipe.start(); Frame f = bayerFrame(PixelLayout::BayerRG12p, 64, 48, 1, 1, 1, 7); f.data.resize(f.data.size() / 2); pipe.submit(f); pipe.submit(bayerFrame(PixelLayout::BayerRG12p, 64, 48, 1, 1, 1, 8)); REQUIRE(waitSaved(pipe, 1)); pipe.stop(); CHECK(pipe.imagesSaved() == 1); CHECK(ch.count() == 1); }