Program Listing for File video_grabber.hpp#

Return to documentation for file (src/video/video_grabber.hpp)

#pragma once
#include <QImage>
#include <QString>
#include <QThread>
#include <QVector>
#include <atomic>
#include <memory>

#include "core/settings.hpp"
#include "utils/ring_buffer.hpp"
#include "video/video_frame.hpp"

namespace mosaic {

// One physically-connected camera found by VideoGrabber::enumerate_devices().
struct DiscoveredCamera {
    QString serialNumber;
    QString modelName;
    QString ipAddress;
};

// Grabs frames from one camera and pushes them into a shared ring buffer.
//
// When MOSAIC_HAVE_CAMERAS is defined: uses Basler Pylon SDK.
// Otherwise: emits a BGR8 test-pattern (colour bars) at the configured FPS.
//
// The grabber runs in a dedicated QThread. Call open() from the main thread
// before calling start(); the grab loop runs inside run().
//
// Frame timing uses elapsed_ns() (steady_clock) + wall_clock_ns() so
// downstream consumers can align to both an absolute wall timestamp and
// a drift-free monotonic timeline.

class VideoGrabber : public QThread {
    Q_OBJECT
   public:
    // frameBuffer must outlive this object.
    explicit VideoGrabber(int cameraIndex, const CameraParameters& params,
                          RingBuffer<std::shared_ptr<VideoFrame>>& frameBuffer,
                          QObject* parent = nullptr);
    ~VideoGrabber() override;

    // Opens the camera device (Pylon) or prepares the stub generator.
    // Safe to call from the main thread before start().
    [[nodiscard]] bool open();
    void close();

    // Requests that the subset of parameters safe to change on an
    // already-open, actively-grabbing camera (exposure, gain, gamma, black
    // level, white balance, auto-exposure target, digital shift) be
    // re-applied, without stopping or reopening the device. Reads current
    // values straight from the CameraParameters reference passed to the
    // constructor, so the caller just needs to have already mutated it
    // (e.g. via the settings UI) before calling this. Safe to call from any
    // thread — the actual node writes happen on the grab thread (shortly
    // after, between frames) rather than synchronously here, since Pylon's
    // camera/node-map access isn't documented as safe to use concurrently
    // with the grab thread's own RetrieveResult() calls. Structural
    // parameters (resolution, pixel format, frame rate, hardware trigger)
    // require a full close+reopen and are intentionally not touched here.
    // No-op if the device isn't open, or in stub builds.
    void apply_live_params();

    // Start / stop the grab loop (QThread::start / requestInterruption + wait).
    void start_grabbing();
    void stop_grabbing();

    // One-shot latch: the NEXT frame this grabber captures after this call
    // is emitted via calibration_frame_ready() at full resolution, in
    // addition to (not instead of) the normal ring-buffer push and the
    // downscaled preview_frame() signal. Used by room calibration, which
    // needs real, undownscaled pixels for accurate ChArUco corner detection
    // — preview_frame() is deliberately capped to 640×360 for the live
    // monitor and is not suitable for that. Safe to call from any thread.
    //
    // token is echoed back verbatim on calibration_frame_ready() so a caller
    // that issues one request per "shot" (e.g. RoomCalibrationW) can reject a
    // reply that arrives after it has already moved on to a later shot,
    // instead of relying only on timing (a delayed reply — e.g. from GigE
    // packet loss/resend — could otherwise be silently misattributed to the
    // wrong shot).
    void request_calibration_frame(uint64_t token = 0);

    // True only once the grab thread has actually reached Pylon's
    // StartGrabbing() (or, in stub builds, the equivalent point in
    // run_stub_loop()) — NOT merely once start_grabbing() has returned,
    // which only confirms the thread was scheduled to start, not that the
    // camera is actually listening for triggers yet. Used by
    // VideoManager::arm_and_fire_action_commands() to avoid firing the
    // first Action Command(s) before every armed camera is truly ready.
    [[nodiscard]] bool is_actually_grabbing() const;

    [[nodiscard]] bool is_open() const;
    [[nodiscard]] int64_t frames_grabbed() const;
    [[nodiscard]] int64_t frames_dropped() const; // dropped due to full ring buffer
    [[nodiscard]] double current_fps() const;

    // elapsed_ns() of the most recently grabbed frame, or -1 if none yet.
    // Used by PerformanceMonitorW to show a live cross-camera skew estimate
    // (max - min across cameras) while recording — a coarse, once-per-second
    // signal distinct from SyncManifest's precise post-hoc report.
    [[nodiscard]] int64_t last_frame_elapsed_ns() const;

    // Set at open() time only when hwTriggerEnabled && hwTriggerSource ==
    // "Action1" AND this firmware exposed writable ActionSelector/
    // ActionDeviceKey/ActionGroupKey/ActionGroupMask nodes when probed. When
    // false (either because Action1 wasn't requested, or the probe failed
    // and this camera fell back to free-run for the session), callers must
    // not include this grabber in a GigE Vision Action Command target list
    // (see gige_action_command.hpp).
    [[nodiscard]] bool action_command_ready() const;
    [[nodiscard]] uint32_t action_device_key() const;
    [[nodiscard]] QString action_broadcast_address() const;

    // The fps this grabber was configured with (CameraParameters::fps).
    // Plain struct read — always available even in stub builds. Used by
    // VideoManager to pick a shared continuous Action Command firing rate
    // across every Action1-armed camera without needing VideoSettings
    // threaded through start_preview() (which doesn't take one).
    [[nodiscard]] double configured_fps() const;

    // The camera's real, measured ResultingFrameRate — refreshed at open()
    // time, after every apply_live_params() call, and periodically (every
    // ~2s) while grabbing (see refresh_achievable_fps()), so it keeps
    // improving over the life of a session rather than staying pinned at
    // whatever was measured first. A reading is only trusted once the
    // camera has been genuinely grabbing for a little while (see
    // is_achievable_fps_measurement_warmed_up() in gige_action_command.hpp)
    // — not filtered by its own magnitude, since real room-11 data showed a
    // magnitude floor can't tell a premature reading apart from a real,
    // stable, but disappointingly low one (e.g. auto-exposure converged
    // near its ceiling in a dim room). So this can still be -1.0 (never yet
    // measured) for a few seconds after open() even though
    // refresh_achievable_fps() already ran — expected, not a bug; a
    // trustworthy reading lands as soon as the warm-up window passes. May be
    // lower than configured_fps() if GigE bandwidth/exposure/ROI can't
    // sustain the requested rate (see the "Requested X fps but the camera
    // can only sustain ~Y fps" warning). Preferred over
    // configured_fps() when picking a shared Action Command firing rate, so
    // the ticker doesn't trigger a camera faster than it can actually
    // process — that would reproduce the same GigE packet-loss failure mode
    // already seen from over-requested free-run fps, just self-inflicted by
    // the trigger cadence instead. Returns -1.0 if never measured (stub
    // builds, or the node was unavailable) — callers should fall back to
    // configured_fps() in that case.
    [[nodiscard]] double achievable_fps() const;

    // Lists every Pylon-visible GigE Vision device currently reachable on the
    // network, independent of any opened camera/session. Used by the "Discover
    // cameras" UI action to auto-fill camera cards with real serial numbers
    // instead of requiring the user to hand-type them via an external tool.
    // Returns an empty list in stub builds (MOSAIC_HAVE_CAMERAS not defined).
    [[nodiscard]] static QVector<DiscoveredCamera> enumerate_devices();

   signals:
    void opened(int cameraIndex, int width, int height, double fps);
    void closed(int cameraIndex);
    void frame_dropped(int cameraIndex, int64_t frameId);
    void grab_error(int cameraIndex, QString message);
    // Throttled preview at ~15 fps — used for live QML display only.
    void preview_frame(int cameraIndex, QImage frame);
    // Emitted once, at full resolution (unlike preview_frame(), which is
    // capped to 640×360), in response to a preceding
    // request_calibration_frame() call. QImage (not VideoFrame) to match
    // preview_frame()'s existing, already-proven-safe cross-thread payload
    // convention rather than introducing a new custom-struct Qt meta-type.
    // token is whatever was passed to request_calibration_frame().
    void calibration_frame_ready(int cameraIndex, QImage frame, uint64_t token);

    // Emitted once from open(), only when hwTriggerEnabled &&
    // hwTriggerSource == "Action1" was requested — i.e. only when the user
    // actually asked for GigE Vision Action Command triggering, so cameras
    // not using it never pay the extra node probe or generate UI noise. Lets
    // the HW Trigger tab show a definitive per-camera "Action-command
    // supported: yes/no" readout without inspecting logs.
    void action_command_capability(int cameraIndex, bool supported);

   protected:
    void run() override;

   private:
    // Writes exposure/gain/gamma/black-level/white-balance/auto-target/
    // digital-shift nodes from d->params onto the currently-open camera.
    // Shared by open() (initial configuration) and apply_live_params()
    // (re-applying after a UI edit) so the node-writing logic exists once.
    void apply_image_params();

    // Re-reads d->resultingFps and re-checks the "requested fps not
    // achievable" warning — see the .cpp definition's doc comment for why
    // this must run again after a live parameter change, not just at
    // open() time. Called from open() and from run_pylon_loop()'s
    // live-apply branch, right after apply_image_params().
    void refresh_achievable_fps();

    void run_pylon_loop();
    void run_stub_loop();

    struct Impl;
    std::unique_ptr<Impl> d;
};

} // namespace mosaic