Program Listing for File pose_analysis_result.hpp#
↰ Return to documentation for file (src/analysis/pose_analysis_result.hpp)
#pragma once
#include <QPair>
#include <QPointF>
#include <QRectF>
#include <QString>
#include <QStringList>
#include <QVector>
#include <cstdint>
namespace mosaic {
/// Identifies *which person* a query is about, as opposed to where they
/// happened to sit in a frame's subjects array.
///
/// A distinct type on purpose. Before tracking existed, run_pose.py wrote
/// subject_id == array position, so "subject index" and "subject id" were the
/// same int holding the same value — meaning a missed call site during the
/// switch to identity keying would have compiled cleanly and silently plotted
/// a different person. Making it its own type turns every such site into a
/// compile error instead.
struct SubjectId {
int value = -1;
SubjectId() = default;
explicit constexpr SubjectId(int v) : value(v) {}
/// Untracked detections carry a negative id (run_pose.py's -(i+1)
/// fallback); real tracker ids are >= 0.
[[nodiscard]] constexpr bool is_tracked() const { return value >= 0; }
friend constexpr bool operator==(SubjectId, SubjectId) = default;
};
/// One detected subject's keypoints within a single analysed frame.
/// Mirrors run_pose.py's _result_to_dict() "subjects" entries exactly.
struct PoseSubject {
int subjectId = -1;
double confidence = 0.0;
QVector<QPointF> keypoints; ///< Pixel coordinates, one per keypoint name.
QVector<double> visibilities; ///< 0-1, same order as keypoints.
QRectF bbox; ///< bbox_xyxy.
};
/// Shared visibility threshold for "trust this keypoint" decisions — used
/// both to decide whether to draw a keypoint (SkeletonOverlayW::paintEvent,
/// src/ui/analysis/pose_overlay_player_w.cpp) and whether to include it in
/// derived kinematics (pose_kinematics.cpp). Kept in one place so the two
/// can't silently drift to different thresholds. A missing visibilities
/// entry (index >= visibilities.size()) defaults to "visible" — matches
/// keypoints[] always being written 1:1 with visibilities[] by run_pose.py,
/// so this only matters for a malformed/truncated file, where treating an
/// unknown entry as visible is the same permissive default this codebase
/// already used before this helper existed.
inline bool is_keypoint_visible(const PoseSubject& subject, int keypointIndex) {
return subject.visibilities.value(keypointIndex, 1.0) >= 0.1;
}
/// One analysed frame. Mirrors run_pose.py's per-frame JSON object.
struct PoseFrame {
int frameIndex = 0;
int64_t timestampNs = 0;
int cameraIndex = 0;
QVector<PoseSubject> subjects;
};
/// The subject carrying `id` in this frame, or nullptr if that person wasn't
/// detected here. A linear scan: a frame holds a handful of subjects, so a
/// per-frame index would cost more to build than it saves.
///
/// Returning nullptr for "not in this frame" is the whole point of identity
/// keying — the old positional lookup could not tell "this person is missing"
/// apart from "this array is shorter", and silently borrowed whoever occupied
/// that index instead.
///
/// First match wins if a file somehow repeats an id within one frame.
/// run_pose.py cannot produce that (tracker ids are unique per frame and the
/// untracked fallback is -(i+1)), so it only arises in a hand-edited or
/// third-party file, where deterministic beats clever.
[[nodiscard]] inline const PoseSubject* find_subject(const PoseFrame& frame, SubjectId id) {
for (const auto& subject : frame.subjects) {
if (subject.subjectId == id.value) {
return &subject;
}
}
return nullptr;
}
/// Parses a .pose.json file written by analysis/run_pose.py (--out-format json)
/// into a queryable in-memory structure, for drawing a live overlay during
/// playback and plotting per-keypoint metrics over time.
///
/// Usage:
/// @code
/// auto result = PoseAnalysisResult::load(jsonPath);
/// if (result.is_valid()) { ... }
/// @endcode
class PoseAnalysisResult {
public:
PoseAnalysisResult() = default;
/// Parses jsonPath. Returns a default-constructed (is_valid() == false)
/// result if the file is missing or malformed.
static PoseAnalysisResult load(const QString& jsonPath);
[[nodiscard]] bool is_valid() const { return valid_; }
/// True if at least one frame has at least one detected subject.
/// Distinct from is_valid() (which only means "the JSON parsed"): a
/// result can be valid but empty if the pose model detected nobody in
/// this camera's footage for the whole session — callers use this to
/// tell that case apart from "hasn't been analyzed yet".
[[nodiscard]] bool has_any_detections() const {
for (const auto& f : frames_) {
if (!f.subjects.isEmpty()) {
return true;
}
}
return false;
}
[[nodiscard]] const QString& source_video() const { return sourceVideo_; }
/// The model id that produced this result (e.g. "yolov8n-pose.pt"), from
/// the JSON's top-level "model" field — empty for files written before
/// this field existed (older, un-namespaced .pose.json files still load
/// fine, they just report no model).
[[nodiscard]] const QString& model() const { return model_; }
/// The cross-frame tracker that produced this result's subject ids (e.g.
/// "botsort"), from the JSON's top-level "tracker" field. Empty for files
/// written before tracking existed, whose subject_id is merely per-frame
/// detection order. Same absent-field convention as model() above.
[[nodiscard]] const QString& tracker() const { return tracker_; }
/// Whether this result's subject ids mean "the same physical person"
/// across frames. Drives the UI's caveat wording: a pre-tracking file must
/// keep the old "detection order, not a tracked individual" warning, and
/// inferring that from the ids themselves would be guesswork.
[[nodiscard]] bool has_tracked_identity() const { return !tracker_.isEmpty(); }
/// The subject ids that can be followed across frames, ascending.
/// Computed once at load().
///
/// Excludes untracked (negative) ids, which run_pose.py assigns per frame
/// and which therefore identify a different person from one frame to the
/// next — see collect_subject_ids(). Use has_untracked_detections() to
/// tell whether any were left out.
///
/// Replaces the old "widest subjects array in any frame" count. For a
/// pre-tracking file — dense ids equal to array positions — this yields
/// {0, 1, ... N-1}, i.e. exactly the old chip order, so legacy results
/// look and behave identically.
[[nodiscard]] const QVector<SubjectId>& subject_ids() const { return subjectIds_; }
/// Whether the file holds detections the tracker never claimed. Those are
/// absent from subject_ids() (and so from the chips, chart and export),
/// but still drawn on the video overlay.
[[nodiscard]] bool has_untracked_detections() const { return hasUntrackedDetections_; }
[[nodiscard]] const QStringList& keypoint_names() const { return keypointNames_; }
[[nodiscard]] const QVector<QPair<int, int>>& skeleton_edges() const { return skeletonEdges_; }
[[nodiscard]] const QVector<PoseFrame>& frames() const { return frames_; }
/// Nearest-frame lookup by frame_index estimate (e.g. derived from a
/// video playback position). frames() is stored in the ascending
/// frame_index order run_pose.py writes them in, so this is a binary
/// search, not a linear scan. Returns nullptr if there are no frames.
[[nodiscard]] const PoseFrame* nearest_frame(int frameIndexEstimate) const;
private:
bool valid_ = false;
QString sourceVideo_;
QString model_;
QString tracker_;
QStringList keypointNames_;
QVector<QPair<int, int>> skeletonEdges_;
QVector<PoseFrame> frames_;
QVector<SubjectId> subjectIds_;
bool hasUntrackedDetections_ = false;
};
} // namespace mosaic