Program Listing for File analysis_manager.hpp#

Return to documentation for file (src/analysis/analysis_manager.hpp)

#pragma once
#include <QObject>
#include <QProcessEnvironment>
#include <QString>
#include <QStringList>
#include <memory>

namespace mosaic {

/// @brief Manages the Python analysis subprocess(es) for post-recording plugins
/// (pose estimation, face masking, speaker diarization/transcription, facial
/// expression).
///
/// analyze_session() / run_face_mask() / run_diarization() /
/// run_expression_analysis() always run when called directly (e.g. a UI "Run"
/// button). @ref auto_analyze is a plain flag
/// callers can check to
/// decide whether to also trigger analysis automatically when a recording
/// session ends — AnalysisManager itself does not gate on it. Only one script
/// runs at a time regardless of which plugin queued it; a second call while
/// one is running queues behind it.
///
/// The Python script is found relative to the application's executable directory
/// (for installed builds) or the project root (during development).
///
/// @par Usage
/// @code{.cpp}
/// AnalysisManager mgr;
/// connect(&mgr, &AnalysisManager::output_received, this, [](const QString& line) {
///     log_info("[Analysis] " + line);
/// });
/// connect(recordMgr, &RecordManager::recording_stopped, &mgr,
///         [&](const QString& path, int) {
///             if (mgr.auto_analyze()) { mgr.analyze_session(path); }
///         });
/// mgr.set_auto_analyze(true);
/// @endcode
///
/// @see RecordManager
class AnalysisManager : public QObject {
    Q_OBJECT
   public:
    explicit AnalysisManager(QObject* parent = nullptr);
    ~AnalysisManager() override;

    // ── Configuration ───────────────────────────────────────────────────────

    /// @brief Enable / disable automatic post-recording analysis.
    ///
    /// When @c true, @ref analyze_session() is triggered automatically by
    /// the @c recording_stopped connection in Application.
    void set_auto_analyze(bool enabled);

    /// @returns @c true if auto-analysis is enabled.
    [[nodiscard]] bool auto_analyze() const;

    /// @brief Override the Python interpreter path.
    ///
    /// By default, the manager searches for a virtual-environment interpreter at
    /// @c analysis/.venv/bin/python (macOS/Linux) or @c analysis\.venv\Scripts\python.exe
    /// (Windows), then falls back to the system @c python3 / @c python.
    ///
    /// @param path  Absolute path to the Python executable.
    void set_python_path(const QString& path);

    /// @brief Set the YOLOv8 model variant.
    ///
    /// @param modelName  e.g. @c "yolov8n-pose.pt" (default) or @c "yolov8s-pose.pt".
    void set_model(const QString& modelName);

    /// @brief Set how many frames to skip between pose estimates.
    ///
    /// @c 1 = every frame (slowest, most detailed).
    /// @c 5 = every 5th frame (≈6 fps at 30 fps recording).
    void set_frame_skip(int skip);

    // ── Status ───────────────────────────────────────────────────────────────

    /// @returns @c true while the analysis subprocess is running.
    [[nodiscard]] bool is_running() const;

    // ── Operations ───────────────────────────────────────────────────────────

    /// @brief Analyse all .mp4 files in @p sessionPath asynchronously.
    ///
    /// Always runs when called directly (e.g. from a UI "Run" action).
    /// @ref auto_analyze() only gates whether the caller triggers this
    /// automatically after a recording stops — it is not checked here.
    /// If a previous analysis is still running, this queues the new session.
    ///
    /// @param sessionPath  Absolute path to the recorded session directory.
    void analyze_session(const QString& sessionPath);

    /// @brief Anonymize (blur/box) faces in all .mp4 files in @p sessionPath,
    /// writing output into a sibling "anonymized/" folder — originals are
    /// never modified.
    ///
    /// Always runs when called directly, exactly like analyze_session(). If a
    /// previous analysis is still running, this queues the new job.
    ///
    /// @param sessionPath  Absolute path to the recorded session directory.
    /// @param backend      "mediapipe" (default), "yolov8", or "opencv".
    /// @param style        "blur" (default) or "box".
    /// @param frameSkip    Run the detector every Nth frame, reusing the last
    ///                     detected boxes on skipped frames. Defaults to 1
    ///                     (every frame) at the call site — raising this
    ///                     risks a skipped frame's fast head motion going
    ///                     unmasked.
    void run_face_mask(const QString& sessionPath, const QString& backend, const QString& style,
                       int frameSkip);

    /// @brief Transcribe (and, when possible, diarize) all .wav files in
    /// @p sessionPath, writing a "<name>.transcript.json" sidecar next to
    /// each one — originals are never modified.
    ///
    /// Always runs when called directly, exactly like analyze_session(). If a
    /// previous analysis is still running, this queues the new job.
    ///
    /// @param sessionPath    Absolute path to the recorded session directory.
    /// @param modelSize      faster-whisper model size, e.g. "small" (default).
    /// @param language       Force a language code (e.g. "en"), or empty to auto-detect.
    /// @param hfToken        Hugging Face access token for the gated pyannote
    ///                       diarization models, or empty to skip diarization
    ///                       (transcript-only output).
    /// @param minSpeakers    Optional pyannote hint, 0 = unset.
    /// @param maxSpeakers    Optional pyannote hint, 0 = unset.
    /// @param skipDiarization  Force transcript-only even if hfToken is set.
    void run_diarization(const QString& sessionPath, const QString& modelSize,
                         const QString& language, const QString& hfToken, int minSpeakers,
                         int maxSpeakers, bool skipDiarization);

    /// @brief Detect faces and classify a dominant basic-emotion label per
    /// face in all .mp4 files in @p sessionPath, writing a
    /// "<name>.expression.json" file per camera into the session's own
    /// expression/ subfolder — originals are never modified.
    ///
    /// Always runs when called directly, exactly like analyze_session(). If a
    /// previous analysis is still running, this queues the new job.
    ///
    /// @param sessionPath     Absolute path to the recorded session directory.
    /// @param backend         "heuristic" (default, rule-based blendshapes)
    ///                        or "ferplus" (pretrained FER+ ONNX model).
    /// @param maxFaces        Maximum simultaneous faces to detect per frame.
    /// @param minConfidence   Face detection/presence confidence threshold (0-1).
    /// @param frameSkip       Process every Nth frame (1 = every frame).
    void run_expression_analysis(const QString& sessionPath, const QString& backend, int maxFaces,
                                 double minConfidence, int frameSkip);

    /// @brief Fuse per-camera 3D gaze rays (from every camera with both
    /// intrinsic and extrinsic calibration) into a triangulated room-space
    /// gaze origin/direction, plus (when the room plane is defined) a
    /// target point, per synchronized master tick. Writes a session-root
    /// "gaze_fusion.json" sidecar — originals are never modified.
    ///
    /// Always runs when called directly, exactly like analyze_session(). If a
    /// previous analysis is still running, this queues the new job.
    ///
    /// Proactively generates+saves sync_manifest.json first if the session
    /// doesn't already have one (mirrors SessionPlayerW's own "generate if
    /// missing" pattern) — the fusion script requires it and shouldn't have
    /// to duplicate that C++-side generation logic in Python.
    ///
    /// @param sessionPath    Absolute path to the recorded session directory.
    /// @param minCameras     Minimum simultaneous cameras required to compute
    ///                       a target point (rays are still recorded below this).
    /// @param minConfidence  Face detection/presence confidence threshold (0-1).
    /// @param frameSkip      Process every Nth frame per camera (1 = every frame).
    void run_gaze_fusion(const QString& sessionPath, int minCameras, double minConfidence,
                         int frameSkip);

    /// @brief Triangulate each camera's already-computed 2D pose keypoints
    /// (analyze_session()'s ".pose.json" sidecars — must already exist for
    /// at least 2 cameras) into 3D room-space skeletons, using the room
    /// extrinsic calibration and real cross-camera person association
    /// (multi-person capable, unlike run_gaze_fusion()'s single-subject
    /// design). Writes a session-root "skeleton3d.json" sidecar —
    /// originals are never modified.
    ///
    /// Always runs when called directly, exactly like analyze_session(). If a
    /// previous analysis is still running, this queues the new job.
    ///
    /// Proactively generates+saves sync_manifest.json first if the session
    /// doesn't already have one, same as run_gaze_fusion().
    ///
    /// @param sessionPath              Absolute path to the recorded session directory.
    /// @param minCameras               Minimum cameras a person cluster must span to
    ///                                 be reconstructed at all (>=2, the mathematical
    ///                                 minimum for triangulation).
    /// @param maxReprojectionErrorPx   Per-view reprojection error threshold (px) for
    ///                                 outlier-view rejection during triangulation.
    /// @param frameSkip                Process every Nth master tick (1 = every tick).
    /// @param smoothingWindow          Centered per-track median filter width (in valid
    ///                                 ticks) for the output's "keypoints_room_smoothed"
    ///                                 field (1 = off, the default — raw
    ///                                 "keypoints_room" is always written regardless).
    void run_pose3d_reconstruction(const QString& sessionPath, int minCameras,
                                   double maxReprojectionErrorPx, int frameSkip,
                                   int smoothingWindow);

    /// @brief Estimate a remote (camera-based) heart rate over the course of
    /// a recorded session's video, using classical (non-deep-learning)
    /// signal-processing algorithms. Writes one
    /// "<video_stem>.<backend>.rppg.json" per camera into the session's own
    /// rppg/ subfolder — originals are never modified.
    ///
    /// EXPERIMENTAL — research-grade heart-rate estimate only, not a
    /// medical device and not clinically validated. No blood-pressure or
    /// heart-rate-variability estimate is attempted (see item 21's plan
    /// section for why both were deliberately descoped).
    ///
    /// Always runs when called directly, exactly like analyze_session(). If a
    /// previous analysis is still running, this queues the new job. No
    /// sync_manifest.json dependency, unlike run_gaze_fusion()/
    /// run_pose3d_reconstruction() — this is a single-camera analysis with
    /// no cross-camera synchronization need.
    ///
    /// @param sessionPath        Absolute path to the recorded session directory.
    /// @param backend            "green" (naive baseline), "chrom", or "pos"
    ///                           (default, generally the most robust classical
    ///                           method).
    /// @param windowSec          HR-analysis window length, in seconds.
    /// @param hopSec             Sliding-window hop length, in seconds.
    /// @param smoothingWindows   Centered median-filter width, in windows, for
    ///                           the smoothed_bpm series (1 = no smoothing).
    void run_rppg_analysis(const QString& sessionPath, const QString& backend, double windowSec,
                           double hopSec, int smoothingWindows);

    /// @brief Stop the currently running analysis process immediately.
    void stop();

   signals:
    /// Emitted for every line of stdout / stderr from the Python subprocess.
    void output_received(QString line);

    /// Emitted when the subprocess starts.
    void analysis_started(QString sessionPath);

    /// Emitted when the subprocess exits.
    void analysis_finished(QString sessionPath, bool success);

    /// Emitted if the Python interpreter or script cannot be found.
    void setup_error(QString message);

   private slots:
    void on_stdout_ready();
    void on_stderr_ready();
    void on_process_finished(int exitCode, int exitStatus);

   private:
    [[nodiscard]] QString find_python() const;
    [[nodiscard]] QString find_script(const QString& scriptRelPath) const;
    [[nodiscard]] QString find_venv_python() const;
    void enqueue_or_launch(const QString& sessionPath, const QString& scriptRelPath,
                           const QStringList& args, const QProcessEnvironment& env);
    void launch(const QString& sessionPath, const QString& scriptRelPath, const QStringList& args,
                const QProcessEnvironment& env);

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

} // namespace mosaic