Image rotation is now configurable via Camera.rotation_deg
This commit is contained in:
parent
5053b82811
commit
02d3f573cf
|
|
@ -56,6 +56,10 @@ white_balance_auto = true
|
|||
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9.
|
||||
jxl_distance = 0.8
|
||||
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]
|
||||
; How a frame is acquired at each waypoint, and the quality bar it must clear.
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ corrupted.
|
|||
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.
|
||||
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).
|
||||
|
||||
## Capture state machine
|
||||
|
|
|
|||
|
|
@ -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` | `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` | `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` | `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 |
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ Surfaced read-only in the TUI's `i` view (IMU CONFIG section); the legacy "scena
|
|||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ struct CameraConfig {
|
|||
// JPEG XL encoding (consumed by ImagePipeline). Near-lossless default.
|
||||
double jxl_distance = 0.8; // 0 = lossless; higher = lossier
|
||||
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
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ struct Orientation {
|
|||
float pitch_deg = 0.f;
|
||||
};
|
||||
|
||||
// Consumes captured frames and turns them into stored artifacts: rotate 90 deg
|
||||
// CCW, encode to JPEG XL, write to <output_dir>/<label>/<timestamp>.jxl, and
|
||||
// publish a CamEvent. Runs a background worker so encoding never blocks
|
||||
// acquisition. Split out of the old VimbaHandler::SaveImage so any camera
|
||||
// source (real or mock) can feed it.
|
||||
// Consumes captured frames and turns them into stored artifacts: rotate by
|
||||
// Camera.rotation_deg, encode to JPEG XL, write to
|
||||
// <output_dir>/<label>/<timestamp>.jxl, and publish a CamEvent. Runs a
|
||||
// background worker so encoding never blocks acquisition. Split out of the old
|
||||
// VimbaHandler::SaveImage so any camera source (real or mock) can feed it.
|
||||
class ImagePipeline {
|
||||
public:
|
||||
struct Params {
|
||||
|
|
@ -34,6 +34,7 @@ public:
|
|||
std::string tower;
|
||||
double jxl_distance = 2.0; // 0 = lossless
|
||||
int jxl_effort = 3;
|
||||
int rotation_deg = 0; // clockwise; 0/90/180/270
|
||||
bool demo = false; // copy demo_image instead of encoding
|
||||
std::string demo_image = "test_smoke.jxl";
|
||||
bool display = false; // show an OpenCV preview window
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ struct CameraConfigView {
|
|||
bool gain_auto = true; double gain_max_db = 0.0;
|
||||
bool white_balance_auto = true;
|
||||
double jxl_distance = 0.0; int jxl_effort = 0;
|
||||
int rotation_deg = 0; // clockwise; 0 = none
|
||||
std::string output_dir;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,21 @@
|
|||
namespace fs = std::filesystem;
|
||||
|
||||
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,
|
||||
Params params)
|
||||
|
|
@ -64,8 +79,12 @@ void ImagePipeline::process(const Frame& frame) {
|
|||
|
||||
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 img;
|
||||
cv::rotate(src, img, cv::ROTATE_90_COUNTERCLOCKWISE);
|
||||
cv::Mat rotated;
|
||||
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) {
|
||||
cv::namedWindow("Display Image", cv::WINDOW_NORMAL);
|
||||
|
|
|
|||
|
|
@ -563,6 +563,7 @@ struct Application::Impl {
|
|||
cc.white_balance_auto = cfg.camera.white_balance_auto;
|
||||
cc.jxl_distance = cfg.camera.jxl_distance;
|
||||
cc.jxl_effort = cfg.camera.jxl_effort;
|
||||
cc.rotation_deg = cfg.camera.rotation_deg;
|
||||
cc.output_dir = cfg.paths.output_dir;
|
||||
}
|
||||
// Live per-camera identity/telemetry/stats.
|
||||
|
|
@ -1509,6 +1510,7 @@ struct Application::Impl {
|
|||
pp.demo = opts.demo;
|
||||
pp.jxl_distance = cfg.camera.jxl_distance;
|
||||
pp.jxl_effort = cfg.camera.jxl_effort;
|
||||
pp.rotation_deg = cfg.camera.rotation_deg;
|
||||
// Orientation supplier: convert the per-axis encoder counts to degrees.
|
||||
pipeline = std::make_unique<ImagePipeline>(
|
||||
*channel,
|
||||
|
|
|
|||
|
|
@ -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.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.rotation_deg = getInt(kv, "Camera.rotation_deg", cfg.camera.rotation_deg);
|
||||
if (cfg.camera.binning < 1)
|
||||
throw std::runtime_error("Camera.binning must be >= 1");
|
||||
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)");
|
||||
if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 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.
|
||||
cfg.capture.mode = get(kv, "Capture.mode", cfg.capture.mode);
|
||||
|
|
|
|||
|
|
@ -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("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) +
|
||||
" 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 ---
|
||||
if (c.has_last) {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ TEST_CASE("Camera imaging + encoding config: defaults and overrides") {
|
|||
CHECK(d.camera.white_balance_auto == true);
|
||||
CHECK(d.camera.jxl_distance == doctest::Approx(0.8));
|
||||
CHECK(d.camera.jxl_effort == 4);
|
||||
CHECK(d.camera.rotation_deg == 0);
|
||||
|
||||
AppConfig c = ConfigLoader::fromMap({
|
||||
{"Camera.binning", "1"},
|
||||
|
|
@ -158,6 +159,7 @@ TEST_CASE("Camera imaging + encoding config: defaults and overrides") {
|
|||
{"Camera.white_balance_auto", "false"},
|
||||
{"Camera.jxl_distance", "0"},
|
||||
{"Camera.jxl_effort", "6"},
|
||||
{"Camera.rotation_deg", "180"},
|
||||
});
|
||||
CHECK(c.camera.binning == 1);
|
||||
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.jxl_distance == doctest::Approx(0.0)); // lossless
|
||||
CHECK(c.camera.jxl_effort == 6);
|
||||
CHECK(c.camera.rotation_deg == 180);
|
||||
}
|
||||
|
||||
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_effort", "0"}})); // effort 1..9
|
||||
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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue