19f1b77503d37.png

*📂 **源代码地址:** Skeleton_Space-control-of-3D-globe*

*🎯 **参赛信息:** 第二届3D视觉创新应用竞赛 | 参赛者:张豪*

📌 项目亮点

"不用鼠标、不用触屏,站在原地挥挥手,地球仪就乖乖转起来!"

亮点 内容
🖐️ 纯手势交互 无需任何控制器,身体就是遥控器,挥挥手就能操控3D地球仪
🦴 18点骨骼追踪 Astra SDK精准检测人体18个关节点,捕捉每一个细微动作
⚡ 毫秒级响应 高斯+均值双重滤波平滑速度计算,体感延迟几乎为零
🌍 OpenGL沉浸渲染 gluSphere球体 + 4张地球纹理贴图,体验丝滑
🔧 多手势融合 单手/双手/Lasso多种手势组合,覆盖翻页/缩放/旋转/重置全场景

一、项目背景

1.1 从"触屏"到"隔空" 🎮

2007年iPhone发布时,乔布斯说"谁需要触控笔?"——然后2013年Leap Motion告诉我们"谁还需要触摸屏?"

体感交互经历了几个时代:

时代 设备代表 交互方式 痛点
🕹️ 手柄时代 Wii / Xbox Kinect 专用控制器 累赘、延迟高
📱 触摸屏时代 iPhone / iPad 物理接触 无法远距离操作
✋ 纯体感时代 Leap Motion / Astra 裸手隔空 无需接触、解放双手 ✅
🤖 空间计算时代 Vision Pro 眼+手+头融合 设备昂贵

本项目站在"纯体感时代"的肩膀上,用Astra深度相机 + 骨骼手势识别,实现了对三维地球仪的自然隔空控制——就像在操控一个魔法球 🔮

1.2 应用场景想象空间

场景 价值
🏫 地理教室 学生挥挥手就能360°无死角观察地球,"空中翻书"般自然
🌍 展览展示 展厅讲解员隔空操控大屏,告别鼠标垫、"讲到哪里鼠标在哪"的尴尬
🏢 数据可视化 会议中高管隔空旋转3D数据模型,气场拉满 💼
🎮 游戏互动 博物馆/科技馆互动装置,科技感拉满

二、系统整体架构

2.1 架构概览

19f1b7eb21b96.png

2.2 硬件选型

设备 型号 参数
📷 深度相机 Orbbec Astra Pro 深度分辨率:VGA 640×480 @ 30fps,红外补光
💻 计算平台 Windows PC VS2015 + OpenCV + OpenGL

2.3 软件技术栈

层级 技术选型 作用
数据采集 Astra SDK + OpenNI2 深度流 + 骨骼关节点
图像处理 OpenCV 深度图可视化、关节点绘制
算法逻辑 C++ 原生实现 滤波、速度计算、手势识别
3D渲染 OpenGL (GLUT) 地球仪球体绘制与纹理贴图
配置文件 INI格式 相机参数独立配置

三、骨骼追踪模块 🦴

这是整个系统的"眼睛",负责实时定位人体的每一个关节。

3.1 Astra SDK骨骼检测原理

19f1b58c8ddf2.png

Astra SDK 内置了完整的人体骨骼追踪系统,支持检测 18个关节点:

索引 节点名称 英文名 关键用途
0 头部 Head —
1 颈部 Neck —
2 肩胛脊柱 ShoulderSpine 身体朝向参考
3 左肩 LeftShoulder 右手坐标系
4 右肩 RightShoulder —
5 左肘 LeftElbow —
6 右肘 RightElbow —
7 左手腕 LeftWrist 手势追踪基准
8 右手腕 RightWrist 手势追踪基准
9 左手 LeftHand 跟随方向判定
10 右手 RightHand HandPose握拳检测
11 脊柱中点 MidSpine —
12 骨盆 BaseSpine 跌倒检测基准点
13 左髋 LeftHip —
14 右髋 RightHip —
15 左膝 LeftKnee —
16 右膝 RightKnee —
17 左脚 LeftFoot —
18 右脚 RightFoot —

3.2 关节点绘制代码

// MyOrbbec.cpp — 骨骼连线绘制
void CBodyBasics::ObeDrawBone(const astra::JointList Joints,
                               astra::JointType joint0,
                               astra::JointType joint1)
{
    astra::Joint Joint0 = Joints[(int)joint0];
    astra::Joint Joint1 = Joints[(int)joint1];
    astra::JointStatus s0 = Joint0.status();
    astra::JointStatus s1 = Joint1.status();

    // 任意一个关节未追踪则跳过
    if (s0 == astra::JointStatus::NotTracked ||
        s1 == astra::JointStatus::NotTracked)
        return;

    // 低置信度时用灰色线代替(半透明)
    if ((s0 == astra::JointStatus::LowConfidence) &&
        (s1 == astra::JointStatus::LowConfidence))
        return;

    // 获取深度图坐标(像素级)
    Point p1 = Point(Joint0.depth_position().x, Joint0.depth_position().y);
    Point p2 = Point(Joint1.depth_position().x, Joint1.depth_position().y);

    // 高置信度画粗白线,低置信度画细线
    if ((s0 == astra::JointStatus::Tracked) &&
        (s1 == astra::JointStatus::Tracked))
    {
        line(depthImg3u, p1, p2, Scalar(255, 255, 255), 8);
    }
}

// 完整骨骼连线顺序(从头部到脚部)
void CBodyBasics::ObeProcessBody()
{
    // ...获取body和joints...
    // 头部 → 颈部 → 脊柱 → 上肢
    ObeDrawBone(joints, astra::JointType::Head, astra::JointType::Neck);
    ObeDrawBone(joints, astra::JointType::Neck, astra::JointType::ShoulderSpine);
    ObeDrawBone(joints, astra::JointType::ShoulderSpine, astra::JointType::LeftShoulder);
    ObeDrawBone(joints, astra::JointType::LeftShoulder, astra::JointType::LeftElbow);
    ObeDrawBone(joints, astra::JointType::LeftElbow, astra::JointType::LeftWrist);
    ObeDrawBone(joints, astra::JointType::LeftWrist, astra::JointType::LeftHand);
    // 同理右手 + 脊柱 + 下肢...
}

3.3 坐标系校正(坐标归一化)

由于人体在相机前的朝向是任意的,系统通过计算肩部坐标系来归一化所有关节点:

// MyOrbbec.cpp — 肩部坐标系校正
void correctBody(astra::JointList points, cv::Mat &R, cv::Mat &T)
{
    // 取脊柱中点和肩部中点定义Y轴方向
    astra::Joint joint_SpineMid = points[(int)astra::JointType::MidSpine];
    astra::Joint joint_SpineShoulder = points[(int)astra::JointType::ShoulderSpine];
    astra::Joint joint_ShoulderRight = points[(int)astra::JointType::RightShoulder];
    astra::Joint joint_ShoulderLeft = points[(int)astra::JointType::LeftShoulder];

    // Y轴 = 肩部 → 脊柱中点方向(指向身体前方)
    cv::Mat vy = (cv::Mat_<float>(3, 1)
        << joint_SpineShoulder.world_position().x - joint_SpineMid.world_position().x,
           joint_SpineShoulder.world_position().y - joint_SpineMid.world_position().y,
           joint_SpineShoulder.world_position().z - joint_SpineMid.world_position().z);

    // X轴 = 右肩 → 左肩方向
    cv::Mat vx = (cv::Mat_<float>(3, 1)
        << joint_ShoulderRight.world_position().x - joint_ShoulderLeft.world_position().x,
           joint_ShoulderRight.world_position().y - joint_ShoulderLeft.world_position().y,
           joint_ShoulderRight.world_position().z - joint_ShoulderLeft.world_position().z);

    // 单位化
    vx = vx / cv::norm(vx);
    vy = vy / cv::norm(vy);

    // Z轴 = X叉乘Y(垂直于XY平面)
    cv::Mat vz = vx.cross(vy);
    vz = vz / cv::norm(vz);

    // 构建旋转矩阵(从相机坐标系到肩部坐标系的变换)
    R = (cv::Mat_<float>(3, 3)
        << vx.at<float>(0), vx.at<float>(1), vx.at<float>(2),
           vy.at<float>(0), vy.at<float>(1), vy.at<float>(2),
           vz.at<float>(0), vz.at<float>(1), vz.at<float>(2));

    T = cv::Mat::zeros(3, 1, CV_32F);

    // 将所有关节点转换到肩部局部坐标系
    for (int i = 0; i < joints.size(); i++)
    {
        cv::Mat pt = (cv::Mat_<float>(3, 1)
            << joints[i].world_position().x,
               joints[i].world_position().y,
               joints[i].world_position().z);
        pt = R * pt + T;
        joints[i].world_position().x = pt.at<float>(0);
        joints[i].world_position().y = pt.at<float>(1);
        joints[i].world_position().z = pt.at<float>(2);
    }
}

> 技术要点: 通过肩部坐标系归一化,无论用户面向相机的角度如何,手部XYZ坐标的含义都是固定的(X=左右、Y=上下、Z=前后),这为后续手势识别奠定了基础 🎯

四、信号滤波与速度计算 🔧

深度相机输出的关节点坐标存在抖动,直接用原始数据计算手势会"飘"。这里用双重滤波+速度追踪来平滑信号。

4.1 高斯滤波(坐标平滑)

// MyOrbbec.cpp — 高斯低通滤波
void GuassFilter_right(double *x, double *y, double *z, int delay)
{
    static int num = 0;
    static double *xs = NULL, *ys = NULL, *zs = NULL;

    // 构造高斯核(窗口大小 = 2*delay+1)
    int size = (delay * 2 + 1);
    double *lowfilter = (double *)malloc(sizeof(double) * size);
    double sumf = 0;

    for (int i = 0; i < size; i++)
    {
        double u = ((double)(i - delay)) * ((double)(i - delay))
                   / (2.0 * delay * delay);
        lowfilter[i] = exp(-u);   // 高斯权重
        sumf += lowfilter[i];
    }

    // 滑动更新缓冲区
    if (xs == NULL) {
        xs = (double *)malloc(sizeof(double) * size);
        ys = (double *)malloc(sizeof(double) * size);
        zs = (double *)malloc(sizeof(double) * size);
    }
    for (int i = 0; i < size - 1; i++) {
        xs[i] = xs[i + 1]; ys[i] = ys[i + 1]; zs[i] = zs[i + 1];
    }
    if (x) xs[size - 1] = *x;
    if (y) ys[size - 1] = *y;
    if (z) zs[size - 1] = *z;

    // 加权求和(高斯卷积)
    double tx = 0, ty = 0, tz = 0;
    if (num >= size - 1)
    {
        for (int i = 0; i < size; i++)
        {
            tx += xs[i] * lowfilter[i];
            ty += ys[i] * lowfilter[i];
            tz += zs[i] * lowfilter[i];
        }
        tx /= sumf; ty /= sumf; tz /= sumf;
        if (x) *x = tx;
        if (y) *y = ty;
        if (z) *z = tz;
    }
    num++;
    free(lowfilter);
}

4.2 均值滤波(帧间去噪)

// MyOrbbec.cpp — 均值滤波
void MeanFilter_left(double *x, double *y, double *z, int delay)
{
    static int num = 0;
    static double *xs = NULL, *ys = NULL, *zs = NULL;

    int size = (delay * 2 + 1);
    double sumf = size;  // 均值分母 = 窗口大小

    // 滑动更新
    if (xs == NULL) {
        xs = (double *)malloc(sizeof(double) * size);
        ys = (double *)malloc(sizeof(double) * size);
        zs = (double *)malloc(sizeof(double) * size);
    }
    for (int i = 0; i < size - 1; i++) {
        xs[i] = xs[i + 1]; ys[i] = ys[i + 1]; zs[i] = zs[i + 1];
    }
    if (x) xs[size - 1] = *x;
    if (y) ys[size - 1] = *y;
    if (z) zs[size - 1] = *z;

    // 均值计算
    double tx = 0, ty = 0, tz = 0;
    if (num >= size - 1)
    {
        for (int i = 0; i < size; i++) {
            tx += xs[i]; ty += ys[i]; tz += zs[i];
        }
        tx /= sumf; ty /= sumf; tz /= sumf;
        if (x) *x = tx;
        if (y) *y = ty;
        if (z) *z = tz;
    }
    num++;
}

4.3 速度计算(历史轨迹追踪)

// MyOrbbec.cpp — 手部运动速度计算
// NRADIUS=2帧轨迹窗口,NRFILTER=1帧滤波延迟

// 维护手部历史位置缓冲区
for (int n = 0; n < NRADIUS - 1; n++)
{
    m_leftHandPre[n] = m_leftHandPre[n + 1];
    m_rightHandPre[n] = m_rightHandPre[n + 1];
}
m_leftHandPre[NRADIUS - 1].worldPosition.x = lefthand_x;
// ... 同理更新右手 ...

m_nFrames++;
if (m_nFrames >= NRADIUS + NRFILTER * 2)
{
    // 计算半窗口位移差作为速度
    for (int n = 0; n < NRADIUS / 2; n++)
    {
        m_leftHand_velocity.Vx +=
            m_leftHandPre[NRADIUS / 2 + n].worldPosition.x
            - m_leftHandPre[n].worldPosition.x;
        // ... Vy, Vz 同理 ...
    }

    // 除以时间和半窗口大小得到速度
    m_leftHand_velocity.Vx /= float(m_nFPS * NRADIUS / 2);
    m_leftHand_velocity.Vy /= float(m_nFPS * NRADIUS / 2);
    m_leftHand_velocity.Vz /= float(m_nFPS * NRADIUS / 2);
    // 再乘以FPS得到帧/秒单位的速率
    m_leftHand_velocity.Vx *= m_nFPS;
    // ...
}

> 技术要点: 高斯滤波去除高频噪声,均值滤波进一步平滑,速度计算用"半窗口位移/时间"方式估算,并做FPS归一化——这样无论帧率如何变化,速度阈值都能保持一致 📐

五、手势识别引擎 🤚

这是整个系统的"大脑",负责把骨骼点运动轨迹翻译成具体的操作指令。

5.1 手势识别流程总览

19f1b8ea5654c.png

5.2 核心参数配置

// 手势识别关键阈值常量
double _swipeDis = 18;    // 翻页阈值(mm):水平位移超过18mm才触发
double _zoomDis = 1.5;    // 缩放比阈值:双手间距变化超过1.5倍触发
double _rotateAng = 10;   // 旋转角度阈值(度):双手连心线旋转超过10°触发
double singAng = 15;      // 单手挥动方向判定容忍角(度)
double _clickTime = 20;   // Lasso点击持续帧数阈值
double _clickPosiZ = 0.05; // Lasso点击Z方向判定(mm)

5.3 单手翻页识别

// MyOrbbec.cpp — 单手挥动翻页
void CBodyBasics::handleHandPosi_single(bool bRight)
{
    resetGesture();

    if (bRight)  // 右手
    {
        int nSize = m_rightHandPosis_open.size();

        // 计算从初始位置到当前位置的向量
        float maxDis = m_rightHandPosis_open[nSize - 1].world_position().x
                     - m_rightHandPosi_init.world_position().x;

        // atan2计算挥动方向角(相对X轴)
        double ang0 = atan2(
            m_rightHandPosis_open[nSize - 1].world_position().y
            - m_rightHandPosi_init.world_position().y,
            m_rightHandPosis_open[nSize - 1].world_position().x
            - m_rightHandPosi_init.world_position().x);
        ang0 = 180 * ang0 / CV_PI;  // 弧度→角度

        // 距离阈值判断
        if (maxDis > _swipeDis || maxDis < -_swipeDis)
        {
            int count = 0;
            int halfSize = nSize / 2;

            // 验证轨迹一致性:前半段和后半段的运动方向
            // 是否与初始方向一致(容忍±15°)
            for (int i = 0; i < halfSize; i++)
            {
                double ang = atan2(
                    m_rightHandPosis_open[halfSize + i].world_position().y
                    - m_rightHandPosis_open[i].world_position().y,
                    m_rightHandPosis_open[halfSize + i].world_position().x
                    - m_rightHandPosis_open[i].world_position().x);
                ang = 180 * ang / CV_PI;

                if (fabs(ang - ang0) < singAng)
                    count++;
            }

            // 超过2/3帧方向一致 → 判定为有效翻页
            if (count > halfSize * 2.0 / 3.0)
            {
                if (maxDis > _swipeDis)
                {
                    m_bSwipeRight = true;  // 向右翻页
                    m_gl_viewer->m_texture++;
                    m_gl_viewer->m_texture %= 4;
                }
                else
                {
                    m_bSwipeLeft = true;   // 向左翻页
                    m_gl_viewer->m_texture--;
                    m_gl_viewer->m_texture = (m_gl_viewer->m_texture) % 4;
                    if (m_gl_viewer->m_texture == -1)
                        m_gl_viewer->m_texture = 3;
                }
            }
        }
    }
    // else: 同理处理左手...
}

> 技术要点: 轨迹一致性验证是关键创新点!不仅要求总位移超过阈值,还要求运动过程中方向保持稳定(超过2/3的帧方向一致)。这样可以有效过滤掉"无意识的手臂晃动"导致的误触发 🙌

5.4 双手缩放与旋转识别

// MyOrbbec.cpp — 双手开合手势(缩放+旋转)
void CBodyBasics::handleHandPosi_double()
{
    resetGesture();

    // 计算双手初始间距和当前间距
    double dis_init_X = fabs(m_leftHandPosi_init_double.worldPosition.x
                            - m_rightHandPosi_init_double.worldPosition.x);
    double dis_final_X = fabs(m_leftHandPosis_open_double.back().worldPosition.x
                             - m_rightHandPosis_open_double.back().worldPosition.x);

    // ── 缩放判定 ──
    // 间距变大(双手张开)→ 缩小
    if (dis_final_X / dis_init_X > _zoomDis)  // 1.5倍
    {
        m_bZoomOut = true;
        m_gl_viewer->m_scale += 0.5;  // 地球缩小
    }
    // 间距变小(双手合拢)→ 放大
    else if (dis_init_X / dis_final_X > _zoomDis)
    {
        m_bZoomIn = true;
        m_gl_viewer->m_scale -= 0.5;  // 地球放大
    }

    // ── 旋转判定(仅在不缩放时触发)──
    if (!m_bZoomOut && !m_bZoomIn)
    {
        // Z轴旋转:双手连线几乎水平(Y方向位移<100mm)
        if ((fabs(m_rightHandPosis_open_double.back().world_position().y
                - m_rightHandPosi_init_double.world_position().y) < 100) &&
            (fabs(m_leftHandPosis_open_double.back().world_position().y
                - m_leftHandPosi_init_double.world_position().y) < 100))
        {
            // atan2计算双手连心线相对初始位置的偏转角
            double ang = atan2(
                m_rightHandPosis_open_double.back().world_position().y
                - m_leftHandPosis_open_double.back().world_position().y,
                m_rightHandPosis_open_double.back().world_position().x
                - m_leftHandPosis_open_double.back().world_position().x);
            ang = 180 * ang / CV_PI;

            if (fabs(ang) > _rotateAng * 2)  // 超过20°触发
                m_dRotateAngZ = ang;
        }

        // Y轴旋转:双手连线几乎竖直(X方向相对稳定)
        if ((fabs(m_rightHandPosis_open_double.back().world_position().x
                - m_rightHandPosi_init_double.world_position().x) < 100) &&
            (fabs(m_leftHandPosis_open_double.back().world_position().x
                - m_leftHandPosi_init_double.world_position().x) < 100))
        {
            double ang = atan2(
                m_rightHandPosis_open_double.back().world_position().z
                - m_leftHandPosis_open_double.back().world_position().z,
                m_rightHandPosis_open_double.back().world_position().x
                - m_leftHandPosis_open_double.back().world_position().x);
            ang = 180 * ang / CV_PI;

            if (fabs(ang) > _rotateAng / 2)  // 超过5°触发
                m_dRotateAngY = ang;
        }

        // 优先响应旋转幅度更大的轴
        if (fabs(m_dRotateAngZ) > fabs(m_dRotateAngY))
        {
            m_dRotateAngY = 0;
            m_gl_viewer->m_az += m_dRotateAngZ;  // Z轴旋转
        }
        else
        {
            m_dRotateAngZ = 0;
            m_gl_viewer->m_ay += m_dRotateAngY;  // Y轴旋转
        }
    }
}

5.5 手势状态机

> 整个手势识别是基于 Astra HandPose 状态机 驱动的:

手势状态 Astra定义 系统行为
✋ Grip 手握拳 进入追踪模式,记录初始位置,开始记录轨迹
🖐️ Unknown 手张开/放松 结束当前手势,执行轨迹分析并触发对应操作
🎯 Lasso 食指+中指张开(未使用) 预留点击重置功能
// MyOrbbec.cpp — 手势状态驱动的控制流
void CBodyBasics::GestureControl(astra::Body body, astra::JointList joints)
{
    astra::HandPose HandPoseR = body.hand_poses().right_hand();
    astra::HandPose HandPoseL = body.hand_poses().left_hand();

    // 场景1:右手握拳(Grip)+ 左手放松 → 单手右手翻页
    if ((HandPoseR == astra::HandPose::Grip) &&
        (HandPoseL == astra::HandPose::Unknown))
    {
        if (joints[(int)astra::JointType::RightWrist].worldPosition.y
            - joints[(int)astra::JointType::RightElbow].worldPosition.y > 30)
        {
            if (m_bFistRightHand)  // 首次检测:记录初始位置
            {
                m_rightHandPosis_open.clear();
                m_rightHandPosi_init = m_rightHandPosi;
                m_bFistRightHand = false;
            }
            else  // 持续追踪:记录轨迹
            {
                m_rightHandPosis_open.push_back(m_rightHandPosi);
                if (m_rightHandPosis_open.size() == 10)
                    handleHandPosi_single(true);  // 触发翻页判定
            }
        }
    }

    // 场景2:双手同时握拳 → 双手缩放/旋转
    else if ((HandPoseL == astra::HandPose::Grip) &&
             (HandPoseR == astra::HandPose::Grip))
    {
        if ((joints[(int)astra::JointType::RightWrist].worldPosition.y
                - joints[(int)astra::JointType::RightElbow].worldPosition.y > 100) &&
            (joints[(int)astra::JointType::LeftWrist].worldPosition.y
                - joints[(int)astra::JointType::LeftElbow].worldPosition.y > 100))
        {
            // 速度方向决定是缩放还是旋转
            if (m_rightHand_velocity.Vx > 40 && m_leftHand_velocity.Vx < -40)
                m_hand_zoom = 0;  // 张开 → 缩小
            else if (m_rightHand_velocity.Vx <= -40 && m_leftHand_velocity.Vx >= 40)
                m_hand_zoom = 1;  // 合拢 → 放大
            else
                m_hand_zoom = -1; // 旋转模式
            // ...
        }
    }

    else
        resetBufData();  // 恢复初始状态
}

六、OpenGL地球仪渲染 🌍

这是整个系统的"颜值担当",负责把操作指令变成丝滑的3D地球仪旋转动画。

6.1 渲染管线

19f1b9650a026.png

6.2 纹理贴图加载

// ColorViewer.cpp — 地球仪纹理贴图初始化
bool ColorViewer::init(int argc, char **argv)
{
    glutInit(&argc, argv);
    glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE);
    glutInitWindowSize(640, 480);
    glutCreateWindow("gesture control");

    // 创建球体对象
    earth_obj = gluNewQuadric();

    // 加载4张地球纹理贴图
    for (int i = 1; i <= 4; i++)
    {
        glGenTextures(1, &texName[i]);
        glBindTexture(GL_TEXTURE_2D, texName[i]);

        // DECAL模式:纹理颜色直接覆盖,不混合
        glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_DECAL);

        // 从data目录加载.bmp地球纹理
        string path = "data/" + to_string(i) + ".bmp";
        cv::Mat TextureImage = cv::imread(path);
        cv::cvtColor(TextureImage, TextureImage, COLOR_BGR2RGB);

        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB,
                     TextureImage.cols, TextureImage.rows,
                     0, GL_RGB, GL_UNSIGNED_BYTE, TextureImage.data);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    }

    // 初始状态
    m_scale = 1.0;  // 缩放
    m_ax = 0; m_ay = 0; m_az = 0;  // 旋转角度
    m_tx = 0; m_ty = 0;             // 平移
    m_texture = 1;                  // 当前纹理索引
    return true;
}

6.3 渲染循环(每帧执行)

// ColorViewer.cpp — 每帧渲染回调
void ColorViewer::mydisplay()
{
    hand_gesture->Update();  // 更新手势识别

    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glLoadIdentity();

    glPushMatrix();
    glTranslatef(m_tx, m_ty, -80.0f);      // 平移
    glScalef(m_scale, m_scale, m_scale);   // 缩放
    glRotatef(m_az, 0, 0, 1.0f);           // Z轴旋转
    glRotatef(m_ay, 0, 1.0f, 0);           // Y轴旋转
    glRotatef(90, 1, 0, 0.0f);             // 初始自转90°(赤道朝前)

    gldisplay();  // 绘制球体

    glPopMatrix();
    glutSwapBuffers();  // 双缓冲交换(防闪烁)
}

void ColorViewer::gldisplay()
{
    glEnable(GL_CULL_FACE);
    glCullFace(GL_BACK);
    gluQuadricNormals(earth_obj, GL_SMOOTH);
    glBindTexture(GL_TEXTURE_2D, texName[m_texture]);  // 当前贴图
    glEnable(GL_TEXTURE_2D);
    gluQuadricTexture(earth_obj, GL_TRUE);
    gluSphere(earth_obj, 20, 60, 60);  // 球体:半径20,60经×60纬
    glDisable(GL_TEXTURE_2D);
}

6.4 操作到渲染的完整链路

手势输入 中间变量 OpenGL参数 视觉效果
单手水平挥动 m_bSwipeLeft/Right m_texture ±= 1 切换地球纹理贴图(翻页效果)
双手间距张大 m_bZoomOut m_scale += 0.5 地球缩小(拉远视角)
双手间距缩小 m_bZoomIn m_scale -= 0.5 地球放大(拉近视角)
双手连心线旋转 m_dRotateAngY/Z m_ay/az += delta 绕Y/Z轴旋转
Lasso点击 m_bClick 调用resetView() 重置所有参数到初始状态

七、系统集成与主函数 🚀

7.1 完整主函数

// OrbbecGestureContrl.cpp — 程序入口
#include "OrbbecGestureContrl.h"
#include "ColorViewer.h"
#include "MyOrbbec.h"
#include <thread>

using namespace std;

extern CBodyBasics* hand_gesture;

int main(int argc, char **argv)
{
    // 1. 初始化Astra SDK
    hand_gesture = new CBodyBasics;
    astra_status_t hr = astra_initialize();

    // 2. 创建OpenGL渲染窗口
    ColorViewer* myGLViewer = new ColorViewer();
    myGLViewer->init(argc, argv);
    hand_gesture->m_gl_viewer = myGLViewer;

    // 3. 初始化深度相机和骨骼追踪
    hr = hand_gesture->InitializeDefaultSensor();

    if (hr == ASTRA_STATUS_SUCCESS) {
        cout << "Astra initialized successfully!" << endl;
        myGLViewer->run();  // 进入OpenGL主循环(永不返回)
    } else {
        cout << "Kinect initialization failed!" << endl;
    }
    return 0;
}

7.2 相机配置参数

; orbbec.ini — Astra相机关键参数

[Depth]
; VGA分辨率640×480,30fps,深度范围0-10000mm
Resolution=1          ; 1 = VGA (默认)
FPS=30
MaxDepthValue=10000   ; 最大测距10米
HoleFilter=1          ; 启用空洞填补
CloseRange=1          ; 启用近距离模式(0.3-0.5米)
InputFormat=3         ; Packed 11-bit压缩格式

[Image]
; 彩色图:VGA@30fps,YUV422格式
Resolution=1           ; 1 = VGA
FPS=30
OutputFormat=201       ; YUV422格式

[IR]
; 红外图:QVGA@30fps
Resolution=0           ; 0 = QVGA (深度分辨率匹配)
FPS=30

八、测试结果与问题分析 🧪

8.1 功能实现情况

功能 状态 用户体验
🌍 地球仪OpenGL渲染 ✅ 完美 球体圆润,纹理清晰,切换流畅
🔄 左右翻页(单手挥动) ✅ 基本可用 灵敏度较高,方向判断准确
🔎 放大/缩小(双手开合) ✅ 基本可用 Z方向速度差异判定可靠
🌍 空间旋转(双手位移) ✅ 基本可用 Y/Z轴旋转响应正常
🎯 点击重置(Lasso) ✅ 已实现 掌心贴近持续0.75s触发
⚡ 实时性 ✅ 优秀 体感延迟无明显感知

8.2 已知问题与改进方向

> ⚠️ 翻页误触发: 单次挥动可能造成多次翻页

原因分析: 翻页触发阈值为18mm,但未对"连续快速挥动"做去抖处理。

改进方案:

// 改进思路:增加"冷却时间"
bool m_bCooldown = false;  // 冷却标志
int  m_nCooldownFrames = 15;  // 冷却帧数(约0.5秒@30fps)

void CBodyBasics::handleHandPosi_single(bool bRight)
{
    if (m_bCooldown) return;  // 冷却中跳过

    // ... 原有的判定逻辑 ...

    if (翻页触发) {
        // 更新OpenGL参数...
        m_bCooldown = true;  // 进入冷却
        schedule_reset(m_nCooldownFrames);  // 15帧后重置冷却标志
    }
}

> ⚠️ Ubuntu/Jetson Nano内存问题: Windows编译运行正常,但Jetson Nano上Ubuntu编译通过但运行报错(疑似动态内存申请失败)。

初步分析:

  • Astra SDK在ARM平台上的内存分配策略与x64不同
  • OpenCV/GL库的静态链接配置可能存在平台差异
  • malloc在ARM上的内存对齐要求更严格

改进方向:

  • 改用 new/delete 替代 malloc/free
  • 预分配内存池而非动态申请
  • 使用 cv::Mat 替代裸指针管理图像缓冲区

九、技术创新与亮点 🚀

9.1 创新点总结

# 创新点 技术方案 效果
1️⃣ 轨迹一致性验证 前后半段运动方向对比(容忍±15°),超过2/3帧一致才触发 翻页误触发率大幅降低
2️⃣ 速度模式区分 双手Grip时,根据Vx速度方向差异区分"缩放"和"旋转" 同手势触发不同功能,交互更自然
3️⃣ 坐标归一化 肩部坐标系校正,XYZ含义固定(左右/上下/前后) 用户面向任意方向均能正确识别
4️⃣ 双重滤波 高斯+均值串联滤波,兼顾平滑和响应速度 坐标抖动抑制明显
5️⃣ 多阈值参数 针对不同手势配置独立阈值(翻页18mm/缩放1.5倍/旋转10°) 精细化控制,触觉反馈明确

9.2 核心算法复杂度分析

算法 时间复杂度 空间复杂度 帧率保证
骨骼点绘制 O(J) O(1) ✅
高斯滤波 O(size) O(size) ✅
速度计算 O(N) O(N) ✅
轨迹一致性验证 O(N) O(N) ✅
OpenGL渲染 O(顶点数) O(1) ✅

十、总结与展望 🌅

10.1 项目总结

"用手势操控地球仪?这不是科幻,这是你触手可及的未来。"

本项目成功实现了一套基于Astra深度相机的骨架手势识别系统,通过实时追踪人体18个关节点,结合运动速度分析和轨迹一致性验证,完成了对三维地球仪的自然隔空操控:

维度 成果
🎮 交互自然 纯手势控制,无需任何实体控制器
⚡ 响应迅速 高斯+均值双重滤波,体感延迟几乎为零
🦴 技术扎实 18点骨骼追踪、坐标归一化、速度向量计算
🐛 问题清醒 翻页去抖和ARM平台内存问题已有改进思路

10.2 未来优化方向

方向 目标 预期价值
🔇 翻页去抖 增加冷却时间和运动趋势预判 消除误触发,提升操作精度
🤖 ARM移植 修复Jetson Nano内存问题 嵌入式部署,拓展应用场景
📱 移动端 移植到iOS/Android + LiDAR 手机直接运行,随时随地使用
🎯 更多手势 增加捏合缩放、滑动手腕选择等 手势库扩充,覆盖更多场景
🌍 更多3D模型 太阳系、人体解剖、地球构造等 内容库扩充,一套系统多场景复用

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注