// 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) // // Parked (hand interaction in VR games): while run_dir/park exists (/run/user/UID/frametop-hands/park; // the input relay makes it when Frametop pauses for a game and removes it when the dashboard opens or // Frametop resumes), ft-hands processes no frames: the models stay loaded, the hands file says no // hands, and a gesture down ends, as lost. It writes "0" to run_dir/hands-want every second, so // ft-camd stops copying the mono cameras' frames meanwhile (it copies again 3 s after the last // write). Unparked, it tracks again from a palm search (about 200 ms). Not while recording. // // --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): the names come from XRService's log // (cameras_from_xrservice_log) and ft-camd matches each camera's buffers exactly (VIDIOC_QUERYBUF), // so they should be right; before 2026-10-05 they were often swapped. auto (the default) still // 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), HANDS_MISREAD_GUARD (0 or 1: the // tracker's guards for fine-tuned landmark models, Tracker::set_misread_guard), HANDS_MODELS (as // --models: a folder with palm.ncnn.* and hand.ncnn.*, e.g. fine-tuned ones), HANDS_REACQUIRE (seconds // the palm search aims at a lost hand, 0 off: Tracker::set_reacquire), HANDS_PAIR_SIDES (0 or 1: two // hands are one left and one right, Tracker::set_pair_sides), HANDS_PINCH_DIP (0 or 1: pinches also // begin on a sharp dip of the thumb-index distance, PinchParams::dip), HANDS_PINCH_FIST_CURL (1.2; 0 off: // no pinch begins while the index and middle are curled under this, PinchParams::fist_curl). // // Flags (track/flagbuf.h; HANDS_FLAG_BUFFER, default 1): ft-hands keeps the last HANDS_FLAG_SECONDS // (12) of hand crops and its output in RAM (at most HANDS_FLAG_MB, 80, of crops HANDS_FLAG_CROP, 224 // px, a side), and `ft-handsctl flag [note]` writes them and the next 2 s to a folder for frame-hands' // offline tools. The request is run_dir/flag (key=value lines: dir, note, said_unix, flag_unix), // checked 4 times a second and removed when taken; the folder's manifest.json is written last. #include "flagbuf.h" #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 #include namespace { volatile std::sig_atomic_t g_stop = 0, g_record = 0; // Which calibrated camera each video device carries. XRService names each tracking camera's // sensor subdev once per instance ("Found camera 'slam_left': interface=msm_csiphy0 // v4l_subdev=/dev/v4l-subdev30 ...") and logs the device and subdev each index opened at every // camera start ("TrackingCameraInit: index: 0. video device: /dev/video9. v4l subdevice: // /dev/v4l-subdev31"). The index is only the order it opens them in: on every start logged since // 2026-10-04, index 0 was slam_right. So the subdev names the device; a log without "Found // camera" lines falls back to the index order (slam_left, slam_right, upper_left, upper_right). // The devices depend on the colour module: with it, the side cameras are on vfe3 (slam_right) // and vfe4 (slam_left) 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: ", found_key = "Found camera '"; std::map> init; // index -> (N of /dev/videoN, subdev), latest start std::map name_of; // subdev -> camera name std::string line; while (std::getline(in, line)) { if (line.find("XRService logging to") != std::string::npos) init.clear(), name_of.clear(); if (const auto at = line.find(found_key); at != std::string::npos) { const auto name_end = line.find('\'', at + found_key.size()); const auto sub = line.find("v4l_subdev=", at); if (name_end != std::string::npos && sub != std::string::npos) { const auto sub_end = line.find_first_of(" \t\r", sub + 11); name_of[line.substr(sub + 11, sub_end == std::string::npos ? std::string::npos : sub_end - sub - 11)] = line.substr(at + found_key.size(), name_end - at - found_key.size()); } continue; } const auto at = line.find(key); int index = -1, node = -1; char subdev[64] = ""; if (at != std::string::npos && std::sscanf(line.c_str() + at + key.size(), "%d. video device: /dev/video%d. v4l subdevice: %63s", &index, &node, subdev) >= 2 && index >= 0 && index < 4) init[index] = {node, subdev}; } std::map out; for (auto &[index, ns] : init) { const auto it = name_of.find(ns.second); const bool known = it != name_of.end() && std::find(std::begin(names), std::end(names), it->second) != std::end(names); out[ns.first] = known ? it->second : 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_right"; if (!std::strcmp(name, "msm_vfe4_video0")) return "slam_left"; 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; if (const std::string m = setting("HANDS_MODELS"); !m.empty()) models = m; // 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 bool misread_guard = setting("HANDS_MISREAD_GUARD") == "1"; double reacquire = 1.5; if (const std::string v = setting("HANDS_REACQUIRE"); !v.empty()) reacquire = std::atof(v.c_str()); bool pair_sides = setting("HANDS_PAIR_SIDES") != "0"; double hold = 0.6; // s a lost, established hand is kept and retried before it's forgotten // the skeleton fit (track/skeleton.h): published as fh_hands version 2, and as version 1's // points and capsules; 0 publishes the One Euro filtered landmarks as before, and no skeleton bool skeleton = setting("HANDS_SKELETON") != "0"; PinchParams pinch_params; pinch_params.dip = setting("HANDS_PINCH_DIP") == "1"; if (const std::string v = setting("HANDS_PINCH_FIST_CURL"); !v.empty()) pinch_params.fist_curl = std::atof(v.c_str()); // How easily a pinch lets go (user, 2026-10-11, gaze + hands: "taps and pinches release way too easily // even when I haven't actually released them": ends came at 4-6 cm, and grips took pinches 130 ms in): // HANDS_PINCH_END (m), HANDS_PINCH_DRAG_OPEN (m), HANDS_PINCH_DRAG_OPEN_S, HANDS_GRIP_TAKEOVER_S. auto num = [&](const char *key, double &v, double lo, double hi) { if (const std::string t = setting(key); !t.empty()) v = std::clamp(std::atof(t.c_str()), lo, hi); }; num("HANDS_PINCH_END", pinch_params.end_m, 0.025, 0.08); num("HANDS_PINCH_DRAG_OPEN", pinch_params.drag_open_m, 0.03, 0.12); num("HANDS_PINCH_DRAG_OPEN_S", pinch_params.drag_open_s, 0.0, 1.0); num("HANDS_GRIP_TAKEOVER_S", pinch_params.grip_takeover_s, 0.0, 1.0); 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) // the flag buffer (see the top) bool flag_on = setting("HANDS_FLAG_BUFFER") != "0"; FlagConfig flag_cfg; if (const std::string v = setting("HANDS_FLAG_SECONDS"); !v.empty()) flag_cfg.before_s = std::clamp(std::atof(v.c_str()), 1.0, 60.0); if (const std::string v = setting("HANDS_FLAG_CROP"); !v.empty()) flag_cfg.crop_px = std::clamp(std::atoi(v.c_str()), 64, 512); if (const std::string v = setting("HANDS_FLAG_MB"); !v.empty()) flag_cfg.max_bytes = size_t(std::clamp(std::atof(v.c_str()), 8.0, 1024.0) * 1048576); 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 == "--pinch-dip" && more) pinch_params.dip = std::atoi(argv[++i]) != 0; else if (a == "--pinch-fist-curl" && more) pinch_params.fist_curl = 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 == "--misread-guard" && more) misread_guard = std::string(argv[++i]) == "1"; else if (a == "--reacquire" && more) reacquire = std::atof(argv[++i]); else if (a == "--pair-sides" && more) pair_sides = std::string(argv[++i]) == "1"; else if (a == "--hold" && more) hold = std::atof(argv[++i]); else if (a == "--skeleton" && more) skeleton = std::string(argv[++i]) != "0"; else if (a == "--flag-buffer" && more) flag_on = std::string(argv[++i]) != "0"; 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" " [--misread-guard 0|1] (0; 1 for fine-tuned landmark models: see Tracker::set_misread_guard)\n" " [--reacquire S] (1.5; 0: off: see Tracker::set_reacquire) [--pair-sides 0|1] (1) [--hold S] (0.6)\n" " [--skeleton 0|1] (1: fit a skeleton with the user's bone lengths and publish it; HANDS_SKELETON)\n" " [--flag-buffer 0|1] (1: keep the last seconds for `ft-handsctl flag`; HANDS_FLAG_BUFFER)\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" " [--pinch-dip 0|1] (0; 1: also on a sharp dip, see PinchParams::dip; HANDS_PINCH_DIP)\n" " [--pinch-fist-curl R] (1.2; 0: off: no pinch begins on a fist, see PinchParams::fist_curl;\n" " HANDS_PINCH_FIST_CURL)\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-