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 or whole people 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 region       "face" (default) or "body". Whole-body masks the
    ///                     union of person segmentation and the face boxes, so
    ///                     someone the segmenter misses still has their face
    ///                     covered.
    /// @param style        "blur" (default) or "box".
    /// @param frameSkip    Run the detector every Nth frame, reusing the last
    ///                     detected boxes on skipped frames. Ignored (forced
    ///                     to 1) for "body" — a reused silhouette misaligns as
    ///                     the subject moves, where a padded box does not.
    ///                     Raising it for "face" risks a skipped frame's fast
    ///                     head motion going unmasked.
    void run_face_mask(const QString& sessionPath, const QString& backend, const QString& region,
                       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 Compute a spectrogram, pitch track and intensity contour for
    ///        every WAV in a session's audio/ folder.
    ///
    /// Writes "<name>.voice.png" + "<name>.voice.json" sidecars beside each
    /// source WAV; originals are untouched.
    ///
    /// Deliberately separate from run_diarization(): this pass loads no models,
    /// downloads nothing and needs no Hugging Face token, finishing in seconds
    /// where a transcription takes minutes. Folding it into the diarization run
    /// would mean re-running Whisper and pyannote to get a picture of a
    /// waveform, and would leave sessions that were only ever transcribed with
    /// no way to get one at all.
    void run_voice_analysis(const QString& sessionPath, double maxFrequencyHz, double pitchFloorHz,
                            double pitchCeilingHz, bool autoPitchRange);

    /// @brief Estimate a calibration-free, per-camera 2D gaze direction
    /// (normalized [-1,1] iris-offset heuristic — the same math already
    /// running live in the Real-time tab) over the course of a recorded
    /// session's video. No camera intrinsic or room/extrinsic calibration
    /// is required, unlike Multi-Camera Gaze Fusion. Writes one
    /// "<video_stem>.gaze2d.json" per camera into the session's own
    /// gaze2d/ 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. 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 minConfidence  Face detection/presence/tracking confidence
    ///                       threshold.
    /// @param frameSkip      Process every Nth frame (1 = every frame).
    void run_gaze2d_analysis(const QString& sessionPath, double minConfidence, int frameSkip);

    /// @brief Produces per-camera "repaired" copies with EQUALIZED frame
    /// counts, aligned to a shared master tick grid, filling small
    /// per-camera frame-count mismatches (GVSP packet loss / trigger
    /// misses) by duplicating the nearest-available frame. Writes
    /// "synced/video_N.mp4" (+ a per-camera "synced/video_N.repair_map.csv"
    /// audit trail marking which output frames are duplicates) and one
    /// "synced/sync_repair.json" summary — originals are never modified.
    ///
    /// Deliberately does NOT touch/depend on the session's canonical
    /// sync_manifest.json (used by SessionPlayerW at its own fixed 25fps
    /// default) — this plugin's own masterFps can legitimately differ, and
    /// overwriting the canonical manifest with a different rate would
    /// silently change SessionPlayerW's frame-step sizing. analysis/
    /// run_sync_repair.py therefore reimplements the same nearest-neighbor
    /// tick-grid algorithm independently (see analysis/sync_repair/
    /// alignment.py's module doc, and the cross-reference comment near
    /// SyncManifest::generate()'s own tick-assignment loop).
    ///
    /// 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 masterFps    Uniform output tick rate, or <= 0.0 for auto (the
    ///                     fastest achieved fps among this session's
    ///                     cameras — never upsamples beyond real capture).
    void run_sync_repair(const QString& sessionPath, double masterFps);

    /// @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