// 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] [--record DIR] [--swap-sides] [--cams auto|mono|color|all] ... // (--help lists them all) // // 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. // // Settings in ~/.config/frametop.conf (FT_ in the environment overrides them, and // options override both): HANDS_SWAP_SIDES (1: as --swap-sides), 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 #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 capture pipe carries (XRService's fixed routing) 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; } }; } // namespace int main(int argc, char **argv) { double seconds = 0, status = 5; int threads = 3, niceness = 5; bool int8 = false, publish = true, track = true, swap_sides = false; 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; swap_sides = setting("HANDS_SWAP_SIDES") == "1"; // 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; 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 == "--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") swap_sides = true; 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 == "--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" " [--record DIR] [--record-for S] [--record-only] [--cpus 5,6,7] [--swap-sides]\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 for S seconds (120) to DIR/sets.bin, for ft-handreplay; SIGUSR1\n" "starts one in ~/.local/share/frametop/hands/rec-