// ft-hands: hands in 3D from ft-camd's ring, published for Frametop's ft-screens (the hand // cutouts), and pinches and grips for the pointer. It started as a port of frame-hands' // Python prototype: the same scheduling, with the models on a few threads. // // ft-hands [--seconds N] [--threads N] [--int8] [--status S] [--models DIR] [--nice N] // [--no-publish] [--no-gestures] [--record DIR] [--sides auto|0|1] [--cams auto|mono|color|all] ... // (--help lists them all) // // --no-gestures: hands for the cutouts only. No pinch or grip detection, so nothing reaches // the pointer and a closing hand doesn't raise the rate; the gestures file is removed. // // Which cameras (--cams, HANDS_CAMERAS): the four mono IR cameras light the hands with their // own IR and track well in dim rooms, but in bright light (a sunny room, a window behind the // hands) they expose for the room and the hands come out dark. The two Arcturus colour // cameras (the passthrough pair, forward-facing, 145 degrees) are the other way round: dark // and grainy in a dim room, clear in a lit one. auto (the default) picks by how bright the // colour cameras' frames are: at HANDS_BRIGHT_ON (mean luma) or over for 2 s, it tracks with // HANDS_BRIGHT (all: every camera, so hands low at the sides stay in the side cameras; or // color); under HANDS_BRIGHT_OFF for 2 s, with the mono cameras again. ft-camd runs the colour // cameras at 2 fps, enough to tell the light, until ft-hands asks for 30 // (/run/user/UID/frametop-hands/color-fps). The colour frames' capture times are on their // own clock, so they're placed on the mono cameras' by when they were dequeued, less the // mono cameras' measured delay. // // Which side camera is which (--sides, HANDS_SWAP_SIDES): ft-camd tells slam_left's buffers from // slam_right's by XRService's allocation order, which some XRService starts reverse. auto (the // default) tells from the hands it tracks (track/sides.h): once it's sure, it exchanges the two // cameras if they're backwards (the tracked views move with their images), and checks once // more. 0 and 1 force the naming (1: exchanged; --swap-sides is --sides 1); it still checks, // and if the hands disagree it warns and publishes what the hands say as the truth ("swapped"), // so recordings are labelled right while tracking keeps the forced names. The decision is published in /run/user/UID/frametop-hands/sides.json (see // write_sides below) and, for recordings, in DIR/sides.json. --record-only can't tell (it tracks // nothing): under auto it records the ring's names as they are. // // Settings in ~/.config/frametop.conf (FT_ in the environment overrides them, and // options override both): HANDS_SWAP_SIDES (auto, 0 or 1), HANDS_CPUS (as --cpus), // HANDS_CAMERAS, HANDS_BRIGHT, HANDS_BRIGHT_ON, HANDS_BRIGHT_OFF, HANDS_COLOR_LEFT (which // colour camera is passthrough_left: color_video0 or color_video3), HANDS_COLOR_CROP // (subtract or none: tools/check_color.py tells both). #include "io.h" #include "pinch.h" #include "record.h" #include "sides.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { volatile std::sig_atomic_t g_stop = 0, g_record = 0; // Which calibrated camera each video device carries. XRService numbers its tracking cameras // (index 0 to 3: slam_left, slam_right, upper_left, upper_right) and logs the device each one // opened ("TrackingCameraInit: index: 0. video device: /dev/video9"). The devices depend on // the colour module: with it, the side cameras are on vfe3 and vfe4 and the upper pair on // vfe2; without it, XRService runs the side cameras through the ISP on vfe0 and vfe1, and the // upper pair on vfe3 and vfe4. So the running XRService's log decides; when it can't be read, // the capture pipes as they are with the module. {} if the log has no cameras. std::map cameras_from_xrservice_log() { static const char *const names[] = {"slam_left", "slam_right", "upper_left", "upper_right"}; const char *home = std::getenv("HOME"); std::ifstream in(std::string(home ? home : "") + "/.local/share/Steam/logs/xrservice.txt"); const std::string key = "TrackingCameraInit: index: "; std::map node_of; // index -> N of /dev/videoN, from the latest camera start std::string line; while (std::getline(in, line)) { if (line.find("XRService logging to") != std::string::npos) node_of.clear(); const auto at = line.find(key); int index = -1, node = -1; if (at != std::string::npos && std::sscanf(line.c_str() + at + key.size(), "%d. video device: /dev/video%d", &index, &node) == 2 && index >= 0 && index < 4) node_of[index] = node; } std::map out; for (auto &[index, node] : node_of) out[node] = names[index]; return out; } const char *camera_for_pipe(int node) { char path[64], name[64] = ""; std::snprintf(path, sizeof path, "/sys/class/video4linux/video%d/name", node); std::ifstream f(path); f.getline(name, sizeof name); if (!std::strcmp(name, "msm_vfe3_video0")) return "slam_left"; if (!std::strcmp(name, "msm_vfe4_video0")) return "slam_right"; if (!std::strcmp(name, "msm_vfe2_video0")) return "upper_left"; if (!std::strcmp(name, "msm_vfe2_video1")) return "upper_right"; return nullptr; } // A setting from ~/.config/frametop.conf, or FT_ from the environment; "" if unset. std::string setting(const std::string &key) { if (const char *v = std::getenv(("FT_" + key).c_str())) return v; const char *home = std::getenv("HOME"); std::ifstream in(std::string(home ? home : "") + "/.config/frametop.conf"); std::string line, value; auto trim = [](std::string s) { s.erase(0, s.find_first_not_of(" \t\"'")); s.erase(s.find_last_not_of(" \t\"'") + 1); return s; }; while (std::getline(in, line)) { line = line.substr(0, line.find('#')); const auto eq = line.find('='); if (eq != std::string::npos && trim(line.substr(0, eq)) == key) value = trim(line.substr(eq + 1)); } return value; } std::vector parse_cpus(const char *p) { std::vector out; while (*p) { char *end; const long c = std::strtol(p, &end, 10); if (end == p) break; out.push_back(int(c)); p = *end == ',' ? end + 1 : end; } return out; } // Where SIGUSR1 puts recordings: $XDG_DATA_HOME/frametop/hands (~/.local/share/...). std::string recordings_dir() { const char *data = std::getenv("XDG_DATA_HOME"), *home = std::getenv("HOME"); std::string dir = data && *data ? data : std::string(home ? home : "") + "/.local/share"; for (const char *part : {"/frametop", "/hands"}) mkdir((dir += part).c_str(), 0700); return dir; } double cpu_seconds() { rusage r; getrusage(RUSAGE_SELF, &r); return r.ru_utime.tv_sec + r.ru_stime.tv_sec + (r.ru_utime.tv_usec + r.ru_stime.tv_usec) / 1e6; } enum class Cams { Mono, Color, All }; const char *cams_name(Cams c) { return c == Cams::Mono ? "mono" : c == Cams::Color ? "color" : "all"; } bool parse_cams(const std::string &s, Cams &out) { if (s == "mono") out = Cams::Mono; else if (s == "color") out = Cams::Color; else if (s == "all") out = Cams::All; else return false; return true; } // How bright it is, for auto (see the top): the colour frames' mean luma, smoothed over about // a second, with hysteresis and a 2 s hold each way. No colour frames for 3 s (ft-camd paused // them, or has none) reads as dim. struct Lighting { double on = 40, off = 25; double level = -1; bool bright = false; uint64_t at_ns = 0, since_ns = 0; // the last frame; since when it's wanted the other way void add(double mean, uint64_t t_ns) { const double dt = at_ns && t_ns > at_ns ? (t_ns - at_ns) / 1e9 : 1.0; level = level < 0 ? mean : level + (mean - level) * std::min(1.0, dt / 1.0); at_ns = t_ns; } // True when it switched. bool update(uint64_t now_ns) { if (level >= 0 && now_ns - at_ns > 3'000'000'000ull) level = -1; const bool want = level >= 0 && (bright ? level > off : level >= on); if (want == bright) return since_ns = 0, false; if (!since_ns) since_ns = now_ns; if (now_ns - since_ns < 2'000'000'000ull) return false; bright = want, since_ns = 0; return true; } }; // Writes path through a temporary file, so a reader never sees half of it. bool write_file(const std::string &path, const std::string &text) { const std::string tmp = path + ".tmp"; FILE *f = std::fopen(tmp.c_str(), "w"); if (!f) return false; const bool ok = std::fputs(text.c_str(), f) >= 0; if (std::fclose(f) != 0 || !ok || std::rename(tmp.c_str(), path.c_str()) != 0) return unlink(tmp.c_str()), false; return true; } std::string json_bool(std::optional b) { return !b ? "null" : *b ? "true" : "false"; } std::string json_str(const std::string &s) { return s.empty() ? "null" : "\"" + s + "\""; } } // namespace int main(int argc, char **argv) { double seconds = 0, status = 5; int threads = 3, niceness = 5; bool int8 = false, publish = true, track = true, gestures_on = true; std::string models = std::string(argv[0]).substr(0, std::string(argv[0]).rfind('/') + 1) + "../models/ncnn"; std::string record, ring_path = "/run/user/" + std::to_string(getuid()) + "/" FH_RING_NAME; // SteamOS starts user processes on CPUs 0-4 and keeps 5-7 (two A720s and the X4) for // SteamVR's compositor, whose threads there run at real-time priority, so they always // win. XRService pins its head tracking to 2-3. frame-hands' probes/core_ab.py // (2026-09-29, headset on, 3 rounds): on 5-7 a step took 8.4 ms against 13.2 on 2-4, // latency 9.6 against 14.1 ms, and the compositor's late frames and CPU/GPU time didn't change. std::vector cpus = {5, 6, 7}; if (const auto c = parse_cpus(setting("HANDS_CPUS").c_str()); !c.empty()) cpus = c; // Which side camera is which (see the top): auto, 0 or 1, and where that came from std::string sides_mode = setting("HANDS_SWAP_SIDES"), sides_from = "config"; if (sides_mode.empty()) sides_mode = "auto", sides_from = "default"; // Which cameras (see the top). std::string cams_arg = setting("HANDS_CAMERAS"), bright_arg = setting("HANDS_BRIGHT"); std::string color_left = setting("HANDS_COLOR_LEFT"), color_crop = setting("HANDS_COLOR_CROP"); if (cams_arg.empty()) cams_arg = "auto"; if (bright_arg.empty()) bright_arg = "all"; if (color_left.empty()) color_left = "color_video0"; if (color_crop.empty()) color_crop = "subtract"; Lighting light; if (const std::string v = setting("HANDS_BRIGHT_ON"); !v.empty()) light.on = std::atof(v.c_str()); if (const std::string v = setting("HANDS_BRIGHT_OFF"); !v.empty()) light.off = std::atof(v.c_str()); // How crops are equalized. CLAHE helps the palm search find hands (about 10% more in the // dim recording), but makes the landmarks jitter, so they get plain crops. Contrast palm_contrast, hand_contrast{Contrast::None}; double keep_presence = 0.5; // landmark presence a tracked view needs to stay PinchParams pinch_params; GripParams grip_params; bool gesture_log = false; // what the pinch and grip detectors measure, 10 times a second double record_for = 120, record_hz = 0; // record_hz: at most this many sets a second (0: all) for (int i = 1; i < argc; ++i) { const std::string a = argv[i]; const bool more = i + 1 < argc; if (a == "--seconds" && more) seconds = std::atof(argv[++i]); else if (a == "--threads" && more) threads = std::max(1, std::atoi(argv[++i])); else if (a == "--status" && more) status = std::atof(argv[++i]); else if (a == "--models" && more) models = argv[++i]; else if (a == "--nice" && more) niceness = std::atoi(argv[++i]); else if (a == "--int8") int8 = true; else if (a == "--no-publish") publish = false; else if (a == "--no-gestures") gestures_on = false; else if (a == "--pinch-begin" && more) pinch_params.begin_m = std::atof(argv[++i]); else if (a == "--pinch-end" && more) pinch_params.end_m = std::atof(argv[++i]); else if (a == "--pinch-triangulated") pinch_params.triangulated = true; else if (a == "--pinch-palm-down" && more) pinch_params.palm_down_max = std::atof(argv[++i]); else if (a == "--grip-begin" && more) grip_params.begin = std::atof(argv[++i]); else if (a == "--grip-end" && more) grip_params.end = std::atof(argv[++i]); else if (a == "--gesture-log") gesture_log = true; else if (a == "--swap-sides") sides_mode = "1", sides_from = "option"; else if (a == "--sides" && more) sides_mode = argv[++i], sides_from = "option"; else if (a == "--record-only") track = publish = false; else if (a == "--ring" && more) ring_path = argv[++i]; else if (a == "--record" && more) record = argv[++i]; else if (a == "--record-for" && more) record_for = std::atof(argv[++i]); else if (a == "--record-hz" && more) record_hz = std::max(0.0, std::atof(argv[++i])); else if (a == "--keep-presence" && more) keep_presence = std::atof(argv[++i]); else if (a == "--cams" && more) cams_arg = argv[++i]; else if (a == "--bright" && more) bright_arg = argv[++i]; else if (a == "--bright-on" && more) light.on = std::atof(argv[++i]); else if (a == "--bright-off" && more) light.off = std::atof(argv[++i]); else if (a == "--color-left" && more) color_left = argv[++i]; else if (a == "--color-crop" && more) color_crop = argv[++i]; else if (a == "--contrast" && more) { if (!Contrast::parse_pair(argv[++i], palm_contrast, hand_contrast)) return std::fprintf(stderr, "--contrast MODE or PALM/HAND, each clahe[:CLIP]|none|stretch\n"), 1; } else if (a == "--cpus" && more) { cpus = parse_cpus(argv[++i]); if (cpus.empty()) cpus = {5, 6, 7}; } else { std::printf("usage: %s [--seconds N] [--threads N] [--int8] [--status S] [--models DIR] [--nice N] [--no-publish]\n" " [--no-gestures] (hands for the cutouts only: no pinches or grips)\n" " [--record DIR] [--record-for S] [--record-hz N] [--record-only] [--cpus 5,6,7]\n" " [--sides auto|0|1] (auto: tell from the hands which side camera is which; 1: exchange them,\n" " as --swap-sides; 0: as ft-camd names them)\n" " [--keep-presence P] (0.5) [--ring PATH] (ft-camd's, or ft-ringplay's)\n" " [--cams auto|mono|color|all] (auto) [--bright all|color] (all) [--bright-on L] (40) [--bright-off L] (25)\n" " [--color-left color_video0|color_video3] [--color-crop subtract|none]\n" " [--pinch-begin M] (0.020) [--pinch-end M] (0.035) [--pinch-triangulated] [--pinch-palm-down MAX] (1: off)\n" " [--grip-begin R] (1.2) [--grip-end R] (1.45) [--gesture-log]\n" " [--contrast MODE|PALM/HAND] (clahe[:CLIP], none, stretch; default clahe:2/none)\n" "Recording saves every frame set (at most N a second with --record-hz) for S seconds (120) to DIR/sets.bin,\n" "for ft-handreplay; SIGUSR1\n" "starts one in ~/.local/share/frametop/hands/rec-