Files
lbgos--rift-s-linux/tests/tests_constellation.cpp
T
lbgos dff6cab92c Rift S: SLAM guard, Touch controller tracking, native room setup and SteamVR integration
Squashed from the working branch. Adds the Basalt patches, constrained
constellation pose solver, Rift S optics, world anchor and boundary
persistence, SteamVR camera queue and room setup guard, and tests.
2026-10-09 22:49:57 +03:00

438 lines
17 KiB
C++

// Copyright 2026, lbgos
// SPDX-License-Identifier: BSL-1.0
#include "catch_amalgamated.hpp"
#include "internal/pose_metrics.h"
#include "internal/ransac_pnp.h"
#include <array>
#include <cmath>
#include <vector>
#include "math/m_vec3.h"
#include "internal/correspondence_search.h"
#include "constellation_rift_s_data.hpp"
#include "tracking/t_led_models.h"
TEST_CASE("Constellation gravity rejects opposite tilt and is symmetric")
{
const xrt_vec3 gravity = {0, 1, 0};
xrt_pose prior = XRT_POSE_IDENTITY;
xrt_pose pose = XRT_POSE_IDENTITY;
const xrt_vec3 axis = {1, 0, 0};
math_quat_from_angle_vector(M_PI / 3, &axis, &prior.orientation);
math_quat_from_angle_vector(-M_PI / 3, &axis, &pose.orientation);
CHECK(pose_metrics_gravity_error(&pose, &prior, &gravity) == Catch::Approx(2 * M_PI / 3));
pose.orientation = {0, 0, 0, 1};
CHECK(pose_metrics_gravity_error(&pose, &prior, &gravity) == Catch::Approx(M_PI / 3));
CHECK(pose_metrics_gravity_error(&prior, &pose, &gravity) == Catch::Approx(M_PI / 3));
pose.orientation = {-prior.orientation.x, -prior.orientation.y, -prior.orientation.z, -prior.orientation.w};
CHECK(pose_metrics_gravity_error(&pose, &prior, &gravity) == Catch::Approx(0).margin(1e-5));
}
TEST_CASE("Constellation gravity permits yaw about camera gravity")
{
const xrt_vec3 gravity = {0.6f, 0.8f, 0};
xrt_pose prior = XRT_POSE_IDENTITY;
xrt_pose pose = XRT_POSE_IDENTITY;
const xrt_vec3 axis = {1, 0, 0};
math_quat_from_angle_vector(0.7, &axis, &prior.orientation);
xrt_quat yaw;
math_quat_from_angle_vector(1.2, &gravity, &yaw);
math_quat_rotate(&yaw, &prior.orientation, &pose.orientation);
CHECK(pose_metrics_gravity_error(&pose, &prior, &gravity) == Catch::Approx(0).margin(1e-5));
}
TEST_CASE("Constellation PnP preserves seed on degenerate correspondences")
{
std::array<t_constellation_led, 5> leds = {};
std::array<blob, 5> blobs = {};
for (size_t i = 0; i < leds.size(); i++) {
leds[i].id = i;
blobs[i].led_id = LED_MAKE_ID(2, i);
blobs[i].x = 320;
blobs[i].y = 240;
}
t_constellation_led_model model = {};
model.id = 2;
model.num_leds = leds.size();
model.leds = leds.data();
camera_model cam = {};
cam.calib.fx = cam.calib.fy = 190;
cam.calib.cx = 320;
cam.calib.cy = 240;
cam.calib.model = T_DISTORTION_FISHEYE_KB4;
xrt_pose pose = XRT_POSE_IDENTITY;
pose.position = {0.1f, 0.2f, 0.6f};
int count = 0, inliers = 0;
CHECK_FALSE(ransac_pnp_pose(&pose, blobs.data(), blobs.size(), &model, &cam, &count, &inliers));
CHECK(pose.position.x == Catch::Approx(0.1));
CHECK(pose.position.y == Catch::Approx(0.2));
CHECK(pose.position.z == Catch::Approx(0.6));
CHECK(pose.orientation.w == Catch::Approx(1));
}
TEST_CASE("Constellation PnP recovers a known camera pose")
{
std::array<t_constellation_led, 8> leds = {};
std::array<blob, 8> blobs = {};
camera_model cam = {};
cam.calib.fx = cam.calib.fy = 190;
cam.calib.cx = 320;
cam.calib.cy = 240;
cam.calib.model = T_DISTORTION_FISHEYE_KB4;
for (size_t i = 0; i < leds.size(); i++) {
leds[i].id = i;
leds[i].pos = {i & 1 ? 0.03f : -0.03f, i & 2 ? 0.03f : -0.03f, i & 4 ? 0.02f : -0.02f};
blobs[i].led_id = LED_MAKE_ID(2, i);
REQUIRE(t_camera_models_project(&cam.calib, leds[i].pos.x, leds[i].pos.y, leds[i].pos.z + 0.6f,
&blobs[i].x, &blobs[i].y));
}
t_constellation_led_model model = {};
model.id = 2;
model.num_leds = leds.size();
model.leds = leds.data();
xrt_pose pose = XRT_POSE_IDENTITY;
pose.position.z = 0.5f;
int count = 0, inliers = 0;
REQUIRE(ransac_pnp_pose(&pose, blobs.data(), blobs.size(), &model, &cam, &count, &inliers));
CHECK(std::isfinite(pose.orientation.w));
CHECK(pose.position.z == Catch::Approx(0.6).margin(1e-4));
CHECK(count == 8);
CHECK(inliers == 8);
}
TEST_CASE("Rift S acquires a recorded five-LED ring with an unknown position and yaw")
{
auto leds = rift_s_ring_leds;
t_constellation_led_model model = {};
model.id = XRT_DEVICE_TYPE_LEFT_HAND_CONTROLLER;
model.num_leds = leds.size();
model.leds = leds.data();
camera_model cam = {};
cam.width = 640;
cam.height = 480;
cam.calib.fx = rift_s_ring_intrinsics[0];
cam.calib.fy = rift_s_ring_intrinsics[1];
cam.calib.cx = rift_s_ring_intrinsics[2];
cam.calib.cy = rift_s_ring_intrinsics[3];
cam.calib.model = T_DISTORTION_FISHEYE_KB4;
cam.calib.fisheye = {rift_s_ring_kb4[0], rift_s_ring_kb4[1], rift_s_ring_kb4[2], rift_s_ring_kb4[3]};
std::array<blob, 8> blobs = {};
for (size_t i = 0; i < blobs.size(); i++) {
blobs[i].x = rift_s_ring_blobs[i][0];
blobs[i].y = rift_s_ring_blobs[i][1];
blobs[i].width = rift_s_ring_blobs[i][2];
blobs[i].height = rift_s_ring_blobs[i][3];
blobs[i].led_id = LED_INVALID_ID;
}
auto *search_model = t_constellation_search_model_new(&model);
auto *cs = correspondence_search_new(&cam);
correspondence_search_set_blobs(cs, blobs.data(), blobs.size());
xrt_vec3 pos_error = {0.1f, 0.1f, 0.1f}, rot_error = {0.5f, 0.5f, 0.5f};
xrt_vec3 gravity = rift_s_ring_gravity;
xrt_pose prior = XRT_POSE_IDENTITY;
prior.position = {42, 42, 42};
math_quat_from_vec_a_to_vec_b(&rift_s_ring_down, &gravity, &prior.orientation);
auto flags = static_cast<correspondence_search_flags>(CS_FLAG_SHALLOW_SEARCH | CS_FLAG_HAVE_POSE_PRIOR |
CS_FLAG_MATCH_GRAVITY);
xrt_pose pose = prior;
pose_metrics score = {};
CHECK_FALSE(correspondence_search_find_one_pose(cs, search_model, flags, &pose, &pos_error, &rot_error,
&gravity, M_PI / 6, &score));
CHECK(pose.position.x == 42);
model.min_acquisition_leds = 5;
cs->max_search_ns = 1;
pose = prior;
CHECK_FALSE(correspondence_search_find_one_pose(cs, search_model, flags, &pose, &pos_error, &rot_error,
&gravity, M_PI / 6, &score));
CHECK(cs->budget_exhausted);
CHECK(cs->num_trials == 0);
CHECK(pose.position.x == 42);
cs->max_search_ns = 0;
unsigned candidates = 0;
cs->pose_candidate_userdata = &candidates;
cs->pose_candidate_cb = [](void *userdata, const xrt_pose *candidate, const pose_metrics *metrics) {
++*static_cast<unsigned *>(userdata);
CHECK(std::isfinite(candidate->position.z));
CHECK(metrics->matched_blobs >= 5);
};
pose = prior;
REQUIRE(correspondence_search_find_one_pose(cs, search_model, flags, &pose, &pos_error, &rot_error, &gravity,
M_PI / 6, &score));
CHECK(candidates > 0);
cs->pose_candidate_cb = nullptr;
cs->pose_candidate_userdata = nullptr;
CHECK(score.matched_blobs == 5);
CHECK(score.visible_leds == 5);
CHECK(score.unmatched_blobs == 0);
CHECK(score.reprojection_error / score.matched_blobs < 1.5);
CHECK_FALSE(POSE_HAS_FLAGS(&score, POSE_MATCH_POSITION));
CHECK_FALSE(POSE_HAS_FLAGS(&score, POSE_MATCH_STRONG));
CHECK(pose.position.z == Catch::Approx(0.220).margin(0.003));
CHECK(pose_metrics_gravity_error(&pose, &prior, &gravity) < M_PI / 6);
pose = prior;
xrt_vec3 opposite_down = {-rift_s_ring_down.x, -rift_s_ring_down.y, -rift_s_ring_down.z};
math_quat_from_vec_a_to_vec_b(&opposite_down, &gravity, &pose.orientation);
CHECK_FALSE(correspondence_search_find_one_pose(cs, search_model, flags, &pose, &pos_error, &rot_error,
&gravity, M_PI / 6, &score));
pose = prior;
flags = static_cast<correspondence_search_flags>(CS_FLAG_SHALLOW_SEARCH | CS_FLAG_HAVE_POSE_PRIOR);
CHECK_FALSE(correspondence_search_find_one_pose(cs, search_model, flags, &pose, &pos_error, &rot_error,
&gravity, M_PI / 6, &score));
correspondence_search_free(cs);
t_constellation_search_model_free(search_model);
}
TEST_CASE("Split blobs cannot count as additional LED correspondences")
{
auto leds = rift_s_ring_leds;
t_constellation_led_model model = {};
model.id = XRT_DEVICE_TYPE_LEFT_HAND_CONTROLLER;
model.leds = leds.data();
model.num_leds = leds.size();
model.min_acquisition_leds = 5;
camera_model cam = {};
cam.width = 640;
cam.height = 480;
cam.calib.fx = cam.calib.fy = 190;
cam.calib.cx = 320;
cam.calib.cy = 240;
cam.calib.model = T_DISTORTION_FISHEYE_KB4;
xrt_pose pose = XRT_POSE_IDENTITY;
pose.position.z = 0.5;
pose.orientation = {0.98480775f, 0, 0, 0.17364818f};
pose_metrics_blob_match_info info = {};
pose_metrics_match_pose_to_blobs(&pose, nullptr, 0, &model, &cam, &info);
REQUIRE(info.num_visible_leds >= 5);
std::array<blob, 6> blobs = {};
for (size_t i = 0; i < blobs.size(); i++) {
size_t led = i < 4 ? i : 0;
blobs[i].x = info.visible_leds[led].pos_px.x;
blobs[i].y = info.visible_leds[led].pos_px.y;
blobs[i].width = blobs[i].height = 2;
blobs[i].led_id = LED_INVALID_ID;
}
pose_metrics score = {};
pose_metrics_evaluate_pose(&score, &pose, blobs.data(), blobs.size(), &model, &cam, nullptr);
CHECK(score.matched_blobs == 4);
CHECK(score.unmatched_blobs == 2);
CHECK_FALSE(pose_metrics_can_acquire_with_gravity(&score, &model));
}
TEST_CASE("Blob extraction keeps first-column peaks and actual scanline coordinates")
{
std::array<uint8_t, 400> pixels = {};
pixels[7 * 20 + 5] = 255;
pixels[8 * 20 + 5] = 100;
xrt_frame frame = {};
frame.width = frame.height = 20;
frame.stride = 20;
frame.format = XRT_FORMAT_L8;
frame.data = pixels.data();
auto *watch = blobwatch_new(96, 128);
blobservation *observation = nullptr;
blobwatch_process(watch, &frame, &observation);
REQUIRE(observation != nullptr);
REQUIRE(observation->num_blobs == 1);
const auto &b = observation->blobs[0];
CHECK(b.brightness == 255);
CHECK(b.x == 5);
CHECK(b.y == Catch::Approx(7 + 100.0 / 355));
CHECK(b.top == 7);
CHECK(b.height == 2);
blobwatch_free(watch);
}
TEST_CASE("Constellation search acquires synthetic Rift S rings without a pose prior")
{
for (bool mirror : {false, true}) {
auto leds = rift_s_ring_leds;
if (mirror) {
for (auto &led : leds) {
led.pos.x = -led.pos.x;
led.dir.x = -led.dir.x;
}
}
t_constellation_led_model model = {};
model.id = mirror ? XRT_DEVICE_TYPE_RIGHT_HAND_CONTROLLER : XRT_DEVICE_TYPE_LEFT_HAND_CONTROLLER;
model.num_leds = leds.size();
model.leds = leds.data();
camera_model cam = {};
cam.width = 640;
cam.height = 480;
cam.calib.fx = rift_s_ring_intrinsics[0];
cam.calib.fy = rift_s_ring_intrinsics[1];
cam.calib.cx = rift_s_ring_intrinsics[2];
cam.calib.cy = rift_s_ring_intrinsics[3];
cam.calib.model = T_DISTORTION_FISHEYE_KB4;
cam.calib.fisheye = {rift_s_ring_kb4[0], rift_s_ring_kb4[1], rift_s_ring_kb4[2], rift_s_ring_kb4[3]};
xrt_pose truth = XRT_POSE_IDENTITY;
truth.orientation = {0.98480775f, 0, 0, 0.17364818f};
truth.position.z = 0.5f;
pose_metrics_blob_match_info info = {};
pose_metrics_match_pose_to_blobs(&truth, nullptr, 0, &model, &cam, &info);
REQUIRE(info.num_visible_leds >= 7);
std::vector<blob> blobs(info.num_visible_leds);
for (size_t i = 0; i < blobs.size(); i++) {
blobs[i].x = info.visible_leds[i].pos_px.x;
blobs[i].y = info.visible_leds[i].pos_px.y;
blobs[i].width = blobs[i].height = 3;
blobs[i].led_id = LED_INVALID_ID;
}
auto *search_model = t_constellation_search_model_new(&model);
auto *cs = correspondence_search_new(&cam);
correspondence_search_set_blobs(cs, blobs.data(), blobs.size());
xrt_pose recovered = XRT_POSE_IDENTITY;
pose_metrics score = {};
REQUIRE(correspondence_search_find_one_pose(cs, search_model, CS_FLAG_SHALLOW_SEARCH, &recovered,
nullptr, nullptr, nullptr, 0, &score));
CHECK(score.matched_blobs == info.num_visible_leds);
CHECK(score.reprojection_error < 1e-5);
CHECK(recovered.position.z == Catch::Approx(0.5).margin(1e-4));
correspondence_search_free(cs);
t_constellation_search_model_free(search_model);
}
}
TEST_CASE("Rift S recorded accelerometer tilt matches the optical tilt in the LED model frame")
{
// The recorded down vector comes from the driver's top-level IMU calibration (acc_m/acc_b)
// alone. If the TrackedObject AccCalibration rotation also belonged between the IMU and the LED
// model, this unconstrained optical fit would disagree with it by that rotation's tilt.
auto leds = rift_s_ring_leds;
t_constellation_led_model model = {};
model.id = XRT_DEVICE_TYPE_LEFT_HAND_CONTROLLER;
model.num_leds = leds.size();
model.leds = leds.data();
model.min_acquisition_leds = 5;
camera_model cam = {};
cam.width = 640;
cam.height = 480;
cam.calib.fx = rift_s_ring_intrinsics[0];
cam.calib.fy = rift_s_ring_intrinsics[1];
cam.calib.cx = rift_s_ring_intrinsics[2];
cam.calib.cy = rift_s_ring_intrinsics[3];
cam.calib.model = T_DISTORTION_FISHEYE_KB4;
cam.calib.fisheye = {rift_s_ring_kb4[0], rift_s_ring_kb4[1], rift_s_ring_kb4[2], rift_s_ring_kb4[3]};
std::array<blob, 8> blobs = {};
for (size_t i = 0; i < blobs.size(); i++) {
blobs[i].x = rift_s_ring_blobs[i][0];
blobs[i].y = rift_s_ring_blobs[i][1];
blobs[i].width = rift_s_ring_blobs[i][2];
blobs[i].height = rift_s_ring_blobs[i][3];
blobs[i].led_id = LED_INVALID_ID;
}
auto *search_model = t_constellation_search_model_new(&model);
auto *cs = correspondence_search_new(&cam);
correspondence_search_set_blobs(cs, blobs.data(), blobs.size());
xrt_vec3 pos_error = {1, 1, 1}, rot_error = {3.2f, 3.2f, 3.2f};
xrt_vec3 gravity = rift_s_ring_gravity;
xrt_pose prior = XRT_POSE_IDENTITY;
math_quat_from_vec_a_to_vec_b(&rift_s_ring_down, &gravity, &prior.orientation);
// Any tilt is allowed: the optical fit must find the tilt on its own.
auto flags = static_cast<correspondence_search_flags>(CS_FLAG_SHALLOW_SEARCH | CS_FLAG_DEEP_SEARCH |
CS_FLAG_HAVE_POSE_PRIOR | CS_FLAG_MATCH_GRAVITY);
xrt_pose pose = prior;
pose_metrics score = {};
REQUIRE(correspondence_search_find_one_pose(cs, search_model, flags, &pose, &pos_error, &rot_error, &gravity,
M_PI, &score));
CHECK(score.matched_blobs == 5);
pose_metrics_blob_match_info info;
pose_metrics_match_pose_to_blobs(&pose, blobs.data(), blobs.size(), &model, &cam, &info);
for (int i = 0; i < info.num_visible_leds; i++) {
if (info.visible_leds[i].matched_blob) {
info.visible_leds[i].matched_blob->led_id = LED_MAKE_ID(model.id, info.visible_leds[i].led->id);
}
}
xrt_pose refined = pose;
int num_leds = 0, inliers = 0;
REQUIRE(ransac_pnp_pose(&refined, blobs.data(), blobs.size(), &model, &cam, &num_leds, &inliers));
CHECK(inliers >= 5);
xrt_vec3 imu_down = rift_s_ring_down;
math_vec3_normalize(&imu_down);
for (const xrt_pose *p : {&pose, &refined}) {
xrt_quat inverse;
math_quat_invert(&p->orientation, &inverse);
xrt_vec3 optical_down;
math_quat_rotate_vec3(&inverse, &gravity, &optical_down);
math_vec3_normalize(&optical_down);
double angle = std::acos(std::min(1.0, (double)m_vec3_dot(optical_down, imu_down)));
CHECK(angle < 3 * M_PI / 180);
}
correspondence_search_free(cs);
t_constellation_search_model_free(search_model);
}
TEST_CASE("Native constellation search rejects noninvertible pixels without a pose change")
{
camera_model camera{};
camera.calib.fx = camera.calib.fy = 190;
camera.calib.cx = 320;
camera.calib.cy = 240;
camera.fisheye62_valid = true;
std::array<blob, 5> blobs{};
std::array<t_constellation_led, 5> leds{};
t_constellation_led_model model{};
model.id = 2;
model.leds = leds.data();
model.num_leds = leds.size();
for (unsigned i = 0; i < blobs.size(); ++i) {
blobs[i].x = NAN;
blobs[i].y = 240;
blobs[i].led_id = LED_MAKE_ID(2, i);
leds[i].id = i;
}
auto search = correspondence_search_new(&camera);
correspondence_search_set_blobs(search, blobs.data(), blobs.size());
CHECK(search->num_points == 0);
correspondence_search_free(search);
xrt_pose pose = XRT_POSE_IDENTITY;
pose.position = {.1f, .2f, .6f};
int count = 0, inliers = 99;
CHECK_FALSE(ransac_pnp_pose(&pose, blobs.data(), blobs.size(), &model, &camera, &count, &inliers));
CHECK(inliers == 0);
CHECK(pose.position.x == Catch::Approx(.1));
CHECK(pose.position.y == Catch::Approx(.2));
CHECK(pose.position.z == Catch::Approx(.6));
CHECK(pose.orientation.w == 1);
}
TEST_CASE("Blob extraction merges a top-left peak without a previous scanline")
{
std::array<uint8_t, 400> pixels = {};
pixels[0] = 255;
pixels[20] = 100;
xrt_frame frame = {};
frame.width = frame.height = 20;
frame.stride = 20;
frame.format = XRT_FORMAT_L8;
frame.data = pixels.data();
auto *watch = blobwatch_new(96, 128);
blobservation *observation = nullptr;
blobwatch_process(watch, &frame, &observation);
REQUIRE(observation != nullptr);
REQUIRE(observation->num_blobs == 1);
CHECK(observation->blobs[0].brightness == 255);
CHECK(observation->blobs[0].x == 0);
CHECK(observation->blobs[0].y == Catch::Approx(100.0 / 355));
CHECK(observation->blobs[0].top == 0);
CHECK(observation->blobs[0].height == 2);
blobwatch_free(watch);
}
TEST_CASE("LED model cleanup releases both arrays and supports repeated cleanup")
{
t_constellation_led_model model = {};
t_constellation_led_model_init(2, nullptr, &model, 3, 4);
REQUIRE(model.leds != nullptr);
REQUIRE(model.bounding_points != nullptr);
t_constellation_led_model_clear(&model);
CHECK(model.leds == nullptr);
CHECK(model.bounding_points == nullptr);
CHECK(model.num_leds == 0);
CHECK(model.num_bounding_points == 0);
t_constellation_led_model_clear(&model);
t_constellation_led_model_init(2, nullptr, &model, 0, 0);
t_constellation_led_model_clear(&model);
}