Program Listing for File skeleton3d_result.hpp#

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

#pragma once
#include <QMap>
#include <QPair>
#include <QPointF>
#include <QString>
#include <QStringList>
#include <QVector>
#include <array>
#include <cstdint>

namespace mosaic {

/// Plain 3-vector (room-space mm) — std::array rather than QVector3D,
/// matching CalibrationData's own std::array<double,N> convention for
/// geometric data (src/core/settings.hpp) and GazeFusionResult's identical
/// choice (src/analysis/gaze_fusion_result.hpp) — kept as a local alias
/// here (not shared across the two headers) since a `using` declaration for
/// the same underlying type is harmless to redeclare per translation unit,
/// but a plain local alias avoids depending on that at all.
using Skeleton3DVec3 = std::array<double, 3>;

/// Static per-camera room position (extrinsic_rt's translation column),
/// written once per file — used by Skeleton3DRoomViewW's camera icons.
/// Mirrors GazeFusionRoomCamera exactly.
struct Skeleton3DRoomCamera {
    int index                   = -1;
    Skeleton3DVec3 positionRoom = {0, 0, 0};
};

/// One triangulated keypoint within one reconstructed person. `valid`
/// is the SINGLE source of truth for "meaningless" everywhere else in this
/// struct — positionRoom/reprojectionErrorPx/every camera's entry in the
/// owning Skeleton3DPerson::reprojectedPx are all left at their defaults
/// (0/-1/absent) whenever !valid, never independently encoded.
struct Skeleton3DKeypoint {
    bool valid                  = false;
    Skeleton3DVec3 positionRoom = {0, 0, 0}; ///< mm, room space, raw (unsmoothed) triangulation.
    /// Centered per-track median-filtered version of positionRoom, from
    /// run_pose3d.py's "keypoints_room_smoothed" field — same
    /// valid-gated meaning as positionRoom. Equal to positionRoom itself
    /// when smoothing is off (--smoothing-window 1, the default) or when
    /// loading an older file written before this field existed — never
    /// silently zero, so a caller that always renders this field never
    /// needs to special-case "no smoothing available" as "sits at the
    /// room origin."
    Skeleton3DVec3 positionRoomSmoothed = {0, 0, 0};
    double reprojectionErrorPx          = -1.0; ///< -1 = n/a (!valid).
};

/// One reconstructed 3D person within one fused frame. Mirrors
/// run_pose3d.py's per-person JSON object.
struct Skeleton3DPerson {
    int trackId                = -1;
    int numContributingCameras = 0;
    QVector<int> sourceCameras;
    QVector<Skeleton3DKeypoint> keypoints; ///< Same order/length as keypoint_names().
    /// cameraIndex -> one reprojected 2D pixel point per keypoint (same
    /// order/length as keypoints above), precomputed in Python for EVERY
    /// calibrated camera (not just sourceCameras) — mirrors how
    /// gaze_fusion.json precomputes face_box_px so the C++ overlay
    /// (PoseOverlayPlayerW::set_skeleton3d_result()) never needs
    /// calibration math at paint time. A keypoint index with
    /// !keypoints[i].valid has no corresponding entry (a null QPointF, see
    /// skeleton3d_result.cpp) in any camera's vector at that index.
    QMap<int, QVector<QPointF>> reprojectedPx;
};

/// One fused master-tick. Mirrors run_pose3d.py's per-frame JSON object.
struct Skeleton3DFrame {
    int64_t tick        = 0;
    int64_t timestampNs = 0;
    QVector<Skeleton3DPerson> people;
};

/// Parses a session-root "skeleton3d.json" file written by
/// analysis/run_pose3d.py into a queryable in-memory structure, for the
/// Analysis tab's 3D Pose Reconstruction plugin (per-camera reprojected
/// skeleton overlay during playback, plus an interactive 3D room view).
/// Mirrors GazeFusionResult (src/analysis/gaze_fusion_result.hpp) closely —
/// same load()/is_valid() shape and the same "keyed on the shared master
/// tick/timestamp, not any single video's frame_index" nearest-frame
/// lookup, since reconstruction is inherently cross-camera. Diverges from
/// GazeFusionResult in carrying MULTIPLE people per frame (gaze fusion only
/// ever tracks one face) with a persistent trackId across frames.
///
/// Usage:
/// @code
///   auto result = Skeleton3DResult::load(jsonPath);
///   if (result.is_valid()) { ... }
/// @endcode
class Skeleton3DResult {
   public:
    Skeleton3DResult() = default;

    /// Parses jsonPath. Returns a default-constructed (is_valid() == false)
    /// result if the file is missing or malformed.
    static Skeleton3DResult load(const QString& jsonPath);

    [[nodiscard]] bool is_valid() const { return valid_; }
    [[nodiscard]] const QStringList& source_videos() const { return sourceVideos_; }
    [[nodiscard]] const QVector<Skeleton3DRoomCamera>& cameras() const { return cameras_; }
    [[nodiscard]] const QStringList& keypoint_names() const { return keypointNames_; }
    [[nodiscard]] const QVector<QPair<int, int>>& skeleton_edges() const { return skeletonEdges_; }
    [[nodiscard]] double master_fps() const { return masterFps_; }
    [[nodiscard]] const QVector<Skeleton3DFrame>& frames() const { return frames_; }

    /// Nearest-frame lookup by timestamp (ns) estimate — binary search,
    /// since frames() is stored in the ascending timestampNs order
    /// run_pose3d.py writes ascending master ticks in. Returns nullptr if
    /// there are no frames.
    [[nodiscard]] const Skeleton3DFrame* nearest_frame(int64_t timestampNsEstimate) const;

   private:
    bool valid_ = false;
    QStringList sourceVideos_;
    QVector<Skeleton3DRoomCamera> cameras_;
    QStringList keypointNames_;
    QVector<QPair<int, int>> skeletonEdges_;
    double masterFps_ = 25.0;
    QVector<Skeleton3DFrame> frames_;
};

} // namespace mosaic