From 2bedc978f40f7afbd5d0f5b74ac130eeb350ff85 Mon Sep 17 00:00:00 2001 From: Kletternaut Date: Thu, 14 May 2026 07:57:59 +0200 Subject: [PATCH 1/2] rpicam-vid: add Unix Domain Socket runtime control Adds a Unix Domain Socket server to rpicam-vid that accepts text commands at runtime, allowing camera parameters to be changed without restarting the process. The socket path is derived from the camera index, e.g. --camera 1 uses /tmp/rpicam-vid1.sock (default: /tmp/rpicam-vid0.sock). Supported commands include: brightness, ev, contrast, saturation, gain (0 = AE auto, >0 = manual), sharpness, roi, awb, awbgains, metering, exposure, denoise, hdr, shutter, camera. Two client tools are provided: - utils/rpicam-vid-tui: curses-based TUI with sliders and combos - utils/rpicam_vid_gui/: Qt GUI with sliders, combos and reset button gain:0 restores AnalogueGainModeAuto so connecting the GUI at default settings does not override AE-controlled gain or an active --roi. Signed-off-by: Kletternaut --- README.md | 62 +++ apps/control_socket.hpp | 543 ++++++++++++++++++++ apps/rpicam_vid.cpp | 19 +- core/rpicam_app.cpp | 21 + core/rpicam_app.hpp | 2 + meson.build | 1 + meson_options.txt | 5 + utils/README.md | 151 ++++++ utils/meson.build | 3 + utils/rpicam-vid-tui | 560 ++++++++++++++++++++ utils/rpicam_vid_gui/main.cpp | 855 +++++++++++++++++++++++++++++++ utils/rpicam_vid_gui/meson.build | 30 ++ 12 files changed, 2251 insertions(+), 1 deletion(-) create mode 100644 apps/control_socket.hpp create mode 100644 utils/README.md create mode 100755 utils/rpicam-vid-tui create mode 100644 utils/rpicam_vid_gui/main.cpp create mode 100644 utils/rpicam_vid_gui/meson.build diff --git a/README.md b/README.md index d1c1d9b9..0fdf2efc 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,68 @@ This is a small suite of libcamera-based applications to drive the cameras on a >[!WARNING] >These applications and libraries have been renamed from `libcamera-*` to `rpicam-*`. Symbolic links to allow users to keep using the old application names have now been removed. +Runtime Control (feature/runtime-control-socket) +------------------------------------------------- + +`rpicam-vid` listens on a Unix domain socket and accepts plain-text commands at runtime — no restart required. The socket path is derived from the `--camera` index: + +| `--camera` | Socket | +|---|---| +| `0` (default) | `/tmp/rpicam-vid0.sock` | +| `1` | `/tmp/rpicam-vid1.sock` | + +### Supported commands + +| Command | Example | Effect | +|---|---|---| +| `brightness:` | `brightness:-0.2` | Brightness −1.0 … +1.0 | +| `ev:` | `ev:1.5` | EV compensation −3.0 … +3.0 | +| `contrast:` | `contrast:1.2` | Contrast 0.0 … 3.0 | +| `saturation:` | `saturation:0.8` | Saturation 0.0 … 3.0 | +| `sharpness:` | `sharpness:1.5` | Sharpness 0.0 … 16.0 | +| `gain:` | `gain:4.0` | Analogue gain 1.0 … 16.0 | +| `awb:` | `awb:daylight` | AWB mode: auto \| incandescent \| tungsten \| fluorescent \| indoor \| daylight \| cloudy | +| `awbgains:,` | `awbgains:1.8,1.3` | Manual colour gains (disables auto AWB) | +| `roi:,,,` | `roi:0.25,0.25,0.5,0.5` | Digital zoom / ROI, relative 0.0–1.0 | +| `metering:` | `metering:spot` | Metering: centre \| spot \| average \| custom | +| `exposure:` | `exposure:sport` | Exposure mode: normal \| sport | +| `denoise:` | `denoise:cdn_fast` | Denoise: auto \| off \| cdn_off \| cdn_fast \| cdn_hq | +| `hdr:` | `hdr:single-exp` | HDR: off \| auto \| sensor \| single-exp | + +Send commands via `echo` or `nc`: + +```sh +echo "brightness:-0.3" | nc -U /tmp/rpicam-vid0.sock +echo "awb:daylight" | nc -U /tmp/rpicam-vid0.sock +echo "roi:0.25,0.25,0.5,0.5" | nc -U /tmp/rpicam-vid0.sock +``` + +### Qt GUI (`rpicam-vid-gui`) + +A graphical control panel built with Qt Widgets. Includes sliders, dropdowns, camera selector (0/1) and keyboard shortcuts (`R` reset, `Q` quit, `C` switch camera). Build via Meson: + +```sh +meson configure build -Denable_control_gui=enabled +ninja -C build +``` + +Requires Qt6 or Qt5 Widgets + Network. Connects automatically to the socket on startup and reconnects if `rpicam-vid` is restarted. + +### TUI (`rpicam-vid-tui`) + +A terminal-based ASCII slider interface, requires only Python 3 (stdlib `curses`): + +```sh +./utils/rpicam-vid-tui # camera 0 +./utils/rpicam-vid-tui 1 # camera 1 +``` + +Navigate with `↑`/`↓`, adjust values with `←`/`→`, `C` to switch camera, `R` to reset all, `Q` to quit. + +### Per-camera state persistence + +Both tools maintain independent settings per camera index and persist them to `/tmp/rpicam-vid{N}.state`. Switching cameras restores that camera's last values. When `rpicam-vid` starts or ends a session it removes any stale state file, so both tools reset to hardware defaults on reconnect. + Build ----- For usage and build instructions, see the official Raspberry Pi documentation pages [here.](https://www.raspberrypi.com/documentation/computers/camera_software.html#building-libcamera-and-rpicam-apps) diff --git a/apps/control_socket.hpp b/apps/control_socket.hpp new file mode 100644 index 00000000..7977e45d --- /dev/null +++ b/apps/control_socket.hpp @@ -0,0 +1,543 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * Copyright (C) 2026, Kletternaut + * + * control_socket.hpp - Unix Domain Socket for runtime camera control in rpicam-vid. + * + * Listens on /tmp/rpicam-vid.sock for newline-terminated commands of the form + * key:value + * and translates them into libcamera ControlList entries that can be passed to + * RPiCamApp::SetControls(). + * + * Supported commands: + * brightness: -1.0 … 1.0 + * contrast: 0.0 … 32.0 + * saturation: 0.0 … 32.0 + * ev: exposure compensation in stops + * shutter: exposure time in microseconds (>0); shutter:0 restores AE + * framerate: target frame rate in fps (>0); framerate:0 restores AE-managed rate + * awb: auto|incandescent|tungsten|fluorescent|indoor|daylight|cloudy|custom + * awbgains:, manual colour gains, e.g. "awbgains:1.5,1.2" + * roi:,,, relative values 0.0-1.0 relative to sensor area + * hdr: off|auto|sensor|single-exp + * + * gain: analogue gain (> 0.0, e.g. 1.0) + * metering: centre|spot|average|custom + * exposure: normal|sport + * sharpness: 0.0 … 15.99 (1.0 = normal) + * denoise: auto|off|cdn_off|cdn_fast|cdn_hq + * af: manual|auto|continuous|trigger|cancel + * lens: manual lens position (0.0 = infinity); implies af:manual + * + * Usage example (shell): + * echo "brightness:0.3" | nc -U /tmp/rpicam-vid.sock + * echo -e "contrast:1.5\nev:-1.0" | nc -U /tmp/rpicam-vid.sock + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "core/logging.hpp" + +static constexpr const char *CONTROL_SOCKET_DEFAULT_PATH = "/tmp/rpicam-vid.sock"; + +class ControlSocket +{ +public: + explicit ControlSocket(const std::string &path = CONTROL_SOCKET_DEFAULT_PATH) : socket_path_(path) + { + server_fd_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (server_fd_ < 0) + { + LOG_ERROR("ControlSocket: socket() failed: " << strerror(errno)); + return; + } + + // Remove a stale socket file from a previous run. + ::unlink(socket_path_.c_str()); + + struct sockaddr_un addr {}; + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, socket_path_.c_str(), sizeof(addr.sun_path) - 1); + + if (::bind(server_fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) + { + LOG_ERROR("ControlSocket: bind() failed: " << strerror(errno)); + ::close(server_fd_); + server_fd_ = -1; + return; + } + + if (::listen(server_fd_, 4) < 0) + { + LOG_ERROR("ControlSocket: listen() failed: " << strerror(errno)); + ::close(server_fd_); + server_fd_ = -1; + return; + } + + LOG(1, "ControlSocket: listening on " << socket_path_); + + // Remove any leftover state file from a previous GUI/TUI session so + // every new rpicam-vid run starts with hardware defaults. + ::unlink(statePath().c_str()); + } + + ~ControlSocket() + { + if (client_fd_ >= 0) + ::close(client_fd_); + if (server_fd_ >= 0) + { + ::close(server_fd_); + ::unlink(socket_path_.c_str()); + // Remove the companion state file so the GUI/TUI resets to + // defaults when the next rpicam-vid session starts. + ::unlink(statePath().c_str()); + } + } + + // Non-copyable, non-movable. + ControlSocket(const ControlSocket &) = delete; + ControlSocket &operator=(const ControlSocket &) = delete; + + bool IsValid() const + { + return server_fd_ >= 0; + } + + // Accept a pending connection (non-blocking). A new connection replaces + // an existing one. Call once per iteration of the main loop. + void AcceptConnections() + { + if (server_fd_ < 0) + return; + + int fd = ::accept4(server_fd_, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd >= 0) + { + if (client_fd_ >= 0) + ::close(client_fd_); + client_fd_ = fd; + recv_buf_.clear(); + LOG(1, "ControlSocket: client connected"); + } + } + + // Drain the receive buffer of any pending data and return a ControlList + // built from all complete commands. Returns an empty ControlList when + // nothing has arrived. sensor_area is required for ROI → pixel conversion. + // is_pisp selects ScalerCrops (PISP/Pi5) vs. ScalerCrop (VC4/Pi4). + libcamera::ControlList ReadControls(const libcamera::Rectangle &sensor_area, bool is_pisp = false) + { + libcamera::ControlList cl(libcamera::controls::controls); + + if (client_fd_ < 0) + return cl; + + // Read all available data without blocking. + char buf[256]; + ssize_t n; + while ((n = ::recv(client_fd_, buf, sizeof(buf) - 1, 0)) > 0) + { + buf[n] = '\0'; + recv_buf_ += buf; + } + + bool peer_closed = (n == 0); + if (peer_closed) + { + // Orderly shutdown by the peer — process remaining buffer first. + ::close(client_fd_); + client_fd_ = -1; + } + // n < 0 with EAGAIN/EWOULDBLOCK is expected on a non-blocking socket. + + // Process every complete (newline-terminated) command. + std::string::size_type pos; + while ((pos = recv_buf_.find('\n')) != std::string::npos) + { + std::string line = recv_buf_.substr(0, pos); + recv_buf_.erase(0, pos + 1); + + // Strip optional carriage return (e.g. from Windows clients). + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + if (!line.empty()) + parseCommand(line, sensor_area, is_pisp, cl); + } + + return cl; + } + +private: + int server_fd_ = -1; + int client_fd_ = -1; + std::string socket_path_; + + // Derive the state-file path from the socket path by replacing the + // ".sock" suffix with ".state" (e.g. /tmp/rpicam-vid0.state). + std::string statePath() const + { + const std::string suffix = ".sock"; + if (socket_path_.size() > suffix.size() && + socket_path_.compare(socket_path_.size() - suffix.size(), suffix.size(), suffix) == 0) + { + return socket_path_.substr(0, socket_path_.size() - suffix.size()) + ".state"; + } + return socket_path_ + ".state"; + } + std::string recv_buf_; + + static void parseCommand(const std::string &line, const libcamera::Rectangle &sensor_area, bool is_pisp, + libcamera::ControlList &cl) + { + const auto colon = line.find(':'); + if (colon == std::string::npos) + { + LOG_ERROR("ControlSocket: malformed command (missing ':'): " << line); + return; + } + + const std::string key = line.substr(0, colon); + const std::string val = line.substr(colon + 1); + + // Locale-independent float parser: force '.' as the decimal separator. + const std::locale classic = std::locale::classic(); + auto parseFloat = [&](const std::string &s) -> float + { + std::istringstream ss(s); + ss.imbue(classic); + float v; + ss >> v; + if (ss.fail()) + throw std::invalid_argument("invalid float: " + s); + return v; + }; + // Parse a comma-separated list of floats into a vector (locale-independent). + auto parseFloatList = [&](const std::string &s, std::vector &out) -> bool + { + std::istringstream ss(s); + ss.imbue(classic); + float v; + while (ss >> v) + { + out.push_back(v); + char comma; + ss >> comma; // consume the comma separator (or EOF) + } + return !out.empty(); + }; + + try + { + if (key == "brightness") + { + const float v = std::clamp(parseFloat(val), -1.0f, 1.0f); + cl.set(libcamera::controls::Brightness, v); + LOG(1, "ControlSocket: Brightness=" << v); + } + else if (key == "contrast") + { + const float v = std::clamp(parseFloat(val), 0.0f, 32.0f); + cl.set(libcamera::controls::Contrast, v); + LOG(1, "ControlSocket: Contrast=" << v); + } + else if (key == "saturation") + { + const float v = std::clamp(parseFloat(val), 0.0f, 32.0f); + cl.set(libcamera::controls::Saturation, v); + LOG(1, "ControlSocket: Saturation=" << v); + } + else if (key == "ev") + { + const float v = parseFloat(val); + cl.set(libcamera::controls::AeEnable, true); + cl.set(libcamera::controls::ExposureValue, v); + LOG(1, "ControlSocket: ExposureValue=" << v); + } + else if (key == "shutter") + { + const int us = std::stoi(val); + if (us == 0) + { + cl.set(libcamera::controls::AeEnable, true); + LOG(1, "ControlSocket: ExposureTime=auto"); + } + else if (us > 0) + { + cl.set(libcamera::controls::AeEnable, false); + cl.set(libcamera::controls::ExposureTime, us); + LOG(1, "ControlSocket: ExposureTime=" << us << " us"); + } + else + LOG_ERROR("ControlSocket: shutter must be >= 0"); + } + else if (key == "framerate") + { + const float fps = parseFloat(val); + if (fps == 0.0f) + { + // Restore AE-managed framerate with a wide open range + cl.set(libcamera::controls::FrameDurationLimits, + libcamera::Span({ 100LL, 1000000LL })); + LOG(1, "ControlSocket: FrameDurationLimits=auto"); + } + else if (fps > 0.0f) + { + const int64_t dur = static_cast(1e6f / fps); + cl.set(libcamera::controls::FrameDurationLimits, libcamera::Span({ dur, dur })); + LOG(1, "ControlSocket: FrameDurationLimits=" << dur << " us (" << fps << " fps)"); + } + else + LOG_ERROR("ControlSocket: framerate must be >= 0"); + } + else if (key == "awb") + { + // clang-format off + static const std::map awb_table = + { + { "auto", libcamera::controls::AwbAuto }, + { "normal", libcamera::controls::AwbAuto }, + { "incandescent", libcamera::controls::AwbIncandescent }, + { "tungsten", libcamera::controls::AwbTungsten }, + { "fluorescent", libcamera::controls::AwbFluorescent }, + { "indoor", libcamera::controls::AwbIndoor }, + { "daylight", libcamera::controls::AwbDaylight }, + { "cloudy", libcamera::controls::AwbCloudy }, + { "custom", libcamera::controls::AwbCustom }, + }; + // clang-format on + const auto it = awb_table.find(val); + if (it != awb_table.end()) + { + cl.set(libcamera::controls::AwbEnable, true); + cl.set(libcamera::controls::AwbMode, it->second); + LOG(1, "ControlSocket: AwbMode=" << val); + } + else + LOG_ERROR("ControlSocket: unknown AWB mode: " << val); + } + else if (key == "awbgains") + { + std::vector gains; + if (parseFloatList(val, gains) && gains.size() == 2 && gains[0] > 0.0f && gains[1] > 0.0f) + { + cl.set(libcamera::controls::AwbEnable, false); + cl.set(libcamera::controls::ColourGains, libcamera::Span({ gains[0], gains[1] })); + LOG(1, "ControlSocket: ColourGains=" << gains[0] << "," << gains[1]); + } + else + LOG_ERROR("ControlSocket: invalid awbgains (expect r,b with r>0 b>0): " << val); + } + else if (key == "roi") + { + std::vector rv; + if (parseFloatList(val, rv) && rv.size() == 4) + { + float x = std::clamp(rv[0], 0.0f, 1.0f); + float y = std::clamp(rv[1], 0.0f, 1.0f); + float w = std::clamp(rv[2], 0.0f, 1.0f - x); + float h = std::clamp(rv[3], 0.0f, 1.0f - y); + + libcamera::Rectangle crop(sensor_area.x + static_cast(x * sensor_area.width), + sensor_area.y + static_cast(y * sensor_area.height), + static_cast(w * sensor_area.width), + static_cast(h * sensor_area.height)); + + // ScalerCrop → VC4 (Pi 1–4); ScalerCrops → PISP (Pi 5) + if (is_pisp) + { + const std::vector crops = { crop }; + cl.set(libcamera::controls::rpi::ScalerCrops, + libcamera::Span(crops.data(), crops.size())); + } + else + { + cl.set(libcamera::controls::ScalerCrop, crop); + } + LOG(1, "ControlSocket: ScalerCrop(s)=" << crop.toString()); + } + else + LOG_ERROR("ControlSocket: invalid roi (expect x,y,w,h 0.0-1.0): " << val); + } + else if (key == "hdr") + { + // clang-format off + static const std::map hdr_table = + { + { "off", libcamera::controls::HdrModeOff }, + { "auto", libcamera::controls::HdrModeSingleExposure }, + { "sensor", libcamera::controls::HdrModeSingleExposure }, + { "single-exp", libcamera::controls::HdrModeSingleExposure }, + }; + // clang-format on + const auto it = hdr_table.find(val); + if (it != hdr_table.end()) + { + cl.set(libcamera::controls::HdrMode, it->second); + LOG(1, "ControlSocket: HdrMode=" << val); + } + else + LOG_ERROR("ControlSocket: unknown HDR mode: " << val); + } + else if (key == "gain") + { + const float v = parseFloat(val); + if (v == 0.0f) + { + cl.set(libcamera::controls::AnalogueGainMode, libcamera::controls::AnalogueGainModeAuto); + LOG(1, "ControlSocket: AnalogueGain=auto"); + } + else if (v > 0.0f) + { + cl.set(libcamera::controls::AnalogueGainMode, libcamera::controls::AnalogueGainModeManual); + cl.set(libcamera::controls::AnalogueGain, v); + LOG(1, "ControlSocket: AnalogueGain=" << v); + } + else + LOG_ERROR("ControlSocket: gain must be >= 0"); + } + else if (key == "metering") + { + // clang-format off + static const std::map metering_table = + { + { "centre", libcamera::controls::MeteringCentreWeighted }, + { "center", libcamera::controls::MeteringCentreWeighted }, + { "spot", libcamera::controls::MeteringSpot }, + { "average", libcamera::controls::MeteringMatrix }, + { "matrix", libcamera::controls::MeteringMatrix }, + { "custom", libcamera::controls::MeteringCustom }, + }; + // clang-format on + const auto it = metering_table.find(val); + if (it != metering_table.end()) + { + cl.set(libcamera::controls::AeMeteringMode, it->second); + LOG(1, "ControlSocket: AeMeteringMode=" << val); + } + else + LOG_ERROR("ControlSocket: unknown metering mode: " << val); + } + else if (key == "exposure") + { + // clang-format off + static const std::map exposure_table = + { + { "normal", libcamera::controls::ExposureNormal }, + { "sport", libcamera::controls::ExposureShort }, + { "short", libcamera::controls::ExposureShort }, + { "long", libcamera::controls::ExposureLong }, + { "custom", libcamera::controls::ExposureCustom }, + }; + // clang-format on + const auto it = exposure_table.find(val); + if (it != exposure_table.end()) + { + cl.set(libcamera::controls::AeExposureMode, it->second); + LOG(1, "ControlSocket: AeExposureMode=" << val); + } + else + LOG_ERROR("ControlSocket: unknown exposure mode: " << val); + } + else if (key == "sharpness") + { + const float v = std::clamp(parseFloat(val), 0.0f, 15.99f); + cl.set(libcamera::controls::Sharpness, v); + LOG(1, "ControlSocket: Sharpness=" << v); + } + else if (key == "denoise") + { + // clang-format off + static const std::map denoise_table = + { + { "auto", libcamera::controls::draft::NoiseReductionModeMinimal }, + { "off", libcamera::controls::draft::NoiseReductionModeOff }, + { "cdn_off", libcamera::controls::draft::NoiseReductionModeMinimal }, + { "cdn_fast", libcamera::controls::draft::NoiseReductionModeFast }, + { "cdn_hq", libcamera::controls::draft::NoiseReductionModeHighQuality }, + }; + // clang-format on + const auto it = denoise_table.find(val); + if (it != denoise_table.end()) + { + cl.set(libcamera::controls::draft::NoiseReductionMode, it->second); + LOG(1, "ControlSocket: NoiseReductionMode=" << val); + } + else + LOG_ERROR("ControlSocket: unknown denoise mode: " << val); + } + else if (key == "af") + { + if (val == "trigger") + { + cl.set(libcamera::controls::AfTrigger, libcamera::controls::AfTriggerStart); + LOG(1, "ControlSocket: AfTrigger=start"); + } + else if (val == "cancel") + { + cl.set(libcamera::controls::AfTrigger, libcamera::controls::AfTriggerCancel); + LOG(1, "ControlSocket: AfTrigger=cancel"); + } + else + { + // clang-format off + static const std::map af_table = + { + { "manual", libcamera::controls::AfModeManual }, + { "auto", libcamera::controls::AfModeAuto }, + { "continuous", libcamera::controls::AfModeContinuous }, + }; + // clang-format on + const auto it = af_table.find(val); + if (it != af_table.end()) + { + cl.set(libcamera::controls::AfMode, it->second); + LOG(1, "ControlSocket: AfMode=" << val); + } + else + LOG_ERROR("ControlSocket: unknown af mode: " << val); + } + } + else if (key == "lens") + { + const float v = parseFloat(val); + if (v >= 0.0f) + { + cl.set(libcamera::controls::AfMode, libcamera::controls::AfModeManual); + cl.set(libcamera::controls::LensPosition, v); + LOG(1, "ControlSocket: LensPosition=" << v); + } + else + LOG_ERROR("ControlSocket: lens position must be >= 0"); + } + else + { + LOG_ERROR("ControlSocket: unknown command key: " << key); + } + } + catch (const std::exception &e) + { + LOG_ERROR("ControlSocket: error parsing '" << line << "': " << e.what()); + } + } +}; diff --git a/apps/rpicam_vid.cpp b/apps/rpicam_vid.cpp index abe43970..36cdcb58 100644 --- a/apps/rpicam_vid.cpp +++ b/apps/rpicam_vid.cpp @@ -11,6 +11,7 @@ #include #include +#include "apps/control_socket.hpp" #include "core/rpicam_encoder.hpp" #include "output/output.hpp" @@ -28,7 +29,7 @@ static void default_signal_handler(int signal_number) static int get_key_or_signal(VideoOptions const *options, pollfd p[1]) { int key = 0; - if (signal_received == SIGINT) + if (signal_received == SIGINT || signal_received == SIGTERM) return 'x'; if (options->Get().keypress) { @@ -75,10 +76,18 @@ static void event_loop(RPiCamEncoder &app) app.StartCamera(); auto start_time = std::chrono::high_resolution_clock::now(); + // Runtime control socket: path derived from camera index (e.g. --camera 1 → /tmp/rpicam-vid1.sock). + std::string ctrl_sock_path = "/tmp/rpicam-vid" + std::to_string(options->Get().camera) + ".sock"; + ControlSocket ctrl_socket(ctrl_sock_path); + if (!ctrl_socket.IsValid()) + LOG_ERROR("ControlSocket: failed to initialise – runtime control unavailable"); + const bool ctrl_is_pisp = app.SupportsScalerCrops(); + // Monitoring for keypresses and signals. signal(SIGUSR1, default_signal_handler); signal(SIGUSR2, default_signal_handler); signal(SIGINT, default_signal_handler); + signal(SIGTERM, default_signal_handler); // SIGPIPE gets raised when trying to write to an already closed socket. This can happen, when // you're using TCP to stream to VLC and the user presses the stop button in VLC. Catching the // signal to be able to react on it, otherwise the app terminates. @@ -103,6 +112,14 @@ static void event_loop(RPiCamEncoder &app) if (key == '\n') output->Signal(); + // Check the runtime control socket for pending parameter updates. + ctrl_socket.AcceptConnections(); + { + libcamera::ControlList cl = ctrl_socket.ReadControls(app.GetSensorArea(), ctrl_is_pisp); + if (!cl.empty()) + app.SetControls(cl); + } + LOG(2, "Viewfinder frame " << count); auto now = std::chrono::high_resolution_clock::now(); bool timeout = !options->Get().frames && options->Get().timeout && diff --git a/core/rpicam_app.cpp b/core/rpicam_app.cpp index 9354c9b6..e7ce667f 100644 --- a/core/rpicam_app.cpp +++ b/core/rpicam_app.cpp @@ -993,6 +993,27 @@ void RPiCamApp::SetControls(const ControlList &controls) controls_.set(c.first, c.second); } +libcamera::Rectangle RPiCamApp::GetSensorArea() const +{ + if (!camera_) + return {}; + try + { + return camera_->controls().at(&libcamera::controls::ScalerCrop).max().get(); + } + catch (...) + { + return {}; + } +} + +bool RPiCamApp::SupportsScalerCrops() const +{ + if (!camera_) + return false; + return camera_->controls().count(&libcamera::controls::rpi::ScalerCrops) > 0; +} + StreamInfo RPiCamApp::GetStreamInfo(Stream const *stream) const { StreamConfiguration const &cfg = stream->configuration(); diff --git a/core/rpicam_app.hpp b/core/rpicam_app.hpp index 9b0215ac..3d6ca917 100644 --- a/core/rpicam_app.hpp +++ b/core/rpicam_app.hpp @@ -169,6 +169,8 @@ class RPiCamApp void ShowPreview(CompletedRequestPtr &completed_request, Stream *stream); void SetControls(const ControlList &controls); + Rectangle GetSensorArea() const; + bool SupportsScalerCrops() const; StreamInfo GetStreamInfo(Stream const *stream) const; const ControlList &GetProperties() const { diff --git a/meson.build b/meson.build index acb5be59..b94a86f3 100644 --- a/meson.build +++ b/meson.build @@ -96,5 +96,6 @@ summary({ 'TFLite postprocessing' : enable_tflite, 'Hailo postprocessing' : enable_hailo, 'IMX500 postprocessing' : get_option('enable_imx500'), + 'runtime control GUI' : get_option('enable_control_gui'), }, bool_yn : true, section : 'Build configuration') diff --git a/meson_options.txt b/meson_options.txt index 39745e4b..6a45d103 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -48,3 +48,8 @@ option('disable_rpi_features', type : 'boolean', value : false, description : 'Disable use Raspberry Pi specific extensions in the build') + +option('enable_control_gui', + type : 'feature', + value : 'disabled', + description : 'Build the optional Qt runtime control GUI (rpicam-vid-gui)') diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 00000000..e7e7f0fa --- /dev/null +++ b/utils/README.md @@ -0,0 +1,151 @@ +# rpicam-vid Runtime Control + +This directory contains tools that enable **live parameter control** of a running +`rpicam-vid` instance — no restart required. + +--- + +## How it works + +`rpicam-vid` automatically creates a Unix Domain Socket whose path is derived +from the `--camera` index: + +| `--camera` | Socket path | +|---|---| +| `0` (default) | `/tmp/rpicam-vid0.sock` | +| `1` | `/tmp/rpicam-vid1.sock` | + +Any client can send plain-text commands (newline-terminated) to change camera +parameters on the fly. The socket is non-blocking and polled once per frame, +so it never interferes with the capture loop. + +``` +brightness:0.3 ← key:value, newline-terminated +roi:0.25,0.25,0.5,0.5 +awb:daylight +``` + +Send commands from the shell: + +```bash +echo "brightness:-0.3" | nc -U /tmp/rpicam-vid0.sock +echo "awbgains:1.8,1.3" | nc -U /tmp/rpicam-vid0.sock +printf "ev:1.0\ncontrast:1.2\n" | nc -U /tmp/rpicam-vid0.sock +``` + +--- + +## Multi-camera support + +Two `rpicam-vid` instances can run simultaneously on separate cameras, each +with its own independent control socket: + +```bash +rpicam-vid --camera 0 -o /dev/null & # /tmp/rpicam-vid0.sock +rpicam-vid --camera 1 -o /dev/null & # /tmp/rpicam-vid1.sock +``` + +Both `rpicam-vid-tui` and `rpicam-vid-gui` can switch between cameras at runtime. + +--- + +## Supported commands + +| Command | Value | Range / options | +|---|---|---| +| `brightness:` | float | −1.0 … +1.0 | +| `ev:` | float | −3.0 … +3.0 | +| `contrast:` | float | 0.0 … 3.0 | +| `saturation:` | float | 0.0 … 3.0 | +| `sharpness:` | float | 0.0 … 16.0 | +| `gain:` | float | 1.0 … 16.0 — sets analogue gain mode to manual automatically | +| `awb:` | string | `auto` `incandescent` `tungsten` `fluorescent` `indoor` `daylight` `cloudy` | +| `awbgains:,` | float,float | manual colour gains — disables auto AWB | +| `roi:,,,` | float×4 | relative 0.0–1.0; full frame = `0,0,1,1` | +| `metering:` | string | `centre` `spot` `average` `custom` | +| `exposure:` | string | `normal` `sport` | +| `denoise:` | string | `auto` `off` `cdn_off` `cdn_fast` `cdn_hq` | +| `hdr:` | string | `off` `auto` `sensor` `single-exp` | + +> **AWB note:** `awb:` re-enables auto AWB. `awbgains:,` disables +> auto AWB and applies fixed colour gains. Switching back to any `awb:` mode +> restores automatic white balance. + +> **HDR note:** On imx477 (HQ Camera), only `off` and `single-exp` are +> functional at runtime. `auto` and `sensor` both map to single-exposure HDR. + +--- + +## Tools + +### `rpicam-vid-tui` — Terminal UI + +Interactive terminal UI built with Python 3 stdlib (`curses`) — no additional +dependencies required. + +```bash +./rpicam-vid-tui # camera 0 +./rpicam-vid-tui 1 # camera 1 +``` + +**Controls:** + +| Key | Action | +|---|---| +| `↑` / `↓` | Move between parameters | +| `←` / `→` | Decrease / increase value (switches camera when Camera row selected) | +| `C` | Cycle to next camera | +| `R` | Reset all parameters to defaults | +| `Q` | Quit | + +All sliders and dropdowns mirror the same parameters as the socket commands. +The TUI connects automatically and retries on the next keypress if +`rpicam-vid` is not yet running. + +Settings are persisted to `/tmp/rpicam-vid{N}.state` (JSON) per camera index +and shared with `rpicam-vid-gui`. Switching cameras restores that camera's +last known values. + +--- + +### `rpicam_vid_gui/` — Qt graphical control panel (`rpicam-vid-gui`) + +A Qt Widgets application (Qt6 preferred, Qt5 fallback) with sliders for all continuous parameters and +dropdowns for mode selections. A camera selector switches between camera 0 and +camera 1 at runtime without restarting the tool. Connects automatically to the +socket on startup and reconnects if `rpicam-vid` is restarted. + +**Features:** +- Sliders: brightness, EV, contrast, saturation, gain, sharpness, zoom +- AWB mode dropdown + manual R/B gain sliders (auto-switches to manual on move) +- Metering, exposure mode, denoise, HDR dropdowns +- Camera selector (0 / 1) — per-camera state, no bleeding between cameras +- Reset button restores all defaults +- Window position saved across restarts +- Keyboard shortcuts: `R` reset, `Q` quit, `C` switch camera +- Settings persisted to `/tmp/rpicam-vid{N}.state`, shared with `rpicam-vid-tui` +- Reconnects automatically; re-reads state file on each connect (shows hardware defaults after camera restart) + +**Build** (requires Qt5 Widgets + Network): + +```bash +meson configure build -Denable_control_gui=enabled +ninja -C build +``` + +--- + +## State file lifecycle + +`rpicam-vid` manages the socket and companion state file as a pair: + +| Event | `.sock` | `.state` | +|---|---|---| +| `rpicam-vid` starts | created | deleted (fresh session) | +| `rpicam-vid` stops (SIGINT / SIGTERM) | deleted | deleted | +| Tool connects | — | read by GUI/TUI | +| Tool sends command | — | written atomically | + +Each new `rpicam-vid` session therefore starts from hardware defaults regardless +of what a previous session had set. `SIGKILL` cannot be caught; any stale files +are cleaned up when the next session starts. diff --git a/utils/meson.build b/utils/meson.build index 9e5b885c..f11bfd52 100644 --- a/utils/meson.build +++ b/utils/meson.build @@ -1 +1,4 @@ install_data('camera-bug-report', install_dir : get_option('bindir')) +install_data('rpicam-vid-tui', install_dir : get_option('bindir')) + +subdir('rpicam_vid_gui') diff --git a/utils/rpicam-vid-tui b/utils/rpicam-vid-tui new file mode 100755 index 00000000..91fa0425 --- /dev/null +++ b/utils/rpicam-vid-tui @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-2-Clause +# Copyright (C) 2026, Kletternaut +# +# rpicam-vid TUI runtime controller — ASCII slider interface +# Navigate with arrow keys, press R to reset, Q to quit. + +import curses +import json +import os +import socket +import sys +from pathlib import Path + +CAMERA_SOCKS = [ + "/tmp/rpicam-vid0.sock", # camera 0 + "/tmp/rpicam-vid1.sock", # camera 1 +] + + +def _resolve_sock_path(arg): + """Map CLI argument to socket path. + No arg / '' / '0' → camera 0 (/tmp/rpicam-vid0.sock) + '1' → camera 1 (/tmp/rpicam-vid1.sock) + Anything else treated as literal path. + """ + if arg is None or arg in ("", "0"): + return CAMERA_SOCKS[0] + if arg == "1": + return CAMERA_SOCKS[1] + return arg # literal path passed directly + + +SOCK_PATH = _resolve_sock_path(sys.argv[1] if len(sys.argv) > 1 else None) +SLIDER_W = 22 # chars for the slider bar +LABEL_W = 12 # chars for the label column + +# --------------------------------------------------------------------------- +# Control definitions +# --------------------------------------------------------------------------- + +# Slider: (label, command, int_min, int_max, int_default, int_step, display_fn, send_fn) +# int values are internally scaled integers (avoids float rounding). +# display_fn(int_val) -> str shown to the user +# send_fn(int_val) -> str sent over the socket (None = skip / handled separately) + + +def _pct(v): + return f"{v / 100:+.2f}" + + +def _ev(v): + return f"{v / 10:+.1f}" + + +def _f2(v): + return f"{v / 100:.2f}" + + +def _gain(v): + return f"{v / 10:.1f}x" + + +def _sharp(v): + return f"{v / 10:.1f}" + + +def _zoom(v): + return f"{v / 10:.1f}\u00d7" + + +def _awbg(v): + return f"{v / 100:.2f}" + + +SLIDERS = [ + # label cmd min max default step disp_fn send_fn + ( + "Brightness", + "brightness", + -100, + 100, + 0, + 1, + _pct, + lambda v: f"brightness:{v / 100:.4f}", + ), + ("EV (stops)", "ev", -30, 30, 0, 1, _ev, lambda v: f"ev:{v / 10:.2f}"), + ("Contrast", "contrast", 0, 300, 100, 5, _f2, lambda v: f"contrast:{v / 100:.4f}"), + ( + "Saturation", + "saturation", + 0, + 300, + 100, + 5, + _f2, + lambda v: f"saturation:{v / 100:.4f}", + ), + ("Gain", "gain", 10, 160, 10, 5, _gain, lambda v: f"gain:{v / 10:.2f}"), + ( + "Sharpness", + "sharpness", + 0, + 160, + 10, + 5, + _sharp, + lambda v: f"sharpness:{v / 10:.4f}", + ), + ("Zoom", "zoom", 10, 100, 10, 5, _zoom, None), # handled specially (-> roi) + ( + "AWB Gain R", + "awbgainr", + 10, + 400, + 150, + 10, + _awbg, + None, + ), # handled together with B + ("AWB Gain B", "awbgainb", 10, 400, 120, 10, _awbg, None), +] + +# Combo: (label, command, [options]) +COMBOS = [ + ( + "AWB mode", + "awb", + [ + "auto", + "incandescent", + "tungsten", + "fluorescent", + "indoor", + "daylight", + "cloudy", + "manual", + ], + ), + ("Metering", "metering", ["centre", "spot", "average", "custom"]), + ("Exposure", "exposure", ["normal", "sport"]), + ("Denoise", "denoise", ["auto", "off", "cdn_off", "cdn_fast", "cdn_hq"]), + ("HDR", "hdr", ["off", "auto", "sensor", "single-exp"]), +] + +# Flat list of all rows: ('camera',) | ('slider', index) | ('combo', index) +ROWS = ( + ([("camera", 0)] if len(CAMERA_SOCKS) > 1 else []) + + [("slider", i) for i in range(len(SLIDERS))] + + [("combo", i) for i in range(len(COMBOS))] +) + +# --------------------------------------------------------------------------- +# Socket helper +# --------------------------------------------------------------------------- + + +class Sock: + def __init__(self, path): + self._path = path + self._s = None + + def _connect(self): + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(self._path) + s.setblocking(False) + self._s = s + return True + except OSError: + self._s = None + return False + + def send(self, cmd: str): + if self._s is None: + if not self._connect(): + return + try: + self._s.sendall((cmd + "\n").encode()) + except OSError: + self._s = None + + @property + def connected(self): + return self._s is not None + + +# --------------------------------------------------------------------------- +# Draw helpers +# --------------------------------------------------------------------------- + + +def draw_slider(win, y, x, val, lo, hi, w): + """Draw [====●====] with val in [lo, hi] inside w chars.""" + filled = round((val - lo) / (hi - lo) * (w - 1)) + bar = "=" * filled + "\u25cf" + "=" * (w - 1 - filled) + win.addch(y, x, "[") + win.addstr(y, x + 1, bar) + win.addch(y, x + w + 1, "]") + + +def draw_combo(win, y, x, options, idx, w): + """Draw < option > centred in w chars.""" + label = f" {options[idx]} " + win.addch(y, x, "<") + win.addstr(y, x + 1, label.center(w)) + win.addch(y, x + w + 1, ">") + + +# --------------------------------------------------------------------------- +# Main TUI +# --------------------------------------------------------------------------- + + +def main(stdscr): + curses.curs_set(0) + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_CYAN, -1) # selected row + curses.init_pair(2, curses.COLOR_GREEN, -1) # connected + curses.init_pair(3, curses.COLOR_RED, -1) # disconnected + curses.init_pair(4, curses.COLOR_YELLOW, -1) # label + + stdscr.nodelay(True) + stdscr.keypad(True) + + sock = Sock(SOCK_PATH) + sock._connect() + current_sock_path = [SOCK_PATH] + current_cam_idx = [ + CAMERA_SOCKS.index(SOCK_PATH) if SOCK_PATH in CAMERA_SOCKS else 0 + ] + + # Per-camera state: load persisted values, fall back to defaults. + saved = load_state() + cam_s_vals = [list(saved[i]["s_vals"]) for i in range(len(CAMERA_SOCKS))] + cam_c_idxs = [list(saved[i]["c_idxs"]) for i in range(len(CAMERA_SOCKS))] + + # Active view — always a reference into the current camera's slot. + s_vals = cam_s_vals[current_cam_idx[0]] + c_idxs = cam_c_idxs[current_cam_idx[0]] + cursor = 0 # selected row + + def slider_row(si): + return ROWS.index(("slider", si)) + + def awbgains_cmd(r_val, b_val): + return f"awbgains:{r_val / 100:.3f},{b_val / 100:.3f}" + + def zoom_cmd(z_val): + factor = z_val / 10.0 + w = 1.0 / factor + h = 1.0 / factor + x = (1.0 - w) / 2.0 + y = (1.0 - h) / 2.0 + return f"roi:{x:.4f},{y:.4f},{w:.4f},{h:.4f}" + + def _set_awb_manual(): + """Switch AWB combo to 'manual' without sending an awb: command.""" + awb_ci = 0 # AWB mode is always combo index 0 + opts = COMBOS[awb_ci][2] + manual_idx = opts.index("manual") + c_idxs[awb_ci] = manual_idx + + def send_slider(si, val): + send_fn = SLIDERS[si][7] + if send_fn is not None: + sock.send(send_fn(val)) + elif SLIDERS[si][0] == "Zoom": + sock.send(zoom_cmd(val)) + elif SLIDERS[si][0] == "AWB Gain R": + _set_awb_manual() + sock.send(awbgains_cmd(val, s_vals[8])) + elif SLIDERS[si][0] == "AWB Gain B": + _set_awb_manual() + sock.send(awbgains_cmd(s_vals[7], val)) + + def send_combo(ci, idx): + label, cmd, opts = COMBOS[ci] + sock.send(f"{cmd}:{opts[idx]}") + + def do_reset(): + nonlocal s_vals, c_idxs + s_vals[:] = [s[4] for s in SLIDERS] + c_idxs[:] = [0] * len(COMBOS) + # Keep the per-camera store and state file in sync. + cam_s_vals[current_cam_idx[0]] = list(s_vals) + cam_c_idxs[current_cam_idx[0]] = list(c_idxs) + save_cam_state(current_cam_idx[0], s_vals, c_idxs) + sock.send("brightness:0") + sock.send("ev:0") + sock.send("contrast:1") + sock.send("saturation:1") + sock.send("gain:0") + sock.send("sharpness:1") + sock.send("roi:0,0,1,1") + sock.send("awb:auto") + sock.send("metering:centre") + sock.send("exposure:normal") + sock.send("denoise:auto") + sock.send("hdr:off") + + def resend_full_state(): + """Send the complete current state to the newly connected camera. + + AWB gain sliders are skipped when AWB mode is 'auto': sending + awbgains would conflict with the camera's automatic gain control + and the _set_awb_manual() side effect would corrupt c_idxs[0]. + When AWB is 'manual' the gains are sent and c_idxs[0] is already + correct, so no snapshot is needed. + """ + awb_is_auto = c_idxs[0] == 0 # COMBOS[0] options[0] == "auto" + for si, sv in enumerate(s_vals): + if awb_is_auto and SLIDERS[si][0] in ("AWB Gain R", "AWB Gain B"): + continue + send_slider(si, sv) + for ci, cidx in enumerate(c_idxs): + send_combo(ci, cidx) + + def do_switch_camera(new_ci): + nonlocal s_vals, c_idxs + if new_ci == current_cam_idx[0]: + return + # Save current camera state (memory + file). + cam_s_vals[current_cam_idx[0]] = list(s_vals) + cam_c_idxs[current_cam_idx[0]] = list(c_idxs) + save_cam_state(current_cam_idx[0], s_vals, c_idxs) + # Switch. + current_cam_idx[0] = new_ci + current_sock_path[0] = CAMERA_SOCKS[new_ci] + # Restore target camera state into the active view. + s_vals[:] = cam_s_vals[new_ci] + c_idxs[:] = cam_c_idxs[new_ci] + # Reconnect and resync hardware. + sock._s = None + sock._path = current_sock_path[0] + if sock._connect(): + resend_full_state() + + # State loaded into UI; do NOT resend to camera on startup — + # the camera is already running with its current settings. + + while True: + height, width = stdscr.getmaxyx() + stdscr.erase() + + try: + # --- Header --- + title = "rpicam-vid Runtime Control" + stdscr.addstr(0, 2, title, curses.A_BOLD) + + base_y = 2 + CTRL_W = SLIDER_W + 2 # [bar] width incl. brackets + + for row_i, (kind, idx) in enumerate(ROWS): + y = base_y + row_i + if y >= height - 1: + break + sel = row_i == cursor + attr = curses.color_pair(1) | curses.A_BOLD if sel else curses.A_NORMAL + prefix = "\u25b6 " if sel else " " + + if kind == "camera": + cam_idx = ( + CAMERA_SOCKS.index(current_sock_path[0]) + if current_sock_path[0] in CAMERA_SOCKS + else 0 + ) + sock_short = f"[{current_sock_path[0]:<{SLIDER_W - 2}}]" + stdscr.addstr(y, 0, prefix, attr) + stdscr.addstr( + y, + 2, + f"{'Camera':<{LABEL_W}}", + curses.color_pair(4) if sel else curses.A_NORMAL, + ) + stdscr.addstr(y, 2 + LABEL_W, sock_short, attr) + stdscr.addstr(y, 2 + LABEL_W + CTRL_W + 1, " cam ", attr) + stdscr.addstr(str(cam_idx), curses.color_pair(2)) + elif kind == "slider": + lo, hi = SLIDERS[idx][2], SLIDERS[idx][3] + val = s_vals[idx] + label = SLIDERS[idx][0] + disp = SLIDERS[idx][6](val) + stdscr.addstr(y, 0, prefix, attr) + stdscr.addstr( + y, + 2, + f"{label:<{LABEL_W}}", + curses.color_pair(4) if sel else curses.A_NORMAL, + ) + draw_slider(stdscr, y, 2 + LABEL_W, val, lo, hi, SLIDER_W) + stdscr.addstr(y, 2 + LABEL_W + CTRL_W + 1, f" {disp}", attr) + else: + label, cmd, opts = COMBOS[idx] + stdscr.addstr(y, 0, prefix, attr) + stdscr.addstr( + y, + 2, + f"{label:<{LABEL_W}}", + curses.color_pair(4) if sel else curses.A_NORMAL, + ) + draw_combo(stdscr, y, 2 + LABEL_W, opts, c_idxs[idx], SLIDER_W) + + # Footer + foot_y = base_y + len(ROWS) + 1 + if foot_y < height: + status = ( + f" Connected to {current_sock_path[0]}" + if sock.connected + else f" Disconnected ({current_sock_path[0]}) \u2014 retrying on next command" + ) + col = curses.color_pair(2) if sock.connected else curses.color_pair(3) + stdscr.addstr(foot_y, 0, status[: width - 1], col) + if foot_y + 1 < height: + cam_hint = " C switch cam" if len(CAMERA_SOCKS) > 1 else "" + stdscr.addstr( + foot_y + 1, + 2, + "\u2190\u2192 adjust \u2191\u2193 navigate R reset Q quit", + curses.A_DIM, + ) + if cam_hint and foot_y + 2 < height: + stdscr.addstr(foot_y + 2, 2, "C switch cam", curses.A_DIM) + except curses.error: + pass # terminal too small — skip frame + + stdscr.refresh() + + # Input (non-blocking) + try: + key = stdscr.getch() + except Exception: + key = -1 + + if key in (ord("q"), ord("Q")): + break + + elif key in (ord("r"), ord("R")): + do_reset() + + elif key == curses.KEY_UP: + cursor = (cursor - 1) % len(ROWS) + + elif key == curses.KEY_DOWN: + cursor = (cursor + 1) % len(ROWS) + + elif key in (curses.KEY_LEFT, curses.KEY_RIGHT): + kind, idx = ROWS[cursor] + d = 1 if key == curses.KEY_RIGHT else -1 + if kind == "camera": + ci = current_cam_idx[0] + do_switch_camera((ci + d) % len(CAMERA_SOCKS)) + elif kind == "slider": + step = SLIDERS[idx][5] + lo, hi = SLIDERS[idx][2], SLIDERS[idx][3] + s_vals[idx] = max(lo, min(hi, s_vals[idx] + d * step)) + send_slider(idx, s_vals[idx]) + else: + n = len(COMBOS[idx][2]) + c_idxs[idx] = (c_idxs[idx] + d) % n + send_combo(idx, c_idxs[idx]) + + elif key in (ord("c"), ord("C")): + # Cycle to next camera socket + do_switch_camera((current_cam_idx[0] + 1) % len(CAMERA_SOCKS)) + + elif key == -1: + curses.napms(30) + + # Save state on clean exit. + cam_s_vals[current_cam_idx[0]] = list(s_vals) + cam_c_idxs[current_cam_idx[0]] = list(c_idxs) + save_state(cam_s_vals, cam_c_idxs) + + +def run(): + try: + curses.wrapper(main) + except KeyboardInterrupt: + pass + + +# Named keys for the shared state file — must match the GUI's JSON format. +# Order corresponds to SLIDERS list. +_SLIDER_KEYS = [ + "brightness", # SLIDERS[0] + "ev", # SLIDERS[1] + "contrast", # SLIDERS[2] + "saturation", # SLIDERS[3] + "gain", # SLIDERS[4] + "sharpness", # SLIDERS[5] + "zoom", # SLIDERS[6] + "awbGainR", # SLIDERS[7] + "awbGainB", # SLIDERS[8] +] +_COMBO_KEYS = [ + "awbIdx", # COMBOS[0] + "meteringIdx", # COMBOS[1] + "exposureIdx", # COMBOS[2] + "denoiseIdx", # COMBOS[3] + "hdrIdx", # COMBOS[4] +] + + +def _default_cam_state(): + return { + "s_vals": [s[4] for s in SLIDERS], + "c_idxs": [0] * len(COMBOS), + } + + +def _state_file(cam_idx): + """Return the shared state file path for a camera index.""" + return Path("/tmp") / f"rpicam-vid{cam_idx}.state" + + +def load_cam_state(cam_idx): + """Load state from /tmp/rpicam-vid{N}.state (named-key JSON, GUI-compatible).""" + try: + data = json.loads(_state_file(cam_idx).read_text()) + if not data: + return _default_cam_state() + s = _default_cam_state() + for i, key in enumerate(_SLIDER_KEYS): + if key in data: + s["s_vals"][i] = int(data[key]) + for i, key in enumerate(_COMBO_KEYS): + if key in data: + s["c_idxs"][i] = int(data[key]) + return s + except Exception: + return _default_cam_state() + + +def load_state(): + """Return list of per-camera state dicts (one per CAMERA_SOCKS entry).""" + return [load_cam_state(i) for i in range(len(CAMERA_SOCKS))] + + +def save_cam_state(cam_idx, s_vals, c_idxs): + """Write state atomically to /tmp/rpicam-vid{N}.state (named-key JSON, GUI-compatible).""" + try: + target = _state_file(cam_idx) + tmp = Path(str(target) + ".tmp") + data = {key: s_vals[i] for i, key in enumerate(_SLIDER_KEYS)} + data.update({key: c_idxs[i] for i, key in enumerate(_COMBO_KEYS)}) + tmp.write_text(json.dumps(data, indent=2)) + os.replace(tmp, target) # atomic on POSIX + except Exception: + pass # non-fatal + + +def save_state(cam_s_vals, cam_c_idxs): + for i in range(len(CAMERA_SOCKS)): + save_cam_state(i, cam_s_vals[i], cam_c_idxs[i]) + + +if __name__ == "__main__": + run() diff --git a/utils/rpicam_vid_gui/main.cpp b/utils/rpicam_vid_gui/main.cpp new file mode 100644 index 00000000..d21be2e6 --- /dev/null +++ b/utils/rpicam_vid_gui/main.cpp @@ -0,0 +1,855 @@ +/* SPDX-License-Identifier: BSD-2-Clause */ +/* + * Copyright (C) 2026, Kletternaut + * + * rpicam-vid-gui — Qt5 runtime control panel for rpicam-vid. + * + * Connects to /tmp/rpicam-vid.sock and sends parameter updates via + * Unix Domain Socket. Requires rpicam-vid built with the ControlSocket patch. + * + * Build: + * meson configure build -Denable_control_gui=enabled + * ninja -C build + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int RECONN_MS = 2000; +static constexpr int DEBOUNCE_MS = 60; + +// --------------------------------------------------------------------------- +// Per-camera UI state snapshot (slider integer values + combo indices). +// Default-initialised members match the widget defaults set in buildUi(). +// --------------------------------------------------------------------------- +struct CameraState +{ + int brightness = 0; + int ev = 0; + int contrast = 100; + int saturation = 100; + int zoom = 10; + int gain = 10; + int sharpness = 10; + int awbGainR = 150; + int awbGainB = 120; + int awbIdx = 0; + int meteringIdx = 0; + int exposureIdx = 0; + int denoiseIdx = 0; + int hdrIdx = 0; +}; + +// --------------------------------------------------------------------------- + +class ControlWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit ControlWindow(QWidget *parent = nullptr) : QMainWindow(parent) + { + setWindowTitle("rpicam-vid Runtime Control"); + setMinimumWidth(520); + + sock_ = new QLocalSocket(this); + connect(sock_, &QLocalSocket::stateChanged, this, + [this](QLocalSocket::LocalSocketState s) + { + if (s == QLocalSocket::ConnectedState) + onConnected(); + else if (s == QLocalSocket::UnconnectedState) + onDisconnected(); + }); + + reconnTimer_ = new QTimer(this); + reconnTimer_->setInterval(RECONN_MS); + connect(reconnTimer_, &QTimer::timeout, this, &ControlWindow::tryConnect); + + debounceTimer_ = new QTimer(this); + debounceTimer_->setSingleShot(true); + debounceTimer_->setInterval(DEBOUNCE_MS); + connect(debounceTimer_, &QTimer::timeout, this, &ControlWindow::flush); + + buildUi(); + loadAllStatesFromFiles(); + tryConnect(); + + // Keyboard shortcuts (mirrors TUI) + connect(new QShortcut(Qt::Key_R, this), &QShortcut::activated, this, &ControlWindow::onReset); + connect(new QShortcut(Qt::Key_Q, this), &QShortcut::activated, this, &QMainWindow::close); + connect(new QShortcut(Qt::Key_C, this), &QShortcut::activated, this, + [this]() + { + int next = (cameraCombo_->currentIndex() + 1) % cameraCombo_->count(); + cameraCombo_->setCurrentIndex(next); + }); + + // Restore last window position (but let height adjust to content). + QSettings settings; + if (settings.contains("window/geometry")) + { + restoreGeometry(settings.value("window/geometry").toByteArray()); + adjustSize(); + } + + // Status bar text slightly smaller than the application default. + QFont sbFont = statusBar()->font(); + sbFont.setPointSizeF(sbFont.pointSizeF() * 0.85); + statusBar()->setFont(sbFont); + } + +protected: + void closeEvent(QCloseEvent *event) override + { + // Disconnect socket signals before closing to avoid callbacks into + // partially-destroyed widgets during Qt's teardown sequence. + reconnTimer_->stop(); + debounceTimer_->stop(); + sock_->disconnect(); + sock_->abort(); + + QSettings settings; + settings.setValue("window/geometry", saveGeometry()); + saveCameraState(); // capture any unsaved changes for the active camera + for (int i = 0; i < 2; ++i) + saveCameraStateToFile(i, cameraStates_[i]); + QMainWindow::closeEvent(event); + } + +private slots: + void tryConnect() + { + if (sock_->state() != QLocalSocket::UnconnectedState) + return; + sock_->connectToServer(currentSockPath_); + } + + void onConnected() + { + reconnTimer_->stop(); + statusBar()->showMessage(" \u25cf Connected \u2014 " + currentSockPath_); + statusBar()->setStyleSheet("QStatusBar { color: green; }"); + // Reload state from file on every connect. If rpicam-vid restarted + // it deleted the state file → loadCameraStateFromFile() returns + // defaults, so we never push stale in-memory values to a fresh camera + // session. If the GUI restarted while the camera was already running, + // the file still exists and we restore exactly what was set before. + cameraStates_[currentCameraIdx_] = loadCameraStateFromFile(currentCameraIdx_); + applyUiState(cameraStates_[currentCameraIdx_]); + // Defer by one event-loop cycle so the socket is fully open for + // writing (stateChanged fires before isOpen() is true). + QTimer::singleShot(0, this, [this] { sendCameraState(cameraStates_[currentCameraIdx_]); }); + } + + void onDisconnected() + { + statusBar()->showMessage(" \u25cb Disconnected \u2014 retrying every 2 s\u2026"); + statusBar()->setStyleSheet("QStatusBar { color: #c0392b; }"); + if (!reconnTimer_->isActive()) + reconnTimer_->start(); + } + + // Queue a key:value command and (re)start the debounce window. + void queue(const QString &key, const QString &value) + { + pending_[key] = value; + debounceTimer_->start(); + } + + // Send all queued commands in a single write. + void flush() + { + if (sock_->state() != QLocalSocket::ConnectedState || pending_.isEmpty()) + return; + QString batch; + for (auto it = pending_.cbegin(); it != pending_.cend(); ++it) + batch += it.key() + ":" + it.value() + "\n"; + pending_.clear(); + sock_->write(batch.toUtf8()); + sock_->flush(); + } + + // Send a command immediately (discrete controls, reset). + void sendNow(const QString &payload) + { + if (!sock_->isOpen() || sock_->state() != QLocalSocket::ConnectedState) + return; + sock_->write((payload + "\n").toUtf8()); + sock_->flush(); + } + + // ------------------------------------------------------------------ + // Slider / combo slots + // ------------------------------------------------------------------ + + void onBrightnessChanged(int v) + { + double val = v / 100.0; + brightnessVal_->setText(QString::asprintf("%+.2f", val)); + queue("brightness", QString::number(val, 'f', 2)); + } + + void onEvChanged(int v) + { + double val = v / 10.0; + evVal_->setText(QString::asprintf("%+.1f", val)); + queue("ev", QString::number(val, 'f', 1)); + } + + void onContrastChanged(int v) + { + double val = v / 100.0; + contrastVal_->setText(QString::number(val, 'f', 2)); + queue("contrast", QString::number(val, 'f', 2)); + } + + void onSaturationChanged(int v) + { + double val = v / 100.0; + saturationVal_->setText(QString::number(val, 'f', 2)); + queue("saturation", QString::number(val, 'f', 2)); + } + + // Zoom: slider 10…100 → 1.0x … 10.0x centred ROI + void onZoomChanged(int v) + { + double factor = v / 10.0; + double w = 1.0 / factor; + double h = 1.0 / factor; + double x = (1.0 - w) / 2.0; + double y = (1.0 - h) / 2.0; + zoomVal_->setText(QString::number(factor, 'f', 1) + "\xc3\x97"); // × + queue("roi", QString("%1,%2,%3,%4").arg(x, 0, 'f', 4).arg(y, 0, 'f', 4).arg(w, 0, 'f', 4).arg(h, 0, 'f', 4)); + } + + void onGainChanged(int v) + { + double val = v / 10.0; + gainVal_->setText(QString::number(val, 'f', 1) + "x"); + queue("gain", QString::number(val, 'f', 1)); + } + + void onSharpnessChanged(int v) + { + double val = v / 10.0; + sharpnessVal_->setText(QString::number(val, 'f', 1)); + queue("sharpness", QString::number(val, 'f', 1)); + } + + void onAwbChanged(int idx) + { + static const QStringList modes = { "auto", "incandescent", "tungsten", "fluorescent", + "indoor", "daylight", "cloudy", "manual" }; + bool isManual = (idx == modes.size() - 1); + if (!isManual) + sendNow("awb:" + modes[idx]); + else + { + // Send current slider values as manual gains + sendNow(QString("awbgains:%1,%2") + .arg(awbGainRSlider_->value() / 100.0, 0, 'f', 2) + .arg(awbGainBSlider_->value() / 100.0, 0, 'f', 2)); + } + } + + void onAwbGainRChanged(int v) + { + double val = v / 100.0; + awbGainRVal_->setText(QString::number(val, 'f', 2)); + setAwbComboToManual(); + queue("awbgains", QString("%1,%2").arg(val, 0, 'f', 2).arg(awbGainBSlider_->value() / 100.0, 0, 'f', 2)); + } + + void onAwbGainBChanged(int v) + { + double val = v / 100.0; + awbGainBVal_->setText(QString::number(val, 'f', 2)); + setAwbComboToManual(); + queue("awbgains", QString("%1,%2").arg(awbGainRSlider_->value() / 100.0, 0, 'f', 2).arg(val, 0, 'f', 2)); + } + + void setAwbComboToManual() + { + static const QStringList modes = { "auto", "incandescent", "tungsten", "fluorescent", + "indoor", "daylight", "cloudy", "manual" }; + int manualIdx = modes.size() - 1; + if (awbCombo_->currentIndex() != manualIdx) + { + QSignalBlocker b(awbCombo_); + awbCombo_->setCurrentIndex(manualIdx); + } + } + + void onHdrChanged(int idx) + { + static const QStringList modes = { "off", "auto", "sensor", "single-exp" }; + if (idx >= 0 && idx < modes.size()) + sendNow("hdr:" + modes[idx]); + } + + void onMeteringChanged(int idx) + { + static const QStringList modes = { "centre", "spot", "average", "custom" }; + if (idx >= 0 && idx < modes.size()) + sendNow("metering:" + modes[idx]); + } + + void onExposureChanged(int idx) + { + static const QStringList modes = { "normal", "sport" }; + if (idx >= 0 && idx < modes.size()) + sendNow("exposure:" + modes[idx]); + } + + void onDenoiseChanged(int idx) + { + static const QStringList modes = { "auto", "off", "cdn_off", "cdn_fast", "cdn_hq" }; + if (idx >= 0 && idx < modes.size()) + sendNow("denoise:" + modes[idx]); + } + + // ------------------------------------------------------------------ + // Per-camera state helpers + // ------------------------------------------------------------------ + + // State files: /tmp/rpicam-vid{N}.state (JSON) - shared with rpicam-vid-tui. + + static QString stateFilePath(int camIdx) + { + return QString("/tmp/rpicam-vid%1.state").arg(camIdx); + } + + static void saveCameraStateToFile(int camIdx, const CameraState &c) + { + QJsonObject o; + o["brightness"] = c.brightness; + o["ev"] = c.ev; + o["contrast"] = c.contrast; + o["saturation"] = c.saturation; + o["zoom"] = c.zoom; + o["gain"] = c.gain; + o["sharpness"] = c.sharpness; + o["awbGainR"] = c.awbGainR; + o["awbGainB"] = c.awbGainB; + o["awbIdx"] = c.awbIdx; + o["meteringIdx"] = c.meteringIdx; + o["exposureIdx"] = c.exposureIdx; + o["denoiseIdx"] = c.denoiseIdx; + o["hdrIdx"] = c.hdrIdx; + // Write atomically: QSaveFile writes to a temp file and renames on + // commit, overwriting any existing target file. + QSaveFile f(stateFilePath(camIdx)); + if (f.open(QIODevice::WriteOnly)) + { + f.write(QJsonDocument(o).toJson()); + f.commit(); + } + } + + static CameraState loadCameraStateFromFile(int camIdx) + { + QFile f(stateFilePath(camIdx)); + if (!f.open(QIODevice::ReadOnly)) + return CameraState {}; // file absent -> use defaults + const QJsonObject o = QJsonDocument::fromJson(f.readAll()).object(); + if (o.isEmpty()) + return CameraState {}; + CameraState c; + c.brightness = o.value("brightness").toInt(c.brightness); + c.ev = o.value("ev").toInt(c.ev); + c.contrast = o.value("contrast").toInt(c.contrast); + c.saturation = o.value("saturation").toInt(c.saturation); + c.zoom = o.value("zoom").toInt(c.zoom); + c.gain = o.value("gain").toInt(c.gain); + c.sharpness = o.value("sharpness").toInt(c.sharpness); + c.awbGainR = o.value("awbGainR").toInt(c.awbGainR); + c.awbGainB = o.value("awbGainB").toInt(c.awbGainB); + c.awbIdx = o.value("awbIdx").toInt(c.awbIdx); + c.meteringIdx = o.value("meteringIdx").toInt(c.meteringIdx); + c.exposureIdx = o.value("exposureIdx").toInt(c.exposureIdx); + c.denoiseIdx = o.value("denoiseIdx").toInt(c.denoiseIdx); + c.hdrIdx = o.value("hdrIdx").toInt(c.hdrIdx); + return c; + } + + void loadAllStatesFromFiles() + { + for (int i = 0; i < 2; ++i) + cameraStates_[i] = loadCameraStateFromFile(i); + // Apply the active camera's state to the UI (widgets already built). + applyUiState(cameraStates_[currentCameraIdx_]); + // Hardware will be synced in onConnected() via sendCameraState(). + } + + void saveCameraState() + { + CameraState &s = cameraStates_[currentCameraIdx_]; + s.brightness = brightnessSlider_->value(); + s.ev = evSlider_->value(); + s.contrast = contrastSlider_->value(); + s.saturation = saturationSlider_->value(); + s.zoom = zoomSlider_->value(); + s.gain = gainSlider_->value(); + s.sharpness = sharpnessSlider_->value(); + s.awbGainR = awbGainRSlider_->value(); + s.awbGainB = awbGainBSlider_->value(); + s.awbIdx = awbCombo_->currentIndex(); + s.meteringIdx = meteringCombo_->currentIndex(); + s.exposureIdx = exposureCombo_->currentIndex(); + s.denoiseIdx = denoiseCombo_->currentIndex(); + s.hdrIdx = hdrCombo_->currentIndex(); + } + + void applyUiState(const CameraState &s) + { + QSignalBlocker b1(brightnessSlider_), b2(evSlider_), b3(contrastSlider_), b4(saturationSlider_); + QSignalBlocker b5(zoomSlider_), b6(gainSlider_), b7(sharpnessSlider_); + QSignalBlocker b8(awbGainRSlider_), b9(awbGainBSlider_), b10(awbCombo_); + QSignalBlocker b11(meteringCombo_), b12(exposureCombo_), b13(denoiseCombo_), b14(hdrCombo_); + + brightnessSlider_->setValue(s.brightness); + evSlider_->setValue(s.ev); + contrastSlider_->setValue(s.contrast); + saturationSlider_->setValue(s.saturation); + zoomSlider_->setValue(s.zoom); + gainSlider_->setValue(s.gain); + sharpnessSlider_->setValue(s.sharpness); + awbGainRSlider_->setValue(s.awbGainR); + awbGainBSlider_->setValue(s.awbGainB); + awbCombo_->setCurrentIndex(s.awbIdx); + meteringCombo_->setCurrentIndex(s.meteringIdx); + exposureCombo_->setCurrentIndex(s.exposureIdx); + denoiseCombo_->setCurrentIndex(s.denoiseIdx); + hdrCombo_->setCurrentIndex(s.hdrIdx); + + brightnessVal_->setText(QString::asprintf("%+.2f", s.brightness / 100.0)); + evVal_->setText(QString::asprintf("%+.1f", s.ev / 10.0)); + contrastVal_->setText(QString::number(s.contrast / 100.0, 'f', 2)); + saturationVal_->setText(QString::number(s.saturation / 100.0, 'f', 2)); + zoomVal_->setText(QString::number(s.zoom / 10.0, 'f', 1) + "\xc3\x97"); + gainVal_->setText(QString::number(s.gain / 10.0, 'f', 1) + "x"); + sharpnessVal_->setText(QString::number(s.sharpness / 10.0, 'f', 1)); + + static constexpr int MANUAL_AWB_IDX = 7; + if (s.awbIdx == MANUAL_AWB_IDX) + { + awbGainRVal_->setText(QString::number(s.awbGainR / 100.0, 'f', 2)); + awbGainBVal_->setText(QString::number(s.awbGainB / 100.0, 'f', 2)); + } + else + { + awbGainRVal_->setText("auto"); + awbGainBVal_->setText("auto"); + } + } + + void sendCameraState(const CameraState &s) + { + static const QStringList awbModes = { "auto", "incandescent", "tungsten", "fluorescent", + "indoor", "daylight", "cloudy", "manual" }; + static const QStringList meteringModes = { "centre", "spot", "average", "custom" }; + static const QStringList exposureModes = { "normal", "sport" }; + static const QStringList denoiseModes = { "auto", "off", "cdn_off", "cdn_fast", "cdn_hq" }; + static const QStringList hdrModes = { "off", "auto", "sensor", "single-exp" }; + + double zoomFactor = s.zoom / 10.0; + double w = 1.0 / zoomFactor; + double h = 1.0 / zoomFactor; + double x = (1.0 - w) / 2.0; + double y = (1.0 - h) / 2.0; + + QString batch; + batch += QString("brightness:%1\n").arg(s.brightness / 100.0, 0, 'f', 2); + batch += QString("ev:%1\n").arg(s.ev / 10.0, 0, 'f', 1); + batch += QString("contrast:%1\n").arg(s.contrast / 100.0, 0, 'f', 2); + batch += QString("saturation:%1\n").arg(s.saturation / 100.0, 0, 'f', 2); + // Only send roi/gain if non-default; otherwise we'd override --roi / + // let AE manage gain freely (gain=10 means 1.0× = AE default). + if (s.zoom != 10) + batch += + QString("roi:%1,%2,%3,%4\n").arg(x, 0, 'f', 4).arg(y, 0, 'f', 4).arg(w, 0, 'f', 4).arg(h, 0, 'f', 4); + if (s.gain != 10) + batch += QString("gain:%1\n").arg(s.gain / 10.0, 0, 'f', 1); + batch += QString("sharpness:%1\n").arg(s.sharpness / 10.0, 0, 'f', 1); + + static constexpr int MANUAL_AWB_IDX = 7; + if (s.awbIdx == MANUAL_AWB_IDX) + batch += QString("awbgains:%1,%2\n").arg(s.awbGainR / 100.0, 0, 'f', 2).arg(s.awbGainB / 100.0, 0, 'f', 2); + else + batch += "awb:" + awbModes[s.awbIdx] + "\n"; + + batch += "metering:" + meteringModes[s.meteringIdx] + "\n"; + batch += "exposure:" + exposureModes[s.exposureIdx] + "\n"; + batch += "denoise:" + denoiseModes[s.denoiseIdx] + "\n"; + batch += "hdr:" + hdrModes[s.hdrIdx] + "\n"; + + sendNow(batch.trimmed()); + } + + void onCameraChanged(int idx) + { + static const QStringList paths = { + "/tmp/rpicam-vid0.sock", + "/tmp/rpicam-vid1.sock", + }; + if (idx < 0 || idx >= paths.size()) + return; + // Discard any debounced commands for the old camera so they cannot + // leak into the new camera's socket after the switch. + debounceTimer_->stop(); + pending_.clear(); + // Persist the current camera's UI values before switching. + saveCameraState(); + currentCameraIdx_ = idx; + currentSockPath_ = paths[idx]; + reconnTimer_->stop(); + sock_->abort(); + // Restore the target camera's UI immediately (no flickering wait). + applyUiState(cameraStates_[currentCameraIdx_]); + // Immediately attempt connection to the new socket; the reconnect + // timer serves only as a fallback if this first attempt fails. + tryConnect(); + } + + void onReset() + { + // Cancel any pending debounced command so it cannot overwrite the reset. + debounceTimer_->stop(); + pending_.clear(); + + QSignalBlocker b1(brightnessSlider_), b2(evSlider_), b3(contrastSlider_), b4(saturationSlider_); + QSignalBlocker b5(zoomSlider_), b6(awbCombo_), b7(gainSlider_), b8(sharpnessSlider_); + QSignalBlocker b9(meteringCombo_), b10(exposureCombo_), b11(denoiseCombo_), b12(hdrCombo_); + QSignalBlocker b13(awbGainRSlider_), b14(awbGainBSlider_); + + brightnessSlider_->setValue(0); + evSlider_->setValue(0); + contrastSlider_->setValue(100); + saturationSlider_->setValue(100); + zoomSlider_->setValue(10); + awbCombo_->setCurrentIndex(0); + gainSlider_->setValue(10); + sharpnessSlider_->setValue(10); + meteringCombo_->setCurrentIndex(0); + exposureCombo_->setCurrentIndex(0); + denoiseCombo_->setCurrentIndex(0); + hdrCombo_->setCurrentIndex(0); + awbGainRSlider_->setValue(150); + awbGainBSlider_->setValue(120); + + brightnessVal_->setText("+0.00"); + evVal_->setText("+0.0"); + contrastVal_->setText("1.00"); + saturationVal_->setText("1.00"); + zoomVal_->setText("1.0\xc3\x97"); + gainVal_->setText("1.0x"); + sharpnessVal_->setText("1.0"); + awbGainRVal_->setText("auto"); + awbGainBVal_->setText("auto"); + + sendNow("brightness:0.00\n" + "ev:0.0\n" + "contrast:1.00\n" + "saturation:1.00\n" + "awb:auto\n" + "roi:0.0,0.0,1.0,1.0\n" + "gain:0\n" + "sharpness:1.0\n" + "metering:centre\n" + "exposure:normal\n" + "denoise:auto\n" + "hdr:off"); + + // Keep the stored state in sync with the reset UI. + cameraStates_[currentCameraIdx_] = CameraState {}; + } + +private: + // ------------------------------------------------------------------ + // UI construction + // ------------------------------------------------------------------ + + void buildUi() + { + auto *central = new QWidget(this); + setCentralWidget(central); + + auto *root = new QVBoxLayout(central); + root->setSpacing(2); + root->setContentsMargins(8, 8, 8, 4); + + // Camera selector + auto *camBar = new QHBoxLayout; + camBar->addWidget(new QLabel("Camera:")); + cameraCombo_ = new QComboBox; + cameraCombo_->addItems({ "0", "1" }); + cameraCombo_->setMaximumWidth(90); + connect(cameraCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + &ControlWindow::onCameraChanged); + camBar->addWidget(cameraCombo_); + camBar->addStretch(); + root->addLayout(camBar); + + root->addWidget(buildExposureGroup()); + root->addWidget(buildImageGroup()); + root->addWidget(buildCameraGroup()); + root->addWidget(buildAwbGroup()); + root->addWidget(buildZoomGroup()); + root->addWidget(buildAdvancedGroup()); + + auto *resetBtn = new QPushButton("Reset all to defaults"); + resetBtn->setFixedHeight(34); + connect(resetBtn, &QPushButton::clicked, this, &ControlWindow::onReset); + root->addWidget(resetBtn); + + statusBar()->showMessage(" \u25cb Connecting\u2026"); + statusBar()->setStyleSheet("QStatusBar { color: gray; }"); + } + + QWidget *buildExposureGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + grid->setColumnStretch(1, 1); + + brightnessSlider_ = addRow(grid, 0, "Brightness", -100, 100, 0, &brightnessVal_); + brightnessVal_->setText("+0.00"); + connect(brightnessSlider_, &QSlider::valueChanged, this, &ControlWindow::onBrightnessChanged); + + evSlider_ = addRow(grid, 1, "EV", -30, 30, 0, &evVal_); + evVal_->setText("+0.0"); + connect(evSlider_, &QSlider::valueChanged, this, &ControlWindow::onEvChanged); + + return group; + } + + QWidget *buildImageGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + grid->setColumnStretch(1, 1); + + contrastSlider_ = addRow(grid, 0, "Contrast", 0, 300, 100, &contrastVal_); + contrastVal_->setText("1.00"); + connect(contrastSlider_, &QSlider::valueChanged, this, &ControlWindow::onContrastChanged); + + saturationSlider_ = addRow(grid, 1, "Saturation", 0, 300, 100, &saturationVal_); + saturationVal_->setText("1.00"); + connect(saturationSlider_, &QSlider::valueChanged, this, &ControlWindow::onSaturationChanged); + + return group; + } + + QWidget *buildCameraGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + grid->setColumnStretch(1, 1); + + gainSlider_ = addRow(grid, 0, "Gain", 10, 160, 10, &gainVal_); + gainVal_->setText("1.0x"); + connect(gainSlider_, &QSlider::valueChanged, this, &ControlWindow::onGainChanged); + + sharpnessSlider_ = addRow(grid, 1, "Sharpness", 0, 160, 10, &sharpnessVal_); + sharpnessVal_->setText("1.0"); + connect(sharpnessSlider_, &QSlider::valueChanged, this, &ControlWindow::onSharpnessChanged); + + return group; + } + + QWidget *buildAwbGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + grid->setColumnStretch(1, 1); + + // AWB mode + grid->addWidget(new QLabel("AWB mode:"), 0, 0); + awbCombo_ = new QComboBox; + awbCombo_->addItems( + { "auto", "incandescent", "tungsten", "fluorescent", "indoor", "daylight", "cloudy", "manual" }); + connect(awbCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, &ControlWindow::onAwbChanged); + awbCombo_->setMaximumWidth(120); + grid->addWidget(awbCombo_, 0, 1); + + // AWB gains — always enabled; moving them auto-switches AWB combo to manual + awbGainRSlider_ = addRow(grid, 1, "Gain R", 10, 400, 150, &awbGainRVal_); + awbGainRVal_->setText("auto"); + connect(awbGainRSlider_, &QSlider::valueChanged, this, &ControlWindow::onAwbGainRChanged); + + awbGainBSlider_ = addRow(grid, 2, "Gain B", 10, 400, 120, &awbGainBVal_); + awbGainBVal_->setText("auto"); + connect(awbGainBSlider_, &QSlider::valueChanged, this, &ControlWindow::onAwbGainBChanged); + + return group; + } + + QWidget *buildAdvancedGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + + auto *meteringLbl = new QLabel("Metering:"); + meteringLbl->setFixedWidth(90); + grid->addWidget(meteringLbl, 0, 0); + meteringCombo_ = new QComboBox; + meteringCombo_->addItems({ "centre", "spot", "average", "custom" }); + connect(meteringCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + &ControlWindow::onMeteringChanged); + grid->addWidget(meteringCombo_, 0, 1); + + grid->addWidget(new QLabel("Exposure:"), 0, 2); + exposureCombo_ = new QComboBox; + exposureCombo_->addItems({ "normal", "sport" }); + connect(exposureCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + &ControlWindow::onExposureChanged); + grid->addWidget(exposureCombo_, 0, 3); + + auto *denoiseLbl = new QLabel("Denoise:"); + denoiseLbl->setFixedWidth(90); + grid->addWidget(denoiseLbl, 1, 0); + denoiseCombo_ = new QComboBox; + denoiseCombo_->addItems({ "auto", "off", "cdn_off", "cdn_fast", "cdn_hq" }); + connect(denoiseCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + &ControlWindow::onDenoiseChanged); + grid->addWidget(denoiseCombo_, 1, 1); + + grid->addWidget(new QLabel("HDR:"), 1, 2); + hdrCombo_ = new QComboBox; + hdrCombo_->addItems({ "off", "auto", "sensor", "single-exp" }); + connect(hdrCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, &ControlWindow::onHdrChanged); + grid->addWidget(hdrCombo_, 1, 3); + + return group; + } + + QWidget *buildZoomGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + grid->setColumnStretch(1, 1); + + zoomSlider_ = addRow(grid, 0, "Zoom", 10, 100, 10, &zoomVal_); + zoomVal_->setText("1.0\xc3\x97"); + connect(zoomSlider_, &QSlider::valueChanged, this, &ControlWindow::onZoomChanged); + + return group; + } + + // Add one labelled slider row to a QGridLayout. + // Columns: 0=label 1=slider 2=value + QSlider *addRow(QGridLayout *g, int row, const QString &label, int minV, int maxV, int initV, QLabel **valueOut) + { + auto *lbl = new QLabel(label + ":"); + lbl->setFixedWidth(90); + g->addWidget(lbl, row, 0); + + auto *slider = new QSlider(Qt::Horizontal); + slider->setRange(minV, maxV); + slider->setValue(initV); + slider->setTickPosition(QSlider::TicksBelow); + slider->setTickInterval((maxV - minV) / 10); + g->addWidget(slider, row, 1); + + auto *vl = new QLabel; + vl->setFixedWidth(62); + vl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + g->addWidget(vl, row, 2); + *valueOut = vl; + + return slider; + } + + // ------------------------------------------------------------------ + // State + // ------------------------------------------------------------------ + + QLocalSocket *sock_; + QTimer *reconnTimer_; + QTimer *debounceTimer_; + QMap pending_; + QString currentSockPath_ = "/tmp/rpicam-vid0.sock"; + int currentCameraIdx_ = 0; + CameraState cameraStates_[2]; + QComboBox *cameraCombo_; + + QSlider *brightnessSlider_; + QSlider *evSlider_; + QSlider *contrastSlider_; + QSlider *saturationSlider_; + QSlider *zoomSlider_; + QSlider *gainSlider_; + QSlider *sharpnessSlider_; + QSlider *awbGainRSlider_; + QSlider *awbGainBSlider_; + QLabel *brightnessVal_; + QLabel *evVal_; + QLabel *contrastVal_; + QLabel *saturationVal_; + QLabel *zoomVal_; + QLabel *gainVal_; + QLabel *sharpnessVal_; + QLabel *awbGainRVal_; + QLabel *awbGainBVal_; + QComboBox *awbCombo_; + QComboBox *meteringCombo_; + QComboBox *exposureCombo_; + QComboBox *denoiseCombo_; + QComboBox *hdrCombo_; +}; + +#include "main.moc" + +// --------------------------------------------------------------------------- + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + app.setApplicationName("rpicam-vid-gui"); + app.setApplicationDisplayName("rpicam-vid Runtime Control"); + + ControlWindow win; + win.show(); + return app.exec(); +} diff --git a/utils/rpicam_vid_gui/meson.build b/utils/rpicam_vid_gui/meson.build new file mode 100644 index 00000000..ef1ef680 --- /dev/null +++ b/utils/rpicam_vid_gui/meson.build @@ -0,0 +1,30 @@ +# meson.build for rpicam-vid-gui (optional Qt runtime control panel) + +enable_control_gui = get_option('enable_control_gui') + +qt_dep = dependency('qt6', modules : ['Core', 'Widgets', 'Network'], required : false) + +if qt_dep.found() + qt = import('qt6') +else + message('Qt6 libraries not found, falling back to Qt5') + qt_dep = dependency('qt5', + modules : ['Core', 'Widgets', 'Network'], + required : enable_control_gui) + qt = import('qt5') +endif + +if qt_dep.found() + moc_files = qt.preprocess( + moc_sources : ['main.cpp'], + dependencies : qt_dep, + include_directories : include_directories('.'), + ) + + executable('rpicam-vid-gui', + sources : ['main.cpp', moc_files], + dependencies : qt_dep, + include_directories : include_directories('../..'), + install : true, + ) +endif From 899141aed79e445e5229edc0ec3064c6058e3bdc Mon Sep 17 00:00:00 2001 From: Kletternaut Date: Sat, 16 May 2026 16:33:04 +0200 Subject: [PATCH 2/2] gui/tui: integer fps slider, log shutter, caps, auto-select cam1 - control_socket.hpp: HasNewClient/ClearNewClient/SendToClient - rpicam_vid.cpp: send caps:maxfps=N,hasaf=0|1 after ConfigureVideo - rpicam_vid_gui: integer fps (1 fps steps), log-scale shutter, auto-select cam1 via socket check, clamp slider from caps - rpicam-vid-tui: rewrite matching GUI; shutter/framerate sliders, caps polling, auto-select cam1, GUI-compatible state format - utils/README: caps protocol, shutter/framerate cmds, AF note - README: shutter/framerate cmds, GUI/TUI features, AF note Signed-off-by: Kletternaut --- README.md | 13 +- apps/control_socket.hpp | 20 ++ apps/rpicam_vid.cpp | 36 +++ utils/README.md | 58 ++++- utils/rpicam-vid-tui | 269 +++++++++++++++------ utils/rpicam_vid_gui/main.cpp | 440 +++++++++++++++++++++++++++++++++- 6 files changed, 746 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 0fdf2efc..ed9f5d90 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Runtime Control (feature/runtime-control-socket) | Command | Example | Effect | |---|---|---| +| `shutter:<µs>` | `shutter:10000` | Fixed shutter 0 (auto) … sensor max µs | +| `framerate:` | `framerate:30` | Fixed framerate 0 (auto) … sensor max fps | | `brightness:` | `brightness:-0.2` | Brightness −1.0 … +1.0 | | `ev:` | `ev:1.5` | EV compensation −3.0 … +3.0 | | `contrast:` | `contrast:1.2` | Contrast 0.0 … 3.0 | @@ -42,7 +44,16 @@ echo "roi:0.25,0.25,0.5,0.5" | nc -U /tmp/rpicam-vid0.sock ### Qt GUI (`rpicam-vid-gui`) -A graphical control panel built with Qt Widgets. Includes sliders, dropdowns, camera selector (0/1) and keyboard shortcuts (`R` reset, `Q` quit, `C` switch camera). Build via Meson: +A graphical control panel built with Qt Widgets. Connects to the running `rpicam-vid` socket and provides: + +- **Sliders** for brightness, EV, contrast, saturation, sharpness, gain, zoom, AWB gains, shutter (logarithmic scale), and framerate (1 fps integer steps) +- **Dropdowns** for AWB, metering, exposure, denoise, HDR modes +- **Autofocus (AF) controls** are implemented in code but hidden in the UI — untested due to missing hardware (no AF-capable lens available); not production-ready +- **Camera selector** (Cam 0 / Cam 1) — auto-selects Cam 1 if Cam 0 is not detected +- **Per-mode fps cap**: on connect, `rpicam-vid` reports the active sensor mode's maximum fps; the framerate slider is clamped accordingly +- **Keyboard shortcuts**: `R` reset all, `Q` quit, `C` switch camera + +Build via Meson: ```sh meson configure build -Denable_control_gui=enabled diff --git a/apps/control_socket.hpp b/apps/control_socket.hpp index 7977e45d..d571c06b 100644 --- a/apps/control_socket.hpp +++ b/apps/control_socket.hpp @@ -137,10 +137,29 @@ class ControlSocket ::close(client_fd_); client_fd_ = fd; recv_buf_.clear(); + newClientConnected_ = true; LOG(1, "ControlSocket: client connected"); } } + // Returns true once after a new client connected; cleared by ClearNewClient(). + bool HasNewClient() const + { + return newClientConnected_; + } + void ClearNewClient() + { + newClientConnected_ = false; + } + + // Send a raw message to the connected client (non-blocking, fire-and-forget). + void SendToClient(const std::string &msg) + { + if (client_fd_ < 0) + return; + ::send(client_fd_, msg.c_str(), msg.size(), MSG_NOSIGNAL); + } + // Drain the receive buffer of any pending data and return a ControlList // built from all complete commands. Returns an empty ControlList when // nothing has arrived. sensor_area is required for ROI → pixel conversion. @@ -191,6 +210,7 @@ class ControlSocket private: int server_fd_ = -1; int client_fd_ = -1; + bool newClientConnected_ = false; std::string socket_path_; // Derive the state-file path from the socket path by replacing the diff --git a/apps/rpicam_vid.cpp b/apps/rpicam_vid.cpp index 36cdcb58..41cc2fe4 100644 --- a/apps/rpicam_vid.cpp +++ b/apps/rpicam_vid.cpp @@ -72,6 +72,28 @@ static void event_loop(RPiCamEncoder &app) app.OpenCamera(); app.ConfigureVideo(get_colourspace_flags(options->Get().codec)); + + // Query the maximum fps for the negotiated sensor mode from FrameDurationLimits. + // Must be read after ConfigureVideo() / camera_->configure() so the pipeline + // has selected the actual sensor mode and the control range is mode-specific. + // Reading after StartCamera() would return the same value but is unnecessarily late. + int caps_max_fps = 0; + { + auto cameras = app.GetCameras(); + const int camIdx = options->Get().camera; + if (camIdx >= 0 && camIdx < static_cast(cameras.size())) + { + const auto &controls = cameras[camIdx]->controls(); + const auto it = controls.find(&libcamera::controls::FrameDurationLimits); + if (it != controls.end()) + { + const int64_t min_duration_us = it->second.min().get(); + if (min_duration_us > 0) + caps_max_fps = static_cast(1'000'000LL / min_duration_us); + } + } + } + app.StartEncoder(); app.StartCamera(); auto start_time = std::chrono::high_resolution_clock::now(); @@ -115,6 +137,20 @@ static void event_loop(RPiCamEncoder &app) // Check the runtime control socket for pending parameter updates. ctrl_socket.AcceptConnections(); { + // Send camera capabilities once to every new client. + // Format: "caps:maxfps=,hasaf=<0|1>\n" + if (ctrl_socket.HasNewClient()) + { + auto cameras = app.GetCameras(); + const int camIdx = options->Get().camera; + const bool has_af = (camIdx >= 0 && camIdx < static_cast(cameras.size())) + ? cameras[camIdx]->controls().count(&libcamera::controls::AfMode) > 0 + : false; + std::string caps = + "caps:maxfps=" + std::to_string(caps_max_fps) + ",hasaf=" + (has_af ? "1" : "0") + "\n"; + ctrl_socket.SendToClient(caps); + ctrl_socket.ClearNewClient(); + } libcamera::ControlList cl = ctrl_socket.ReadControls(app.GetSensorArea(), ctrl_is_pisp); if (!cl.empty()) app.SetControls(cl); diff --git a/utils/README.md b/utils/README.md index e7e7f0fa..d45fe72b 100644 --- a/utils/README.md +++ b/utils/README.md @@ -66,6 +66,8 @@ Both `rpicam-vid-tui` and `rpicam-vid-gui` can switch between cameras at runtime | `exposure:` | string | `normal` `sport` | | `denoise:` | string | `auto` `off` `cdn_off` `cdn_fast` `cdn_hq` | | `hdr:` | string | `off` `auto` `sensor` `single-exp` | +| `shutter:<µs>` | integer | exposure time in microseconds; `0` = auto | +| `framerate:` | integer | target frame rate; `0` = auto (sensor maximum) | > **AWB note:** `awb:` re-enables auto AWB. `awbgains:,` disables > auto AWB and applies fixed colour gains. Switching back to any `awb:` mode @@ -74,6 +76,30 @@ Both `rpicam-vid-tui` and `rpicam-vid-gui` can switch between cameras at runtime > **HDR note:** On imx477 (HQ Camera), only `off` and `single-exp` are > functional at runtime. `auto` and `sensor` both map to single-exposure HDR. +> **Shutter / framerate note:** `shutter:0` restores auto exposure; +> `framerate:0` removes the frame-rate cap. Setting both to non-zero values +> enters fully manual exposure mode. The maximum useful shutter duration is +> 1 / framerate seconds; the GUI and TUI enforce this automatically. + +--- + +## Caps protocol (server → client) + +When a client connects, `rpicam-vid` sends a single line describing the active +sensor mode's capabilities: + +``` +caps:maxfps=40,hasaf=0 +``` + +| Field | Meaning | +|---|---| +| `maxfps` | Maximum frame rate of the active mode (integer) | +| `hasaf` | `1` if the camera supports autofocus, `0` otherwise | + +Both `rpicam-vid-gui` and `rpicam-vid-tui` parse this line and clamp the +framerate slider maximum accordingly. + --- ## Tools @@ -98,9 +124,25 @@ dependencies required. | `R` | Reset all parameters to defaults | | `Q` | Quit | -All sliders and dropdowns mirror the same parameters as the socket commands. +**Parameters:** + +| Row | Type | Notes | +|---|---|---| +| Brightness, EV | slider | | +| Shutter | log-scale slider | 0 = auto; range 100 µs … 1-frame period | +| Framerate | integer slider | 0 = auto; max clamped from sensor caps | +| Contrast, Saturation | slider | | +| Gain, Sharpness | slider | | +| AWB Gain R / B | slider | auto-switches AWB mode to manual | +| Zoom | slider | maps to `roi:` | +| AWB, Metering, Exposure, Denoise, HDR | combo | | + The TUI connects automatically and retries on the next keypress if -`rpicam-vid` is not yet running. +`rpicam-vid` is not yet running. On connect, it receives the `caps:` line +and adjusts the framerate slider maximum for the active sensor mode. + +**Auto-select cam1:** if `/tmp/rpicam-vid0.sock` does not exist but +`/tmp/rpicam-vid1.sock` does, camera 1 is selected automatically on startup. Settings are persisted to `/tmp/rpicam-vid{N}.state` (JSON) per camera index and shared with `rpicam-vid-gui`. Switching cameras restores that camera's @@ -116,15 +158,19 @@ camera 1 at runtime without restarting the tool. Connects automatically to the socket on startup and reconnects if `rpicam-vid` is restarted. **Features:** -- Sliders: brightness, EV, contrast, saturation, gain, sharpness, zoom +- Sliders: brightness, EV, shutter (log-scale), framerate (1 fps integer steps), + contrast, saturation, gain, sharpness, AWB gains R/B, zoom - AWB mode dropdown + manual R/B gain sliders (auto-switches to manual on move) - Metering, exposure mode, denoise, HDR dropdowns -- Camera selector (0 / 1) — per-camera state, no bleeding between cameras +- Camera selector (0 / 1) — auto-selects cam 1 if cam 0 socket is absent +- Per-mode fps cap: on connect `rpicam-vid` sends `caps:maxfps=N`; + the framerate slider is clamped and the current value is adjusted if needed +- Autofocus (AF) controls are implemented but hidden — untested due to + missing hardware; not production-ready - Reset button restores all defaults -- Window position saved across restarts - Keyboard shortcuts: `R` reset, `Q` quit, `C` switch camera - Settings persisted to `/tmp/rpicam-vid{N}.state`, shared with `rpicam-vid-tui` -- Reconnects automatically; re-reads state file on each connect (shows hardware defaults after camera restart) +- Reconnects automatically; re-reads state file on each connect **Build** (requires Qt5 Widgets + Network): diff --git a/utils/rpicam-vid-tui b/utils/rpicam-vid-tui index 91fa0425..7cfcc67c 100755 --- a/utils/rpicam-vid-tui +++ b/utils/rpicam-vid-tui @@ -20,11 +20,16 @@ CAMERA_SOCKS = [ def _resolve_sock_path(arg): """Map CLI argument to socket path. - No arg / '' / '0' → camera 0 (/tmp/rpicam-vid0.sock) + No arg / '' / '0' → camera 0, unless only cam1 socket exists (auto-select). '1' → camera 1 (/tmp/rpicam-vid1.sock) Anything else treated as literal path. """ if arg is None or arg in ("", "0"): + # Auto-select cam1 if only cam1 is active (socket present). + cam0_up = Path(CAMERA_SOCKS[0]).exists() + cam1_up = Path(CAMERA_SOCKS[1]).exists() + if not cam0_up and cam1_up: + return CAMERA_SOCKS[1] return CAMERA_SOCKS[0] if arg == "1": return CAMERA_SOCKS[1] @@ -35,6 +40,51 @@ SOCK_PATH = _resolve_sock_path(sys.argv[1] if len(sys.argv) > 1 else None) SLIDER_W = 22 # chars for the slider bar LABEL_W = 12 # chars for the label column +# --------------------------------------------------------------------------- +# Shutter / framerate helpers (log-scale shutter, direct fps) +# --------------------------------------------------------------------------- + +SHUTTER_STEPS = 200 # slider positions 1..200 (0 = auto) +SHUTTER_IDX = 2 # index in SLIDERS list (matches GUI group order) +FRAMERATE_IDX = 3 # index in SLIDERS list + +# Mutable caps state — updated when rpicam-vid sends "caps:..." on connect. +_caps = {"maxfps": 120} # reset on camera switch + +# Current framerate slider value for the active camera — used by shutter +# display/send to compute the one-frame period. 0 = auto. +_cur_fps = [0] + + +def _max_shutter_us(): + """Return the maximum meaningful shutter duration in µs.""" + fps = float(_cur_fps[0]) + if fps <= 0.0: + fps = float(_caps["maxfps"]) if _caps["maxfps"] > 0 else 30.0 + return int(1_000_000 / max(1.0, fps)) + + +def _shutter_micros(pos): + """Convert log-scale slider position (1..SHUTTER_STEPS) to µs.""" + if pos <= 0: + return 0 + lo = 100.0 + hi = float(_max_shutter_us()) + t = (pos - 1) / (SHUTTER_STEPS - 1) + return int(round(lo * (hi / lo) ** t)) + + +def _shutter_disp(pos): + if pos <= 0: + return "auto" + us = _shutter_micros(pos) + return f"{us} µs" if us < 1000 else f"{us / 1000:.1f}k µs" + + +def _fps_disp(v): + return "auto" if v <= 0 else f"{v} fps" + + # --------------------------------------------------------------------------- # Control definitions # --------------------------------------------------------------------------- @@ -42,7 +92,7 @@ LABEL_W = 12 # chars for the label column # Slider: (label, command, int_min, int_max, int_default, int_step, display_fn, send_fn) # int values are internally scaled integers (avoids float rounding). # display_fn(int_val) -> str shown to the user -# send_fn(int_val) -> str sent over the socket (None = skip / handled separately) +# send_fn(int_val) -> str sent over the socket (None = handled in send_slider) def _pct(v): @@ -73,8 +123,11 @@ def _awbg(v): return f"{v / 100:.2f}" +# Order mirrors the Qt GUI group layout: +# Exposure (Brightness, EV) → Shutter/Framerate → Image (Contrast, Saturation) +# → Camera (Gain, Sharpness) → AWB (Gain R, Gain B) → Zoom SLIDERS = [ - # label cmd min max default step disp_fn send_fn + # label cmd min max default step disp_fn send_fn ( "Brightness", "brightness", @@ -84,9 +137,22 @@ SLIDERS = [ 1, _pct, lambda v: f"brightness:{v / 100:.4f}", - ), - ("EV (stops)", "ev", -30, 30, 0, 1, _ev, lambda v: f"ev:{v / 10:.2f}"), - ("Contrast", "contrast", 0, 300, 100, 5, _f2, lambda v: f"contrast:{v / 100:.4f}"), + ), # 0 + ("EV (stops)", "ev", -30, 30, 0, 1, _ev, lambda v: f"ev:{v / 10:.2f}"), # 1 + # Shutter group — SHUTTER_IDX=2, FRAMERATE_IDX=3 + ("Shutter", "shutter", 0, SHUTTER_STEPS, 0, 1, _shutter_disp, None), # 2 + ("Framerate", "framerate", 0, 120, 0, 1, _fps_disp, None), # 3 + # Image group + ( + "Contrast", + "contrast", + 0, + 300, + 100, + 5, + _f2, + lambda v: f"contrast:{v / 100:.4f}", + ), # 4 ( "Saturation", "saturation", @@ -96,8 +162,9 @@ SLIDERS = [ 5, _f2, lambda v: f"saturation:{v / 100:.4f}", - ), - ("Gain", "gain", 10, 160, 10, 5, _gain, lambda v: f"gain:{v / 10:.2f}"), + ), # 5 + # Camera group + ("Gain", "gain", 10, 160, 10, 5, _gain, lambda v: f"gain:{v / 10:.2f}"), # 6 ( "Sharpness", "sharpness", @@ -107,19 +174,12 @@ SLIDERS = [ 5, _sharp, lambda v: f"sharpness:{v / 10:.4f}", - ), - ("Zoom", "zoom", 10, 100, 10, 5, _zoom, None), # handled specially (-> roi) - ( - "AWB Gain R", - "awbgainr", - 10, - 400, - 150, - 10, - _awbg, - None, - ), # handled together with B - ("AWB Gain B", "awbgainb", 10, 400, 120, 10, _awbg, None), + ), # 7 + # AWB group + ("AWB Gain R", "awbgainr", 10, 400, 150, 10, _awbg, None), # 8 -> awbgains + ("AWB Gain B", "awbgainb", 10, 400, 120, 10, _awbg, None), # 9 -> awbgains + # Zoom group + ("Zoom", "zoom", 10, 100, 10, 5, _zoom, None), # 10 -> roi ] # Combo: (label, command, [options]) @@ -144,7 +204,7 @@ COMBOS = [ ("HDR", "hdr", ["off", "auto", "sensor", "single-exp"]), ] -# Flat list of all rows: ('camera',) | ('slider', index) | ('combo', index) +# Flat list of all rows: ('camera', 0) | ('slider', index) | ('combo', index) ROWS = ( ([("camera", 0)] if len(CAMERA_SOCKS) > 1 else []) + [("slider", i) for i in range(len(SLIDERS))] @@ -160,6 +220,7 @@ class Sock: def __init__(self, path): self._path = path self._s = None + self._buf = "" def _connect(self): try: @@ -167,6 +228,7 @@ class Sock: s.connect(self._path) s.setblocking(False) self._s = s + self._buf = "" return True except OSError: self._s = None @@ -181,11 +243,42 @@ class Sock: except OSError: self._s = None + def recv_lines(self): + """Non-blocking read; returns list of complete lines received.""" + if self._s is None: + return [] + try: + chunk = self._s.recv(4096).decode(errors="ignore") + self._buf += chunk + except BlockingIOError: + pass + except OSError: + self._s = None + return [] + lines = self._buf.split("\n") + self._buf = lines[-1] # keep partial last line + return lines[:-1] + @property def connected(self): return self._s is not None +# --------------------------------------------------------------------------- +# Caps parsing +# --------------------------------------------------------------------------- + + +def _parse_caps(caps_str): + """Parse 'maxfps=40,hasaf=0' into a dict.""" + result = {} + for kv in caps_str.split(","): + parts = kv.strip().split("=", 1) + if len(parts) == 2: + result[parts[0].strip()] = parts[1].strip() + return result + + # --------------------------------------------------------------------------- # Draw helpers # --------------------------------------------------------------------------- @@ -193,7 +286,10 @@ class Sock: def draw_slider(win, y, x, val, lo, hi, w): """Draw [====●====] with val in [lo, hi] inside w chars.""" - filled = round((val - lo) / (hi - lo) * (w - 1)) + if hi <= lo: + filled = 0 + else: + filled = round((val - lo) / (hi - lo) * (w - 1)) bar = "=" * filled + "\u25cf" + "=" * (w - 1 - filled) win.addch(y, x, "[") win.addstr(y, x + 1, bar) @@ -241,8 +337,8 @@ def main(stdscr): c_idxs = cam_c_idxs[current_cam_idx[0]] cursor = 0 # selected row - def slider_row(si): - return ROWS.index(("slider", si)) + # Sync _cur_fps with the loaded framerate state for the initial camera. + _cur_fps[0] = s_vals[FRAMERATE_IDX] def awbgains_cmd(r_val, b_val): return f"awbgains:{r_val / 100:.3f},{b_val / 100:.3f}" @@ -256,11 +352,12 @@ def main(stdscr): return f"roi:{x:.4f},{y:.4f},{w:.4f},{h:.4f}" def _set_awb_manual(): - """Switch AWB combo to 'manual' without sending an awb: command.""" - awb_ci = 0 # AWB mode is always combo index 0 - opts = COMBOS[awb_ci][2] - manual_idx = opts.index("manual") - c_idxs[awb_ci] = manual_idx + opts = COMBOS[0][2] + c_idxs[0] = opts.index("manual") + + def _framerate_max(): + """Effective maximum for the framerate slider.""" + return _caps["maxfps"] def send_slider(si, val): send_fn = SLIDERS[si][7] @@ -270,10 +367,15 @@ def main(stdscr): sock.send(zoom_cmd(val)) elif SLIDERS[si][0] == "AWB Gain R": _set_awb_manual() - sock.send(awbgains_cmd(val, s_vals[8])) + sock.send(awbgains_cmd(val, s_vals[9])) # 9 = AWB Gain B elif SLIDERS[si][0] == "AWB Gain B": _set_awb_manual() - sock.send(awbgains_cmd(s_vals[7], val)) + sock.send(awbgains_cmd(s_vals[8], val)) # 8 = AWB Gain R + elif SLIDERS[si][0] == "Shutter": + sock.send(f"shutter:{_shutter_micros(val)}") + elif SLIDERS[si][0] == "Framerate": + _cur_fps[0] = val + sock.send(f"framerate:{val}") def send_combo(ci, idx): label, cmd, opts = COMBOS[ci] @@ -283,7 +385,7 @@ def main(stdscr): nonlocal s_vals, c_idxs s_vals[:] = [s[4] for s in SLIDERS] c_idxs[:] = [0] * len(COMBOS) - # Keep the per-camera store and state file in sync. + _cur_fps[0] = 0 cam_s_vals[current_cam_idx[0]] = list(s_vals) cam_c_idxs[current_cam_idx[0]] = list(c_idxs) save_cam_state(current_cam_idx[0], s_vals, c_idxs) @@ -294,6 +396,8 @@ def main(stdscr): sock.send("gain:0") sock.send("sharpness:1") sock.send("roi:0,0,1,1") + sock.send("shutter:0") + sock.send("framerate:0") sock.send("awb:auto") sock.send("metering:centre") sock.send("exposure:normal") @@ -301,15 +405,8 @@ def main(stdscr): sock.send("hdr:off") def resend_full_state(): - """Send the complete current state to the newly connected camera. - - AWB gain sliders are skipped when AWB mode is 'auto': sending - awbgains would conflict with the camera's automatic gain control - and the _set_awb_manual() side effect would corrupt c_idxs[0]. - When AWB is 'manual' the gains are sent and c_idxs[0] is already - correct, so no snapshot is needed. - """ - awb_is_auto = c_idxs[0] == 0 # COMBOS[0] options[0] == "auto" + """Send the complete current state to the newly connected camera.""" + awb_is_auto = c_idxs[0] == 0 for si, sv in enumerate(s_vals): if awb_is_auto and SLIDERS[si][0] in ("AWB Gain R", "AWB Gain B"): continue @@ -321,33 +418,47 @@ def main(stdscr): nonlocal s_vals, c_idxs if new_ci == current_cam_idx[0]: return - # Save current camera state (memory + file). cam_s_vals[current_cam_idx[0]] = list(s_vals) cam_c_idxs[current_cam_idx[0]] = list(c_idxs) save_cam_state(current_cam_idx[0], s_vals, c_idxs) - # Switch. current_cam_idx[0] = new_ci current_sock_path[0] = CAMERA_SOCKS[new_ci] - # Restore target camera state into the active view. s_vals[:] = cam_s_vals[new_ci] c_idxs[:] = cam_c_idxs[new_ci] - # Reconnect and resync hardware. + # Reset caps to defaults for new camera (will be updated on connect). + _caps["maxfps"] = 120 + _cur_fps[0] = s_vals[FRAMERATE_IDX] sock._s = None sock._path = current_sock_path[0] + sock._buf = "" if sock._connect(): resend_full_state() # State loaded into UI; do NOT resend to camera on startup — - # the camera is already running with its current settings. + # the camera already runs with its current settings. while True: + # --- Poll socket for incoming caps messages --- + for line in sock.recv_lines(): + if line.startswith("caps:"): + parsed = _parse_caps(line[5:]) + if "maxfps" in parsed: + try: + fps = int(parsed["maxfps"]) + if fps > 0: + _caps["maxfps"] = fps + # Clamp framerate slider if it exceeds new max. + if s_vals[FRAMERATE_IDX] > fps: + s_vals[FRAMERATE_IDX] = 0 + _cur_fps[0] = 0 + except ValueError: + pass + height, width = stdscr.getmaxyx() stdscr.erase() try: - # --- Header --- - title = "rpicam-vid Runtime Control" - stdscr.addstr(0, 2, title, curses.A_BOLD) + stdscr.addstr(0, 2, "rpicam-vid Runtime Control", curses.A_BOLD) base_y = 2 CTRL_W = SLIDER_W + 2 # [bar] width incl. brackets @@ -377,10 +488,13 @@ def main(stdscr): stdscr.addstr(y, 2 + LABEL_W, sock_short, attr) stdscr.addstr(y, 2 + LABEL_W + CTRL_W + 1, " cam ", attr) stdscr.addstr(str(cam_idx), curses.color_pair(2)) + elif kind == "slider": - lo, hi = SLIDERS[idx][2], SLIDERS[idx][3] - val = s_vals[idx] label = SLIDERS[idx][0] + lo = SLIDERS[idx][2] + # For framerate, effective max comes from caps. + hi = _framerate_max() if idx == FRAMERATE_IDX else SLIDERS[idx][3] + val = s_vals[idx] disp = SLIDERS[idx][6](val) stdscr.addstr(y, 0, prefix, attr) stdscr.addstr( @@ -391,6 +505,7 @@ def main(stdscr): ) draw_slider(stdscr, y, 2 + LABEL_W, val, lo, hi, SLIDER_W) stdscr.addstr(y, 2 + LABEL_W + CTRL_W + 1, f" {disp}", attr) + else: label, cmd, opts = COMBOS[idx] stdscr.addstr(y, 0, prefix, attr) @@ -402,32 +517,30 @@ def main(stdscr): ) draw_combo(stdscr, y, 2 + LABEL_W, opts, c_idxs[idx], SLIDER_W) - # Footer foot_y = base_y + len(ROWS) + 1 if foot_y < height: status = ( - f" Connected to {current_sock_path[0]}" + f" Connected to {current_sock_path[0]} [max {_caps['maxfps']} fps]" if sock.connected else f" Disconnected ({current_sock_path[0]}) \u2014 retrying on next command" ) col = curses.color_pair(2) if sock.connected else curses.color_pair(3) stdscr.addstr(foot_y, 0, status[: width - 1], col) if foot_y + 1 < height: - cam_hint = " C switch cam" if len(CAMERA_SOCKS) > 1 else "" stdscr.addstr( foot_y + 1, 2, "\u2190\u2192 adjust \u2191\u2193 navigate R reset Q quit", curses.A_DIM, ) - if cam_hint and foot_y + 2 < height: - stdscr.addstr(foot_y + 2, 2, "C switch cam", curses.A_DIM) + if foot_y + 2 < height and len(CAMERA_SOCKS) > 1: + stdscr.addstr(foot_y + 2, 2, "C switch cam", curses.A_DIM) + except curses.error: pass # terminal too small — skip frame stdscr.refresh() - # Input (non-blocking) try: key = stdscr.getch() except Exception: @@ -449,11 +562,11 @@ def main(stdscr): kind, idx = ROWS[cursor] d = 1 if key == curses.KEY_RIGHT else -1 if kind == "camera": - ci = current_cam_idx[0] - do_switch_camera((ci + d) % len(CAMERA_SOCKS)) + do_switch_camera((current_cam_idx[0] + d) % len(CAMERA_SOCKS)) elif kind == "slider": step = SLIDERS[idx][5] - lo, hi = SLIDERS[idx][2], SLIDERS[idx][3] + lo = SLIDERS[idx][2] + hi = _framerate_max() if idx == FRAMERATE_IDX else SLIDERS[idx][3] s_vals[idx] = max(lo, min(hi, s_vals[idx] + d * step)) send_slider(idx, s_vals[idx]) else: @@ -462,7 +575,6 @@ def main(stdscr): send_combo(idx, c_idxs[idx]) elif key in (ord("c"), ord("C")): - # Cycle to next camera socket do_switch_camera((current_cam_idx[0] + 1) % len(CAMERA_SOCKS)) elif key == -1: @@ -481,18 +593,23 @@ def run(): pass -# Named keys for the shared state file — must match the GUI's JSON format. -# Order corresponds to SLIDERS list. +# --------------------------------------------------------------------------- +# State persistence — JSON, compatible with the Qt GUI's state format. +# --------------------------------------------------------------------------- + +# Named keys in the state file — order must match SLIDERS list. _SLIDER_KEYS = [ "brightness", # SLIDERS[0] "ev", # SLIDERS[1] - "contrast", # SLIDERS[2] - "saturation", # SLIDERS[3] - "gain", # SLIDERS[4] - "sharpness", # SLIDERS[5] - "zoom", # SLIDERS[6] - "awbGainR", # SLIDERS[7] - "awbGainB", # SLIDERS[8] + "shutter", # SLIDERS[2] — log-scale position (0=auto, 1..SHUTTER_STEPS) + "framerateIdx", # SLIDERS[3] — fps directly (0=auto, 1..N) + "contrast", # SLIDERS[4] + "saturation", # SLIDERS[5] + "gain", # SLIDERS[6] + "sharpness", # SLIDERS[7] + "awbGainR", # SLIDERS[8] + "awbGainB", # SLIDERS[9] + "zoom", # SLIDERS[10] ] _COMBO_KEYS = [ "awbIdx", # COMBOS[0] @@ -511,7 +628,6 @@ def _default_cam_state(): def _state_file(cam_idx): - """Return the shared state file path for a camera index.""" return Path("/tmp") / f"rpicam-vid{cam_idx}.state" @@ -528,27 +644,28 @@ def load_cam_state(cam_idx): for i, key in enumerate(_COMBO_KEYS): if key in data: s["c_idxs"][i] = int(data[key]) + # Clamp shutter to valid range. + s["s_vals"][SHUTTER_IDX] = max(0, min(SHUTTER_STEPS, s["s_vals"][SHUTTER_IDX])) return s except Exception: return _default_cam_state() def load_state(): - """Return list of per-camera state dicts (one per CAMERA_SOCKS entry).""" return [load_cam_state(i) for i in range(len(CAMERA_SOCKS))] def save_cam_state(cam_idx, s_vals, c_idxs): - """Write state atomically to /tmp/rpicam-vid{N}.state (named-key JSON, GUI-compatible).""" + """Write state atomically to /tmp/rpicam-vid{N}.state.""" try: target = _state_file(cam_idx) tmp = Path(str(target) + ".tmp") data = {key: s_vals[i] for i, key in enumerate(_SLIDER_KEYS)} data.update({key: c_idxs[i] for i, key in enumerate(_COMBO_KEYS)}) tmp.write_text(json.dumps(data, indent=2)) - os.replace(tmp, target) # atomic on POSIX + os.replace(tmp, target) except Exception: - pass # non-fatal + pass def save_state(cam_s_vals, cam_c_idxs): diff --git a/utils/rpicam_vid_gui/main.cpp b/utils/rpicam_vid_gui/main.cpp index d21be2e6..e6ec1b71 100644 --- a/utils/rpicam_vid_gui/main.cpp +++ b/utils/rpicam_vid_gui/main.cpp @@ -12,6 +12,7 @@ * ninja -C build */ +#include #include #include @@ -27,11 +28,14 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -41,8 +45,150 @@ static constexpr int RECONN_MS = 2000; static constexpr int DEBOUNCE_MS = 60; // --------------------------------------------------------------------------- -// Per-camera UI state snapshot (slider integer values + combo indices). -// Default-initialised members match the widget defaults set in buildUi(). +// Camera capability probe (via rpicam-hello --list-cameras) +// --------------------------------------------------------------------------- + +struct CameraInfo +{ + bool present = false; + bool hasAf = false; + int maxFps = 120; // populated from --list-cameras output + QString model; +}; + +// Known cameras that support autofocus. +static const QSet kAfCameraModels = { + "imx708", // Raspberry Pi Camera Module 3 + "arducam_64mp", // Arducam 64 MP + "arducam_imx519", // Arducam 16 MP + "arducam_imx708", // Arducam imx708 variant +}; + +// Runs "rpicam-hello --list-cameras", parses output and returns info for +// cameras 0 and 1. If the probe fails (command not found, timeout, …) both +// entries remain at their zero-initialised defaults so the GUI falls back to +// showing all controls without restriction. +static std::array probeCameras() +{ + std::array info; + + QProcess proc; + proc.setProcessChannelMode(QProcess::MergedChannels); + proc.start("rpicam-hello", { "--list-cameras" }); + if (!proc.waitForFinished(4000)) + { + proc.kill(); + return info; + } + + const QString out = proc.readAllStandardOutput(); + + // Lines of interest look like: "0 : imx708 [4608x2592 …]" + QRegularExpression re(R"(^\s*(\d+)\s*:\s*(\S+)\s*\[)"); + // Mode lines look like: " 2028x1520 [40.01 fps - …]" + QRegularExpression reFps(R"(\[(\d+\.?\d*)\s+fps)"); + int parsedCamIdx = -1; + for (const QString &line : out.split('\n')) + { + const auto m = re.match(line); + if (m.hasMatch()) + { + parsedCamIdx = m.captured(1).toInt(); + if (parsedCamIdx < 0 || parsedCamIdx >= 2) + { + parsedCamIdx = -1; + continue; + } + const QString model = m.captured(2).toLower(); + info[parsedCamIdx].present = true; + info[parsedCamIdx].model = model; + info[parsedCamIdx].hasAf = kAfCameraModels.contains(model); + info[parsedCamIdx].maxFps = 0; // filled in by mode lines below + } + else if (parsedCamIdx >= 0) + { + const auto mFps = reFps.match(line); + if (mFps.hasMatch()) + { + const int fps = static_cast(mFps.captured(1).toDouble()); + info[parsedCamIdx].maxFps = std::max(info[parsedCamIdx].maxFps, fps); + } + } + } + // Ensure a sensible default for cameras where no fps line was found. + for (auto &ci : info) + if (ci.present && ci.maxFps == 0) + ci.maxFps = 120; + return info; +} + +// --------------------------------------------------------------------------- +// Framerate slider: value 0 = auto, 1..N = fps directly (1 fps integer steps). +// --------------------------------------------------------------------------- +static constexpr int FPS_SLIDER_MAX_DEFAULT = 120; // overridden by caps/probe + +// Returns the display label for a framerate slider value. +static QString framerateLabel(int fps) +{ + return fps <= 0 ? "auto" : QString("%1 fps").arg(fps); +} + +// --------------------------------------------------------------------------- +// Shutter slider: position 0 = auto; 1..SHUTTER_STEPS maps logarithmically +// to 100 µs .. maxShutterUs(fpsIdx). The upper bound is dynamic: it equals +// 1 000 000 / fps so the shutter can never exceed one frame period. +// --------------------------------------------------------------------------- +static constexpr int SHUTTER_STEPS = 200; + +// Returns the maximum meaningful shutter in µs for the given fps slider index. +// fallbackMaxFps is used when fpsIdx == 0 (auto); it comes from the caps message. +static int maxShutterUs(int fpsSlidVal, int fallbackMaxFps) +{ + double fps = static_cast(fpsSlidVal); + if (fps <= 0.0) + fps = (fallbackMaxFps > 0) ? static_cast(fallbackMaxFps) : 30.0; + if (fps < 1.0) + fps = 1.0; // floor at 1 fps + return static_cast(1'000'000.0 / fps); +} + +static int shutterMicros(int sliderVal, int maxUs) +{ + if (sliderVal <= 0) + return 0; + const double lo = 100.0; + const double hi = static_cast(maxUs); + const double t = static_cast(sliderVal - 1) / (SHUTTER_STEPS - 1); + return static_cast(std::round(lo * std::pow(hi / lo, t))); +} + +// Inverse of shutterMicros: find the closest slider position for targetUs. +static int shutterSliderForMicros(int targetUs, int maxUs) +{ + if (targetUs <= 0) + return 0; + const double lo = 100.0; + const double hi = static_cast(maxUs); + if (targetUs <= static_cast(lo)) + return 1; + if (targetUs >= maxUs) + return SHUTTER_STEPS; + const double t = std::log(static_cast(targetUs) / lo) / std::log(hi / lo); + return std::clamp(static_cast(std::round(t * (SHUTTER_STEPS - 1))) + 1, 1, SHUTTER_STEPS); +} + +static QString shutterLabel(int sliderVal, int maxUs) +{ + if (sliderVal <= 0) + return "auto"; + const int us = shutterMicros(sliderVal, maxUs); + if (us < 1000) + return QString("%1 µs").arg(us); + if (us < 1'000'000) + return QString("%1 ms").arg(us / 1000.0, 0, 'f', 1); + return QString("%1 s").arg(us / 1'000'000.0, 0, 'f', 2); +} + // --------------------------------------------------------------------------- struct CameraState { @@ -60,6 +206,10 @@ struct CameraState int exposureIdx = 0; int denoiseIdx = 0; int hdrIdx = 0; + int shutter = 0; // slider position 0..SHUTTER_STEPS; 0=auto, log-scale -> shutterMicros() + int framerateIdx = 0; // fps directly: 0=auto, 1..N fps + int afIdx = 0; // 0=auto, 1=continuous, 2=manual + int lens = 0; // 0..100; val/10.0 = LensPosition }; // --------------------------------------------------------------------------- @@ -83,6 +233,7 @@ class ControlWindow : public QMainWindow else if (s == QLocalSocket::UnconnectedState) onDisconnected(); }); + connect(sock_, &QLocalSocket::readyRead, this, &ControlWindow::onSocketData); reconnTimer_ = new QTimer(this); reconnTimer_->setInterval(RECONN_MS); @@ -93,6 +244,23 @@ class ControlWindow : public QMainWindow debounceTimer_->setInterval(DEBOUNCE_MS); connect(debounceTimer_, &QTimer::timeout, this, &ControlWindow::flush); + // Probe available cameras before building the UI so that buildUi() + // can populate the camera combo and set AF controls accordingly. + camInfo_ = probeCameras(); + + // Auto-select cam1 if only cam1 has a live rpicam-vid socket. + // This handles the case where cam0 is listed by libcamera but is not + // actually running (e.g. hardware fault), while cam1 is active. + // Socket presence is a more reliable signal than probe output. + // Must happen before buildUi() so the combo is initialized correctly. + const bool sock0 = QFile::exists("/tmp/rpicam-vid0.sock"); + const bool sock1 = QFile::exists("/tmp/rpicam-vid1.sock"); + if (!sock0 && sock1) + { + currentCameraIdx_ = 1; + currentSockPath_ = "/tmp/rpicam-vid1.sock"; + } + buildUi(); loadAllStatesFromFiles(); tryConnect(); @@ -172,6 +340,54 @@ private slots: reconnTimer_->start(); } + // Receives capability information pushed by rpicam-vid on connect. + // Format: "caps:maxfps=,hasaf=<0|1>\n" + void onSocketData() + { + while (sock_->canReadLine()) + { + const QString line = QString::fromUtf8(sock_->readLine()).trimmed(); + if (line.startsWith("caps:")) + parseCaps(line.mid(5)); + } + } + + void parseCaps(const QString &caps) + { + for (const QString &kv : caps.split(',')) + { + const QStringList parts = kv.split('='); + if (parts.size() != 2) + continue; + const QString key = parts[0].trimmed(); + const QString val = parts[1].trimmed(); + if (key == "maxfps") + { + const int fps = val.toInt(); + if (fps > 0 && currentCameraIdx_ >= 0 && currentCameraIdx_ < 2) + { + // Store the authoritative max fps from the active sensor mode. + // Used by maxShutterUs() when framerate is set to auto. + camInfo_[currentCameraIdx_].maxFps = fps; + // Clamp the framerate slider to the hardware max. + QSignalBlocker b(framerateSlider_); + framerateSlider_->setMaximum(fps); + if (framerateSlider_->value() > fps) + { + framerateSlider_->setValue(0); + framerateVal_->setText("auto"); + } + } + } + else if (key == "hasaf") + { + if (currentCameraIdx_ >= 0 && currentCameraIdx_ < 2) + camInfo_[currentCameraIdx_].hasAf = (val == "1"); + applyCameraCapabilities(currentCameraIdx_); + } + } + } + // Queue a key:value command and (re)start the debounce window. void queue(const QString &key, const QString &value) { @@ -259,6 +475,45 @@ private slots: queue("sharpness", QString::number(val, 'f', 1)); } + void onShutterChanged(int v) + { + const int maxUs = maxShutterUs(framerateSlider_->value(), camInfo_[currentCameraIdx_].maxFps); + shutterVal_->setText(shutterLabel(v, maxUs)); + queue("shutter", QString::number(shutterMicros(v, maxUs))); + } + + void onFramerateChanged(int fps) + { + framerateVal_->setText(framerateLabel(fps)); + // Send 0 for auto, otherwise the integer fps value. + queue("framerate", fps <= 0 ? "0" : QString::number(fps)); + + // Rescale the shutter slider: preserve the current µs value as closely + // as possible, but clamp to the new maximum (= one frame period). + const int newMaxUs = maxShutterUs(fps, camInfo_[currentCameraIdx_].maxFps); + const int currentUs = shutterMicros(shutterSlider_->value(), newMaxUs); + const int clampedUs = std::min(currentUs, newMaxUs); + { + QSignalBlocker b(shutterSlider_); + shutterSlider_->setValue(shutterSliderForMicros(clampedUs, newMaxUs)); + } + shutterVal_->setText(shutterLabel(shutterSlider_->value(), newMaxUs)); + } + + void onAfChanged(int idx) + { + static const QStringList modes = { "auto", "continuous", "manual" }; + lensSlider_->setEnabled(idx == 2); + if (idx >= 0 && idx < modes.size()) + sendNow("af:" + modes[idx]); + } + + void onLensChanged(int v) + { + lensVal_->setText(QString::number(v / 10.0, 'f', 1)); + queue("lens", QString::number(v / 10.0, 'f', 1)); + } + void onAwbChanged(int idx) { static const QStringList modes = { "auto", "incandescent", "tungsten", "fluorescent", @@ -359,6 +614,10 @@ private slots: o["exposureIdx"] = c.exposureIdx; o["denoiseIdx"] = c.denoiseIdx; o["hdrIdx"] = c.hdrIdx; + o["shutter"] = c.shutter; + o["framerateIdx"] = c.framerateIdx; // fps directly (0 = auto) + o["afIdx"] = c.afIdx; + o["lens"] = c.lens; // Write atomically: QSaveFile writes to a temp file and renames on // commit, overwriting any existing target file. QSaveFile f(stateFilePath(camIdx)); @@ -392,6 +651,15 @@ private slots: c.exposureIdx = o.value("exposureIdx").toInt(c.exposureIdx); c.denoiseIdx = o.value("denoiseIdx").toInt(c.denoiseIdx); c.hdrIdx = o.value("hdrIdx").toInt(c.hdrIdx); + c.shutter = std::min(o.value("shutter").toInt(c.shutter), SHUTTER_STEPS); + // framerateIdx is the fps directly (0=auto, 1..N fps). + // Accept legacy "framerate" float key for backwards compat. + if (o.contains("framerateIdx")) + c.framerateIdx = std::clamp(o.value("framerateIdx").toInt(0), 0, 240); + else + c.framerateIdx = std::clamp(static_cast(std::round(o.value("framerate").toDouble(0.0))), 0, 240); + c.afIdx = o.value("afIdx").toInt(c.afIdx); + c.lens = o.value("lens").toInt(c.lens); return c; } @@ -421,6 +689,10 @@ private slots: s.exposureIdx = exposureCombo_->currentIndex(); s.denoiseIdx = denoiseCombo_->currentIndex(); s.hdrIdx = hdrCombo_->currentIndex(); + s.shutter = shutterSlider_->value(); + s.framerateIdx = framerateSlider_->value(); + s.afIdx = afCombo_->currentIndex(); + s.lens = lensSlider_->value(); } void applyUiState(const CameraState &s) @@ -429,6 +701,7 @@ private slots: QSignalBlocker b5(zoomSlider_), b6(gainSlider_), b7(sharpnessSlider_); QSignalBlocker b8(awbGainRSlider_), b9(awbGainBSlider_), b10(awbCombo_); QSignalBlocker b11(meteringCombo_), b12(exposureCombo_), b13(denoiseCombo_), b14(hdrCombo_); + QSignalBlocker b15(shutterSlider_), b16(framerateSlider_), b17(afCombo_), b18(lensSlider_); brightnessSlider_->setValue(s.brightness); evSlider_->setValue(s.ev); @@ -444,6 +717,11 @@ private slots: exposureCombo_->setCurrentIndex(s.exposureIdx); denoiseCombo_->setCurrentIndex(s.denoiseIdx); hdrCombo_->setCurrentIndex(s.hdrIdx); + shutterSlider_->setValue(s.shutter); + framerateSlider_->setValue(s.framerateIdx); + afCombo_->setCurrentIndex(s.afIdx); + lensSlider_->setValue(s.lens); + lensSlider_->setEnabled(s.afIdx == 2); brightnessVal_->setText(QString::asprintf("%+.2f", s.brightness / 100.0)); evVal_->setText(QString::asprintf("%+.1f", s.ev / 10.0)); @@ -464,6 +742,11 @@ private slots: awbGainRVal_->setText("auto"); awbGainBVal_->setText("auto"); } + + const int maxUs = maxShutterUs(s.framerateIdx, camInfo_[currentCameraIdx_].maxFps); + shutterVal_->setText(shutterLabel(s.shutter, maxUs)); + framerateVal_->setText(framerateLabel(s.framerateIdx)); + lensVal_->setText(QString::number(s.lens / 10.0, 'f', 1)); } void sendCameraState(const CameraState &s) @@ -506,6 +789,14 @@ private slots: batch += "denoise:" + denoiseModes[s.denoiseIdx] + "\n"; batch += "hdr:" + hdrModes[s.hdrIdx] + "\n"; + if (s.shutter != 0) + { + const int maxUs = maxShutterUs(s.framerateIdx, camInfo_[currentCameraIdx_].maxFps); + batch += QString("shutter:%1\n").arg(shutterMicros(s.shutter, maxUs)); + } + if (s.framerateIdx != 0) + batch += QString("framerate:%1\n").arg(s.framerateIdx); + sendNow(batch.trimmed()); } @@ -529,6 +820,8 @@ private slots: sock_->abort(); // Restore the target camera's UI immediately (no flickering wait). applyUiState(cameraStates_[currentCameraIdx_]); + // Show / hide AF controls based on the new camera's capabilities. + applyCameraCapabilities(currentCameraIdx_); // Immediately attempt connection to the new socket; the reconnect // timer serves only as a fallback if this first attempt fails. tryConnect(); @@ -544,6 +837,7 @@ private slots: QSignalBlocker b5(zoomSlider_), b6(awbCombo_), b7(gainSlider_), b8(sharpnessSlider_); QSignalBlocker b9(meteringCombo_), b10(exposureCombo_), b11(denoiseCombo_), b12(hdrCombo_); QSignalBlocker b13(awbGainRSlider_), b14(awbGainBSlider_); + QSignalBlocker b15(shutterSlider_), b16(framerateSlider_), b17(afCombo_), b18(lensSlider_); brightnessSlider_->setValue(0); evSlider_->setValue(0); @@ -559,6 +853,11 @@ private slots: hdrCombo_->setCurrentIndex(0); awbGainRSlider_->setValue(150); awbGainBSlider_->setValue(120); + shutterSlider_->setValue(0); + framerateSlider_->setValue(0); // index 0 = auto + afCombo_->setCurrentIndex(0); + lensSlider_->setValue(0); + lensSlider_->setEnabled(false); brightnessVal_->setText("+0.00"); evVal_->setText("+0.0"); @@ -569,6 +868,9 @@ private slots: sharpnessVal_->setText("1.0"); awbGainRVal_->setText("auto"); awbGainBVal_->setText("auto"); + shutterVal_->setText("auto"); + framerateVal_->setText("auto"); // framerateIdx reset to 0 + lensVal_->setText("0.0"); sendNow("brightness:0.00\n" "ev:0.0\n" @@ -581,13 +883,49 @@ private slots: "metering:centre\n" "exposure:normal\n" "denoise:auto\n" - "hdr:off"); + "hdr:off\n" + "shutter:0\n" + "framerate:0"); // 0 restores AE-managed framerate // Keep the stored state in sync with the reset UI. - cameraStates_[currentCameraIdx_] = CameraState {}; + cameraStates_[currentCameraIdx_] = CameraState {}; // framerateIdx=0 = auto } private: + // ------------------------------------------------------------------ + // AF visibility helper + // ------------------------------------------------------------------ + + // Returns true if the camera at camIdx supports AF, or if no probe data + // is available (show controls rather than hide them in that case). + bool cameraHasAf(int camIdx) const + { + const bool anyFound = camInfo_[0].present || camInfo_[1].present; + if (!anyFound) + return true; // no probe data → don't hide anything + if (camIdx < 0 || camIdx >= 2) + return false; + return camInfo_[camIdx].hasAf; + } + + void applyCameraCapabilities(int camIdx) + { + // Set the framerate slider range based on the probe data. + // Slider value = fps directly (0=auto, 1..maxFps). + const bool anyFound = camInfo_[0].present || camInfo_[1].present; + if (anyFound && camIdx >= 0 && camIdx < 2 && camInfo_[camIdx].maxFps > 0) + { + const int maxFps = camInfo_[camIdx].maxFps; + QSignalBlocker b(framerateSlider_); + framerateSlider_->setMaximum(maxFps); + if (framerateSlider_->value() > maxFps) + { + framerateSlider_->setValue(0); + framerateVal_->setText("auto"); + } + } + } + // ------------------------------------------------------------------ // UI construction // ------------------------------------------------------------------ @@ -601,12 +939,35 @@ private slots: root->setSpacing(2); root->setContentsMargins(8, 8, 8, 4); - // Camera selector + // Camera selector — only list cameras that are detected; if the probe + // found nothing at all (command not available etc.) fall back to "0"/"1". auto *camBar = new QHBoxLayout; camBar->addWidget(new QLabel("Camera:")); cameraCombo_ = new QComboBox; - cameraCombo_->addItems({ "0", "1" }); - cameraCombo_->setMaximumWidth(90); + + const bool anyFound = camInfo_[0].present || camInfo_[1].present; + for (int i = 0; i < 2; ++i) + { + QString label = QString("Cam %1").arg(i); + if (camInfo_[i].present && !camInfo_[i].model.isEmpty()) + label += " · " + camInfo_[i].model; + else if (!camInfo_[i].present && anyFound) + label += " (—)"; + cameraCombo_->addItem(label); + + // Grey out / disable unavailable entries. + if (!camInfo_[i].present && anyFound) + { + auto *m = qobject_cast(cameraCombo_->model()); + if (m) + m->item(i)->setEnabled(false); + } + } + + cameraCombo_->setMaximumWidth(200); + // Initialize to the pre-selected camera (set before buildUi() runs). + // Must happen before connecting the signal so no spurious onCameraChanged fires. + cameraCombo_->setCurrentIndex(currentCameraIdx_); connect(cameraCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, &ControlWindow::onCameraChanged); camBar->addWidget(cameraCombo_); @@ -614,12 +975,16 @@ private slots: root->addLayout(camBar); root->addWidget(buildExposureGroup()); + root->addWidget(buildShutterGroup()); root->addWidget(buildImageGroup()); root->addWidget(buildCameraGroup()); root->addWidget(buildAwbGroup()); root->addWidget(buildZoomGroup()); + focusGroup_ = buildFocusGroup(); // built but not added to layout — AF hidden for now root->addWidget(buildAdvancedGroup()); + applyCameraCapabilities(currentCameraIdx_); + auto *resetBtn = new QPushButton("Reset all to defaults"); resetBtn->setFixedHeight(34); connect(resetBtn, &QPushButton::clicked, this, &ControlWindow::onReset); @@ -761,6 +1126,58 @@ private slots: return group; } + QWidget *buildShutterGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + grid->setColumnStretch(1, 1); + + shutterSlider_ = addRow(grid, 0, "Shutter", 0, SHUTTER_STEPS, 0, &shutterVal_); + shutterVal_->setText("auto"); + connect(shutterSlider_, &QSlider::valueChanged, this, &ControlWindow::onShutterChanged); + + // Framerate slider range: 0=auto, 1..FPS_STEP_COUNT; max clamped by caps/probe later. + framerateSlider_ = addRow(grid, 1, "Framerate", 0, FPS_SLIDER_MAX_DEFAULT, 0, &framerateVal_); + framerateVal_->setText("auto"); + connect(framerateSlider_, &QSlider::valueChanged, this, &ControlWindow::onFramerateChanged); + + return group; + } + + QWidget *buildFocusGroup() + { + auto *group = new QFrame; + group->setFrameShape(QFrame::StyledPanel); + auto *grid = new QGridLayout(group); + grid->setSpacing(3); + grid->setContentsMargins(6, 4, 6, 4); + grid->setColumnStretch(1, 1); + + auto *afLbl = new QLabel("AF mode:"); + afLbl->setFixedWidth(90); + grid->addWidget(afLbl, 0, 0); + afCombo_ = new QComboBox; + afCombo_->addItems({ "auto", "continuous", "manual" }); + afCombo_->setMaximumWidth(120); + connect(afCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, &ControlWindow::onAfChanged); + grid->addWidget(afCombo_, 0, 1); + + auto *trigBtn = new QPushButton("Trigger AF"); + trigBtn->setMaximumWidth(100); + connect(trigBtn, &QPushButton::clicked, this, [this] { sendNow("af:trigger"); }); + grid->addWidget(trigBtn, 0, 2); + + lensSlider_ = addRow(grid, 1, "Lens pos.", 0, 100, 0, &lensVal_); + lensVal_->setText("0.0"); + lensSlider_->setEnabled(false); + connect(lensSlider_, &QSlider::valueChanged, this, &ControlWindow::onLensChanged); + + return group; + } + QWidget *buildZoomGroup() { auto *group = new QFrame; @@ -812,6 +1229,7 @@ private slots: QString currentSockPath_ = "/tmp/rpicam-vid0.sock"; int currentCameraIdx_ = 0; CameraState cameraStates_[2]; + std::array camInfo_; QComboBox *cameraCombo_; QSlider *brightnessSlider_; @@ -837,6 +1255,14 @@ private slots: QComboBox *exposureCombo_; QComboBox *denoiseCombo_; QComboBox *hdrCombo_; + QSlider *shutterSlider_; + QSlider *framerateSlider_; + QSlider *lensSlider_; + QLabel *shutterVal_; + QLabel *framerateVal_; + QLabel *lensVal_; + QComboBox *afCombo_; + QWidget *focusGroup_ = nullptr; }; #include "main.moc"