Program Listing for File session_info.hpp#
↰ Return to documentation for file (src/session/session_info.hpp)
#pragma once
#include <QColor>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QList>
#include <QString>
#include <QStringList>
namespace mosaic {
// ── AnnotationCategory ─────────────────────────────────────────────────────
struct AnnotationCategory {
QString name;
QColor color;
static QList<AnnotationCategory> defaults() {
return {
{"Grooming", QColor("#e8865a")}, {"Exploration", QColor("#5ab4e8")},
{"Social", QColor("#a05ae8")}, {"Rearing", QColor("#e8c05a")},
{"Freezing", QColor("#5ae8c0")}, {"Vocalization", QColor("#e85a8a")},
{"Eating", QColor("#88e85a")}, {"Drinking", QColor("#5a8ae8")},
{"Custom", QColor("#909090")},
};
}
static QColor color_for(const QString& name) {
for (const auto& c : defaults()) {
if (c.name == name) {
return c.color;
}
}
return QColor("#909090");
}
};
// ── Annotation ─────────────────────────────────────────────────────────────
struct Annotation {
int64_t timestampMs = 0; // ms from session start (0 = session begin)
QString category;
QString note;
QJsonObject to_json() const {
return {
{"timestamp_ms", timestampMs},
{"category", category},
{"note", note},
};
}
static Annotation from_json(const QJsonObject& o) {
Annotation a;
a.timestampMs = static_cast<int64_t>(o["timestamp_ms"].toVariant().toLongLong());
a.category = o["category"].toString();
a.note = o["note"].toString();
return a;
}
};
// ── SessionInfo ────────────────────────────────────────────────────────────
struct SessionInfo {
QString path;
QString name; // directory basename
QString recordedBy;
QDateTime startUtc;
int durationMs = -1;
int cameraCount = 0;
int micCount = 0;
QString mosaicVersion;
QString videoCodec;
QString audioCodec;
bool hasPoseAnalysis = false;
bool hasMotionAnalysis = false;
bool hasTranscript = false;
bool hasExpression = false;
bool hasGazeFusion = false;
bool hasSkeleton3D = false;
bool hasRppg = false;
QStringList videoFiles;
QStringList audioFiles;
QStringList analysisFiles;
QList<Annotation> annotations;
// Load from a session directory.
static SessionInfo load(const QString& dir) {
SessionInfo info;
info.path = QDir::cleanPath(dir);
info.name = QDir(dir).dirName();
QFile metaFile(dir + "/session_meta.json");
if (metaFile.open(QIODevice::ReadOnly)) {
const auto doc = QJsonDocument::fromJson(metaFile.readAll());
const auto root = doc.object();
info.recordedBy = root["recorded_by"].toString();
info.mosaicVersion = root["mosaic_version"].toString();
info.startUtc =
QDateTime::fromString(root["session_start_utc"].toString(), Qt::ISODateWithMs);
info.startUtc.setTimeZone(QTimeZone::utc());
info.cameraCount = root["cameras"].toArray().size();
info.micCount = root["microphones"].toArray().size();
const auto rec = root["recording"].toObject();
info.videoCodec = rec["video_codec"].toString();
info.audioCodec = rec["audio_codec"].toString();
}
// Video/audio recordings live under video/ and audio/ subfolders.
// Pose output has its own pose/ subfolder (analysis/run_pose.py's
// pose_dir), Expression has its own expression/ subfolder
// (analysis/run_expression.py's expression_dir), and rPPG has its
// own rppg/ subfolder (analysis/run_rppg.py's rppg_dir); per-video
// motion output still lands beside its source video in video/;
// session-level aggregate motion output (motion_results.*,
// motion_heatmap.png, …) stays at the session root — so analysis
// files are scanned across all six locations. Entries are stored
// as paths relative to the session dir (e.g. "video/video_0.mp4",
// "pose/video_0.pose.json") so callers can join them onto `path`
// directly without needing to know which subfolder a given file
// lives in.
const auto classify = [&info](const QDir& scanDir, const QString& prefix) {
if (!scanDir.exists()) {
return;
}
for (const auto& fi : scanDir.entryInfoList(QDir::Files, QDir::Name)) {
const QString sfx = fi.suffix().toLower();
const QString fn = prefix + fi.fileName();
if (sfx == "mp4" || sfx == "mkv" || sfx == "avi") {
info.videoFiles << fn;
} else if (sfx == "wav" || sfx == "flac") {
info.audioFiles << fn;
} else if (fi.fileName().contains("pose") || fi.fileName().contains("motion") ||
fi.fileName().contains("keypoint") ||
fi.fileName().contains("heatmap") ||
fi.fileName().contains("trajectory") ||
fi.fileName().contains("velocity") ||
fi.fileName().contains("transcript") ||
fi.fileName().contains("expression") || fi.fileName().contains("gaze") ||
fi.fileName().contains("skeleton") || fi.fileName().contains("rppg")) {
info.analysisFiles << fn;
if (fi.fileName().contains("pose")) {
info.hasPoseAnalysis = true;
}
if (fi.fileName().contains("motion")) {
info.hasMotionAnalysis = true;
}
if (fi.fileName().contains("transcript")) {
info.hasTranscript = true;
}
if (fi.fileName().contains("expression")) {
info.hasExpression = true;
}
if (fi.fileName().contains("gaze")) {
info.hasGazeFusion = true;
}
if (fi.fileName().contains("skeleton")) {
info.hasSkeleton3D = true;
}
if (fi.fileName().contains("rppg")) {
info.hasRppg = true;
}
}
}
};
classify(QDir(dir), "");
classify(QDir(dir + "/video"), "video/");
classify(QDir(dir + "/audio"), "audio/");
classify(QDir(dir + "/pose"), "pose/");
classify(QDir(dir + "/expression"), "expression/");
classify(QDir(dir + "/rppg"), "rppg/");
// Approximate duration: video file mtime vs session start
if (info.startUtc.isValid()) {
qint64 maxMtime = 0;
for (const auto& fn : info.videoFiles) {
const QFileInfo fi2(dir + "/" + fn);
const auto mt = fi2.lastModified().toSecsSinceEpoch();
if (mt > maxMtime) {
maxMtime = mt;
}
}
if (maxMtime > 0) {
const qint64 startSec = info.startUtc.toSecsSinceEpoch();
if (maxMtime > startSec) {
info.durationMs = static_cast<int>((maxMtime - startSec) * 1000);
}
}
}
info.load_annotations();
return info;
}
// List all valid sessions under rootDir, newest first.
static QList<SessionInfo> list_all(const QString& rootDir) {
QList<SessionInfo> result;
const QDir root(rootDir);
if (!root.exists()) {
return result;
}
const auto dirs =
root.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name | QDir::Reversed);
for (const auto& di : dirs) {
if (QFile::exists(di.filePath() + "/session_meta.json")) {
result.append(load(di.filePath()));
}
}
return result;
}
void load_annotations() {
annotations.clear();
QFile f(path + "/annotations.json");
if (!f.open(QIODevice::ReadOnly)) {
return;
}
const auto arr = QJsonDocument::fromJson(f.readAll()).array();
for (const auto& v : arr) {
annotations.append(Annotation::from_json(v.toObject()));
}
}
void save_annotations() const {
QJsonArray arr;
for (const auto& a : annotations) {
arr.append(a.to_json());
}
QFile f(path + "/annotations.json");
if (f.open(QIODevice::WriteOnly | QIODevice::Text)) {
f.write(QJsonDocument(arr).toJson(QJsonDocument::Indented));
}
}
QString format_duration() const {
if (durationMs < 0) {
return "—";
}
const int secs = durationMs / 1000;
const int mins = secs / 60;
const int hours = mins / 60;
if (hours > 0) {
return QString("%1h %2m").arg(hours).arg(mins % 60);
}
return QString("%1m %2s").arg(mins).arg(secs % 60);
}
// Format milliseconds into HH:MM:SS string.
static QString ms_to_hms(int64_t ms) {
const int64_t secs = ms / 1000;
const int64_t h = secs / 3600;
const int64_t m = (secs % 3600) / 60;
const int64_t s = secs % 60;
return QString("%1:%2:%3")
.arg(h, 2, 10, QChar('0'))
.arg(m, 2, 10, QChar('0'))
.arg(s, 2, 10, QChar('0'));
}
};
} // namespace mosaic