Image rotation is now configurable via Camera.rotation_deg

This commit is contained in:
pgdalmeida 2026-08-26 14:14:49 +02:00
parent 5053b82811
commit 02d3f573cf
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
12 changed files with 51 additions and 9 deletions

View File

@ -56,6 +56,10 @@ white_balance_auto = true
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9. ; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9.
jxl_distance = 0.8 jxl_distance = 0.8
jxl_effort = 4 jxl_effort = 4
; Clockwise rotation applied to saved images: 0, 90, 180 or 270. Depends on how the
; camera is mounted. 0 = store frames exactly as the sensor delivers them.
; (Builds before this key existed always rotated 90 CCW, i.e. 270.)
rotation_deg = 0
[Capture] [Capture]
; How a frame is acquired at each waypoint, and the quality bar it must clear. ; How a frame is acquired at each waypoint, and the quality bar it must clear.

View File

@ -79,7 +79,7 @@ corrupted.
seeds exposure/gain from the per-angle store, software-triggers **one** frame, measures it, corrects and seeds exposure/gain from the per-angle store, software-triggers **one** frame, measures it, corrects and
re-shoots until it passes or the budget runs out. See *Image capture* below. re-shoots until it passes or the budget runs out. See *Image capture* below.
6. **Frame handling** — the accepted `Frame` goes to the callback, which `submit()`s it to the 6. **Frame handling** — the accepted `Frame` goes to the callback, which `submit()`s it to the
`ImagePipeline`. The worker rotates it 90° CCW, encodes JPEG XL (or copies the demo image), writes `ImagePipeline`. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders). `<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders).
## Capture state machine ## Capture state machine

View File

@ -47,6 +47,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Camera` | `white_balance_auto` | bool | `true` | `BalanceWhiteAuto=Continuous` | | `Camera` | `white_balance_auto` | bool | `true` | `BalanceWhiteAuto=Continuous` |
| `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) | | `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) |
| `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) | | `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) |
| `Camera` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to saved images (`0` = as the sensor delivers them) |
| `Capture` | `mode` | `trigger`\|`freerun` | `trigger` | Per-waypoint software trigger, or the older paced stream + keep-latest | | `Capture` | `mode` | `trigger`\|`freerun` | `trigger` | Per-waypoint software trigger, or the older paced stream + keep-latest |
| `Capture` | `quality_gate` | bool | `true` | Judge each frame and reshoot with corrected exposure until it passes | | `Capture` | `quality_gate` | bool | `true` | Judge each frame and reshoot with corrected exposure until it passes |
| `Capture` | `max_attempts` | int ≥ 1 | `3` | Retry budget per waypoint | | `Capture` | `max_attempts` | int ≥ 1 | `3` | Retry budget per waypoint |

View File

@ -125,7 +125,7 @@ Surfaced read-only in the TUI's `i` view (IMU CONFIG section); the legacy "scena
| Artifact | Path | Format | | Artifact | Path | Format |
|----------|------|--------| |----------|------|--------|
| Captured images | `<output_dir>/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated 90° CCW | | Captured images | `<output_dir>/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated per `Camera.rotation_deg` |
| Demo placeholder | `bin/x64/Release/test_smoke.jxl` | copied verbatim in demo mode | | Demo placeholder | `bin/x64/Release/test_smoke.jxl` | copied verbatim in demo mode |
| Diagnostics log | `<data_dir>/fire_gimbal_control/logs/diag_<ts>.log` | raw `DG` stream + parsed summary | | Diagnostics log | `<data_dir>/fire_gimbal_control/logs/diag_<ts>.log` | raw `DG` stream + parsed summary |
| Calibration log | `<data_dir>/fire_gimbal_control/logs/calib_<ts>.log` | per-axis samples + the least-squares fit | | Calibration log | `<data_dir>/fire_gimbal_control/logs/calib_<ts>.log` | per-axis samples + the least-squares fit |

View File

@ -54,6 +54,8 @@ struct CameraConfig {
// JPEG XL encoding (consumed by ImagePipeline). Near-lossless default. // JPEG XL encoding (consumed by ImagePipeline). Near-lossless default.
double jxl_distance = 0.8; // 0 = lossless; higher = lossier double jxl_distance = 0.8; // 0 = lossless; higher = lossier
int jxl_effort = 4; // libjxl effort 1..9 int jxl_effort = 4; // libjxl effort 1..9
// Clockwise rotation applied to saved images (0/90/180/270). 0 = as-sensed.
int rotation_deg = 0;
}; };
// [Capture]: how a frame is acquired at each waypoint, and the quality bar it has // [Capture]: how a frame is acquired at each waypoint, and the quality bar it has

View File

@ -21,11 +21,11 @@ struct Orientation {
float pitch_deg = 0.f; float pitch_deg = 0.f;
}; };
// Consumes captured frames and turns them into stored artifacts: rotate 90 deg // Consumes captured frames and turns them into stored artifacts: rotate by
// CCW, encode to JPEG XL, write to <output_dir>/<label>/<timestamp>.jxl, and // Camera.rotation_deg, encode to JPEG XL, write to
// publish a CamEvent. Runs a background worker so encoding never blocks // <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Runs a
// acquisition. Split out of the old VimbaHandler::SaveImage so any camera // background worker so encoding never blocks acquisition. Split out of the old
// source (real or mock) can feed it. // VimbaHandler::SaveImage so any camera source (real or mock) can feed it.
class ImagePipeline { class ImagePipeline {
public: public:
struct Params { struct Params {
@ -34,6 +34,7 @@ public:
std::string tower; std::string tower;
double jxl_distance = 2.0; // 0 = lossless double jxl_distance = 2.0; // 0 = lossless
int jxl_effort = 3; int jxl_effort = 3;
int rotation_deg = 0; // clockwise; 0/90/180/270
bool demo = false; // copy demo_image instead of encoding bool demo = false; // copy demo_image instead of encoding
std::string demo_image = "test_smoke.jxl"; std::string demo_image = "test_smoke.jxl";
bool display = false; // show an OpenCV preview window bool display = false; // show an OpenCV preview window

View File

@ -107,6 +107,7 @@ struct CameraConfigView {
bool gain_auto = true; double gain_max_db = 0.0; bool gain_auto = true; double gain_max_db = 0.0;
bool white_balance_auto = true; bool white_balance_auto = true;
double jxl_distance = 0.0; int jxl_effort = 0; double jxl_distance = 0.0; int jxl_effort = 0;
int rotation_deg = 0; // clockwise; 0 = none
std::string output_dir; std::string output_dir;
}; };

View File

@ -10,6 +10,21 @@
namespace fs = std::filesystem; namespace fs = std::filesystem;
namespace fgc { namespace fgc {
namespace {
// Maps a clockwise rotation in degrees to the cv::rotate code. Returns false
// when there is nothing to do (0 deg, or an unexpected value - ConfigLoader
// rejects anything but 0/90/180/270 before it reaches here).
bool rotateCode(int deg, int& code) {
switch (deg) {
case 90: code = cv::ROTATE_90_CLOCKWISE; return true;
case 180: code = cv::ROTATE_180; return true;
case 270: code = cv::ROTATE_90_COUNTERCLOCKWISE; return true;
default: return false;
}
}
} // namespace
ImagePipeline::ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation, ImagePipeline::ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation,
Params params) Params params)
@ -64,8 +79,12 @@ void ImagePipeline::process(const Frame& frame) {
const int type = frame.channels == 1 ? CV_8UC1 : CV_8UC3; const int type = frame.channels == 1 ? CV_8UC1 : CV_8UC3;
cv::Mat src(frame.height, frame.width, type, const_cast<uint8_t*>(frame.data.data())); cv::Mat src(frame.height, frame.width, type, const_cast<uint8_t*>(frame.data.data()));
cv::Mat img; cv::Mat rotated;
cv::rotate(src, img, cv::ROTATE_90_COUNTERCLOCKWISE); int code = 0;
bool rotate = rotateCode(params_.rotation_deg, code);
if (rotate) cv::rotate(src, rotated, code);
// src only wraps frame.data, so an unrotated frame costs no copy at all.
const cv::Mat& img = rotate ? rotated : src;
if (params_.display) { if (params_.display) {
cv::namedWindow("Display Image", cv::WINDOW_NORMAL); cv::namedWindow("Display Image", cv::WINDOW_NORMAL);

View File

@ -563,6 +563,7 @@ struct Application::Impl {
cc.white_balance_auto = cfg.camera.white_balance_auto; cc.white_balance_auto = cfg.camera.white_balance_auto;
cc.jxl_distance = cfg.camera.jxl_distance; cc.jxl_distance = cfg.camera.jxl_distance;
cc.jxl_effort = cfg.camera.jxl_effort; cc.jxl_effort = cfg.camera.jxl_effort;
cc.rotation_deg = cfg.camera.rotation_deg;
cc.output_dir = cfg.paths.output_dir; cc.output_dir = cfg.paths.output_dir;
} }
// Live per-camera identity/telemetry/stats. // Live per-camera identity/telemetry/stats.
@ -1509,6 +1510,7 @@ struct Application::Impl {
pp.demo = opts.demo; pp.demo = opts.demo;
pp.jxl_distance = cfg.camera.jxl_distance; pp.jxl_distance = cfg.camera.jxl_distance;
pp.jxl_effort = cfg.camera.jxl_effort; pp.jxl_effort = cfg.camera.jxl_effort;
pp.rotation_deg = cfg.camera.rotation_deg;
// Orientation supplier: convert the per-axis encoder counts to degrees. // Orientation supplier: convert the per-axis encoder counts to degrees.
pipeline = std::make_unique<ImagePipeline>( pipeline = std::make_unique<ImagePipeline>(
*channel, *channel,

View File

@ -181,6 +181,7 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.camera.white_balance_auto = getBool(kv, "Camera.white_balance_auto", cfg.camera.white_balance_auto); cfg.camera.white_balance_auto = getBool(kv, "Camera.white_balance_auto", cfg.camera.white_balance_auto);
cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance); cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance);
cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort); cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort);
cfg.camera.rotation_deg = getInt(kv, "Camera.rotation_deg", cfg.camera.rotation_deg);
if (cfg.camera.binning < 1) if (cfg.camera.binning < 1)
throw std::runtime_error("Camera.binning must be >= 1"); throw std::runtime_error("Camera.binning must be >= 1");
if (cfg.camera.throughput_mbytes <= 0) if (cfg.camera.throughput_mbytes <= 0)
@ -191,6 +192,9 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)"); throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)");
if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9) if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9)
throw std::runtime_error("Camera.jxl_effort must be in 1..9"); throw std::runtime_error("Camera.jxl_effort must be in 1..9");
if (cfg.camera.rotation_deg != 0 && cfg.camera.rotation_deg != 90 &&
cfg.camera.rotation_deg != 180 && cfg.camera.rotation_deg != 270)
throw std::runtime_error("Camera.rotation_deg must be 0, 90, 180 or 270");
// [Capture] acquisition mode + quality gate. // [Capture] acquisition mode + quality gate.
cfg.capture.mode = get(kv, "Capture.mode", cfg.capture.mode); cfg.capture.mode = get(kv, "Capture.mode", cfg.capture.mode);

View File

@ -324,6 +324,9 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
rows.push_back(row("white bal", text(cf.white_balance_auto ? "auto (continuous)" : "manual"))); rows.push_back(row("white bal", text(cf.white_balance_auto ? "auto (continuous)" : "manual")));
rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) + rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) +
" effort " + std::to_string(cf.jxl_effort)))); " effort " + std::to_string(cf.jxl_effort))));
rows.push_back(row("rotation", text(cf.rotation_deg == 0
? std::string("none")
: std::to_string(cf.rotation_deg) + " deg CW")));
// --- Last capture --- // --- Last capture ---
if (c.has_last) { if (c.has_last) {

View File

@ -143,6 +143,7 @@ TEST_CASE("Camera imaging + encoding config: defaults and overrides") {
CHECK(d.camera.white_balance_auto == true); CHECK(d.camera.white_balance_auto == true);
CHECK(d.camera.jxl_distance == doctest::Approx(0.8)); CHECK(d.camera.jxl_distance == doctest::Approx(0.8));
CHECK(d.camera.jxl_effort == 4); CHECK(d.camera.jxl_effort == 4);
CHECK(d.camera.rotation_deg == 0);
AppConfig c = ConfigLoader::fromMap({ AppConfig c = ConfigLoader::fromMap({
{"Camera.binning", "1"}, {"Camera.binning", "1"},
@ -158,6 +159,7 @@ TEST_CASE("Camera imaging + encoding config: defaults and overrides") {
{"Camera.white_balance_auto", "false"}, {"Camera.white_balance_auto", "false"},
{"Camera.jxl_distance", "0"}, {"Camera.jxl_distance", "0"},
{"Camera.jxl_effort", "6"}, {"Camera.jxl_effort", "6"},
{"Camera.rotation_deg", "180"},
}); });
CHECK(c.camera.binning == 1); CHECK(c.camera.binning == 1);
CHECK(c.camera.pixel_format == "BayerRG8"); CHECK(c.camera.pixel_format == "BayerRG8");
@ -172,6 +174,7 @@ TEST_CASE("Camera imaging + encoding config: defaults and overrides") {
CHECK(c.camera.white_balance_auto == false); CHECK(c.camera.white_balance_auto == false);
CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless
CHECK(c.camera.jxl_effort == 6); CHECK(c.camera.jxl_effort == 6);
CHECK(c.camera.rotation_deg == 180);
} }
TEST_CASE("Camera config validates ranges") { TEST_CASE("Camera config validates ranges") {
@ -181,6 +184,8 @@ TEST_CASE("Camera config validates ranges") {
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_distance", "-1"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_distance", "-1"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "0"}})); // effort 1..9 CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "0"}})); // effort 1..9
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "10"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "10"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rotation_deg", "45"}})); // 0/90/180/270 only
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rotation_deg", "-90"}}));
} }
TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") { TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") {