基于关键特征实现 3D 人物骨骼映射

4,667 阅读18分钟

楔子

左边导入模型,右边标准骨骼 介绍一下需求背景,我们所做的应用是一个基于 Cocos Creator 引擎用于生产 3D 虚拟角色的编辑器,其有一项很重要的功能是对于角色模型的驱动;用户可以导入自定义的角色模型,经过与标准人物模型骨架映射后,即可使用内置标准动画系统驱动导入的人物模型,其中很重要的一个环节就是实现导入模型骨骼标准人物骨骼自动映射。简单聊聊骨骼映射算法的实现。

附:示例 GitHub - kinglisky/human-skeleton-mapping: 3D 人物骨骼映射

骨骼数据结构

各种 3D DCC 工具都支持模型的骨骼绑定,目前我们的标准人物骨骼是基于 3DS MAX 规范搭建的,人物骨骼一般是以树状格式存储的。一般以髋部(hip)为人物的起始节点,分为左右大腿与上半身三个子节点,上半身继续往下分化出左右肩膀与头部,以此类推往子级分化。

髋部节点
右手节点
骨骼数据结构

export interface Vec3 {
    x: number;
    y: number;
    z: number;
}

export interface SkeletonNode {
    name: string;
    path: string;
    pos: Vec3;
    children: SkeletonNode[];
}

const rootNode: SkeletonNode = {
    name: 'hana',
    path: 'scene_root/SceneNode-1/hana',
    pos: { x: 0, y: 0, z: 0 },
    children: [
        {
            name: 'Armature',
            path: 'scene_root/SceneNode-1/hana/Armature',
            pos: { x: 0, y: 0, z: 0 },
            children: [
                {
                    name: 'Hips',
                    path: 'scene_root/SceneNode-1/hana/Armature/Hips',
                    pos: {
                        x: 0,
                        y: 1.0363331089993273,
                        z: 0.000019000133000931006,
                    },
                    children: [
                        /** some children node */
                    ],
                },
            ],
        },
    ],
};

骨骼的数据结构如下:

  • name 骨骼节点名称
  • path 骨骼节点完整路径
  • pos 节点的世界坐标
  • children 子节点

需要注意,一根骨骼是由一个起点与终点构成路径, pos 表示的骨骼起点的 3D 世界坐标。
image.png
我们需要实现的就是这样两个人物关键骨骼节点的映射算法,头对头,脚对脚,屁股对屁股。
左目标骨骼,右边标准骨骼

基于骨骼路径名称实现映射

由于人体骨骼是一个典型的树结构,且每个节点包含了完整路径信息,早期使用的映射算法很简单:判断两棵骨骼树节点的路径相似度,相似度最高的两个节点即为匹配节点。
那如何判断两个节点的路径相似度?或者换个问法如何判断两个字符相似度?找找相应的算法算法即可,比较典型的有:

  1. 编辑距离(Edit Distance):编辑距离是衡量两个字符串之间差异的一种方式,其值为将一个字符串转换成另一个字符串所需的最少单字符编辑操作数。常见的编辑距离算法包括莱文斯坦距离(Levenshtein Distance)和汉明距离(Hamming Distance)等。
  2. Jaccard 相似度(Jaccard Similarity):Jaccard 相似度是衡量两个集合之间相似性的指标,计算方式是两个集合的交集大小除以并集大小。在处理字符串时,可以将字符串视为字符的集合或 n-gram 的集合。
  3. 余弦相似度(Cosine Similarity):余弦相似度常用于衡量文本相似性,通过计算两个向量之间的夹角余弦值来度量它们之间的相似度。可以将字符串转换为特征向量(如词袋模型、TF-IDF 或 Word2Vec 表示),然后计算这些向量之间的余弦相似度。
  4. 最长公共子序列LCS)是一个在一个序列集合中(通常为两个序列)用来查找所有序列中最长子序列的问题。这与查找最长公共子串的问题不同的地方是:子序列不需要在原序列中占用连续的位置 。最长公共子序列问题是一个经典的计算机科学问题,也是数据比较程序,比如 Diff 工具,和生物信息学应用的基础。它也被广泛地应用在版本控制,比如 Git 用来调和文件之间的改变。。
  5. Dice 系数(Sørensen-Dice Coefficient):Sørensen-Dice 系数是一种基于集合的相似度度量方法,常用于比较字符串。它计算两个集合(可以是字符集合或 n-gram 集合)的交集大小的两倍除以两个集合的大小之和。

实际测试下来,最长公共子序效果稍微好一点,但还无法达到可用的程度,实现如下:

import type { SkeletonNode } from './types';

/**
 * 统一驼峰与一些特殊分割符转下划线,转小写
 * @param name
 * @returns
 */
const uniformName = (name: string) => {
    const items = name.split('_');
    return items
        .map((item) => {
            return item
                .replace(/\B([A-Z])|\s|-|~|\/|\\/g, '_$1')
                .toLowerCase()
                .replace(/_+/g, '_');
        })
        .join('_');
};

/**
 * 遍历节点
 * @param node
 * @param cb
 * @returns
 */
const traverseNode = (node: SkeletonNode, cb: (item: SkeletonNode) => void) => {
    cb(node);
    (node.children || []).forEach((it) => traverseNode(it, cb));
};

/**
 * 字符串的最长公共子序列(LCS)
 * @param s1
 * @param s2
 * @returns
 */
export const longestCommonSubsequence = (s1: string, s2: string) => {
    const m = s1.length;
    const n = s2.length;
    const dp: number[][] = Array.from({ length: m + 1 }, () =>
        Array.from({ length: n + 1 }, () => 0)
    );

    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
                dp[i][j] = 1 + dp[i - 1][j - 1];
            } else {
                dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
            }
        }
    }

    const maxLength = dp[m][n];
    // 换算成与最长字符的比值
    return maxLength / Math.max(s1.length, s2.length);
};

/**
 * 通过路径名称实现映射
 * @param targetRoot
 * @param standardRoot
 * @param computer
 */
export const mappingBySkeletonPath = (
    targetRoot: SkeletonNode,
    standardRoot: SkeletonNode,
    computer: (s1: string, s2: string) => number
) => {
    const targetNodes: SkeletonNode[] = [];
    const standardNodes: SkeletonNode[] = [];

    traverseNode(targetRoot, (node) => {
        node.path = node.path
            .split('/')
            .map((name) => uniformName(name))
            .join('/');
        targetNodes.push(node);
    });

    traverseNode(standardRoot, (node) => {
        node.path = node.path
            .split('/')
            .map((name) => uniformName(name))
            .join('/');
        standardNodes.push(node);
    });

    const results: string[][] = [];
    standardNodes.forEach((standardNode) => {
        const similarityValues = targetNodes.map((targetNode) =>
            computer(targetNode.path, standardNode.path)
        );

        // 查找相似度最高的节点作为匹配节点
        let maxIndex = -1;
        let maxSimilarity = -1;
        similarityValues.forEach((similarity, index) => {
            if (similarity > maxSimilarity) {
                maxSimilarity = similarity;
                maxIndex = index;
            }
        });

        // 可以设置一个最小相似性阈值过排除掉不可用的路径匹配,这里不做过滤
        const limitSimilarity = 0;
        if (maxSimilarity > limitSimilarity) {
            results.push([standardNode.path, targetNodes[maxIndex].path]);
            // 已经匹配过的节点不再做匹配
            targetNodes.slice(maxIndex, 1);
        }
    });

    return results;
};
const STANDARD_SKELETON_NODE_NAMES = {
  root: '根节点',
  head: '头',
  neck: '脖子',
  upper_chest: '上胸部',
  chest: '胸部',
  spine: '腰部脊柱',
  hips: '盆骨',
  left_shoulder: '左边锁骨',
  left_upper_arm: '左上臂',
  left_lower_arm: '左小臂',
  left_hand: '左手掌',
  right_shoulder: '右边锁骨',
  right_upper_arm: '右上臂',
  right_lower_arm: '右小臂',
  right_hand: '右手掌',
  left_upper_leg: '左边大腿',
  left_lower_leg: '左小腿',
  left_foot: '左脚踝',
  left_toes: '左脚掌',
  left_foot_tip: '左脚尖',
  right_upper_leg: '右边大腿',
  right_lower_leg: '右小腿',
  right_foot: '右脚踝',
  right_toes: '右脚掌',
  right_foot_tip: '右脚尖',
  // ...手指节点
}

示例一:较为标准的骨架匹配结果
image.png
demo.webp
示例二:非标准的骨架匹配结果
hana.png
demo.webp
示例三:日本命名骨架,这种路径匹配就无解了
日本命名骨骼
基于路径名称实现映射并不是一个可靠的通用方案,在一些比较标准的模型上可以实现较好的效果。但在一些诸如手指,脚尖一类叶子骨骼节点上的映射效果就比较差了,这些节点一些设计师在制作模型时可能只给几个简单的索引命名,还有就是命名差异较大的模型效果就十分鬼畜了。除了匹配精度还有就是多语言的问题,不能保证所有的骨骼命名都使用英文命名。所以需要一个摆脱命名限制基于骨骼特征实现映射方案

骨骼关键节点特征检测

先不谈具体实现方式,提个问题,给你两张人物骨骼图片,让你肉眼进行骨骼映射,你是怎么做的?是不是找出图片中两具骨骼对应部位进行匹配,头对头、手对手、脚对脚。如果我们可以检测出两个人物骨骼这些关键特征是不是就可以实现人物骨骼的映射了?

如何检测

image.png人眼很高级,几亿年进化的产物,瞅一眼就知道手脚屁股,快说一句谢谢眼睛。眼睛会抓重点,关键骨骼节点检测我们也需要找到一个**起始节点,**看上面的人物模型,很容易想到从以髋关节为起点:

  1. 找到髋关节,髋关节分出 3 个骨骼节点(脊柱,左右腿)
  2. 左右脚一路向下到达脚尖
  3. 脊柱往上可以找到胸部节点,胸往上分出 3 个骨骼节点(脖子,左右肩膀)
  4. 脖子往上是头
  5. 左右肩膀往下走是整条手臂可以找手
  6. 手会分出 5 个手指,每根手指 3 个关节

大功告成,先来找到髋关节,观察下髋关节的特征:

  • 层级较低
  • 分出 3 个子节点

image.png

import type { SkeletonNode } from './types';

class SkeletonPart {
    constructor(private nodes: SkeletonNode[]) {}

    findHip() {
        return this.nodes.find((node) => node.children.length === 3);
    }
}

那找几个模型验证下?
image.png
image.png
image.png
image.png
image.png
为了满足人类奇奇怪怪的 XP,设计师给出的模型可能也是奇奇怪怪的,不是简简单单就能找到髋关节的,主要是髋关节的特征并不明显,不能单通过分化出 3 个节点来作为关键特征。你可能会想到增加特征条件来区分出髋关节,但有个悖论:想要增加特征判断不可以避免需要使用其子节点信息来断言,而我们需要通过父节点来断言出子节点(脊柱,左右腿),这不就死循环了。
所以需要换个思路,人类与其他动物最根本的区别是:人类会制造使用工具从事生产劳动,动物则不会,关键就是那双灵巧的双手,以手作为起点,人即得以区分。
image.png

  1. 找出左右手,判断左右手时会依据子节点(手指)的特征来推断
  2. 左右手往父级走,两只手臂交汇的地方即为胸部
  3. 胸部往双手沿路的第一个节点即为左右锁骨
  4. 胸部排除掉左右锁骨且夹在中的就是脖子,脖子往后就是头部
  5. 胸部往父级走遇到第一个有多个子节点父节点就是髋关节,髋关节到胸部沿经节点为脊柱(脊柱可能有一到两节)
  6. 髋关节排除掉脊柱,剩余骨骼中朝向与脊柱大致相反两根骨骼即为左右大腿

检测双手

image.png
image.png
先确定手掌节点的关键特征:

  • 手部节点至少有 5 个子节点(手指),不排除有些多余节点
  • 5 个手指的关节数量是一致的,手指的关节数量为 3 或 4 (注意末端的原点也算作一个节点)
import { uniformName, traverseNode, getNodeInfo } from './utils';

import type { SkeletonNode } from './types';

interface MaybeHand {
    weight: number;
    hand: SkeletonNode;
    fingers: SkeletonNode[];
}

export class SkeletonPart {
    private nodes: SkeletonNode[] = [];

    constructor(rootNode: SkeletonNode) {
        traverseNode(rootNode, (node) => this.nodes.push(node));

        // 统一骨骼节点名称
        this.nodes.forEach((node) => {
            node.path = node.path
                .split('/')
                .map((name) => uniformName(name))
                .join('/');
        });

        this.initHandNodes();
    }

    /**
     * 初始化左右手
     * @returns
     */
    private initHandNodes() {
        const maybeHands: Array<MaybeHand> = [];

        // 手指数量
        const fingerCount = 5;
        this.nodes.forEach((maybeHandNode) => {
            // 排除子节点数量小于 5 的节点
            if (maybeHandNode.children.length < 5) return;

            const { leafNodes, maxLeafDepth } = getNodeInfo(maybeHandNode);
            // 按叶子层级分类
            const nodeDepthDict: Record<number, SkeletonNode[]> = {};
            const maybeHandDepth = maybeHandNode.path.split('/').length;
            leafNodes.forEach((leafNode) => {
                const leafNodeDepth = leafNode.path.split('/').length;
                // 计算相对层级
                const differenceDepth = leafNodeDepth - maybeHandDepth;
                if (!nodeDepthDict[differenceDepth]) {
                    nodeDepthDict[differenceDepth] = [leafNode];
                } else {
                    nodeDepthDict[differenceDepth].push(leafNode);
                }
            });

            const leafNodeDepthCate = Object.entries(nodeDepthDict)
                .filter(
                    ([depth, nodes]) =>
                        nodes.length >= fingerCount &&
                        [3, 4].includes(Number(depth))
                )
                .map(([depth, nodes]) => {
                    const depthValue = Number(depth);
                    // 权重计算,以【叶子层级最深】且【同级节点数最接近 5 个】优先
                    const weight =
                        depthValue / maxLeafDepth + fingerCount / nodes.length;
                    return {
                        depth: depthValue,
                        nodes,
                        weight,
                    };
                });

            // 排除无相同叶子层级的节点
            if (!leafNodeDepthCate.length) return;

            // 权重排序
            leafNodeDepthCate.sort((a, b) => b.weight - a.weight);
            maybeHands.push({
                hand: maybeHandNode,
                fingers: leafNodeDepthCate[0].nodes,
                weight: leafNodeDepthCate[0].weight,
            });
        });

        maybeHands.sort((a, b) => b.weight - a.weight);
        // 取权重最大两个的节点来推断左右手
        this.inferHands(maybeHands.slice(0, 2));
    }

    private inferHands(hands: MaybeHand[]) {
        console.log('inferHands', hands);
    }
}

注意这里有个计算节点权重的操作,因为一个可能的手掌节点可能会有多个不同层级候选手指节点,我们需要找到可能性(权重)最大的那个,这里的权重规则比较简单:

  • 以叶子层级较深的节点优先
  • 以子节点的数量接近 5 优先

推断左右手

在不依赖节点名称的前提推断左右手会比较繁琐,需要用到一些空间向量知识。
image.png

Cocos Creator 的世界坐标系采用的是笛卡尔右手坐标系,默认 x 向右,y 向上,z 向外,同时使用 -z 轴为正前方朝向。

所以,如果我们可以计算出手掌的 x 向量(大拇指)和 z 向量(中指)就可以通过叉乘(向量积)等到法向量 y 通过 y 向量的正负(右手为正,左手为负)就可以推断左右手了。
右手法向量:
image.png
左手法向量:
image.png
image.png
需要注意一点,整个手掌与手指不一定都处于一个平面,所以计算时只取第一节手指做向量计算。向量与叉乘计算很简单:
image.png
叉乘公式如下:
a×b=[a1a2a3]×[b1b2b3]=[a2b3a3b2a3b1a1b3a1b2a2b1]\mathbf{a} \times \mathbf{b} = \begin{bmatrix} a_{1} \\ a_{2} \\ a_{3} \end{bmatrix} \times \begin{bmatrix} b_{1} \\ b_{2} \\ b_{3} \end{bmatrix} = \begin{bmatrix} a_{2}b_{3} - a_{3}b_{2} \\ a_{3}b_{1} - a_{1}b_{3} \\ a_{1}b_{2} - a_{2}b_{1} \end{bmatrix}

/**
 * 创建相对目标点的向量
 * @param root
 * @param p
 * @returns
 */
export const createVector = (root: Vec3, p: Vec3) => {
    return {
        x: p.x - root.x,
        y: p.y - root.y,
        z: p.z - root.z,
    } as Vec3;
};

/**
 * 计算两个向量的叉乘(法向量)
 * @param vec1
 * @param vec2
 */
export const calculateNormalVector = (v1: Vec3, v2: Vec3) => {
    return {
        x: v1.y * v2.z - v1.z * v2.y,
        y: v1.z * v2.x - v1.x * v2.z,
        z: v1.x * v2.y - v1.y * v2.x,
    };
};

计算手掌的法向量需要用到大拇指与中指,所以接下来先做手指的推断。

推断手指

一般人物模型中,手指依据长度从从长到短分为:中指、食指、无名指、小拇指和大拇指,我们只需要计算手指关节的长度就可以知道手指的分类了。可能有些模型会例外,但不影响向量的计算,我们只需要取最长的手指(中指)与最短的手指(大拇指)计算即可。
image.png

interface MaybeHand {
    weight: number;
    root: SkeletonNode;
    fingers: SkeletonNode[];
}

interface HandPart {
    root: SkeletonNode;
    finger: {
        thumb?: SkeletonNode[];
        pinky?: SkeletonNode[];
        ring?: SkeletonNode[];
        index?: SkeletonNode[];
        middle?: SkeletonNode[];
    } | null;
}

/**
 * 计算两点距离
 * @param p1
 * @param p2
 * @returns
 */
const calculateVecDistance = (p1: Vec3, p2: Vec3) => {
    return Math.sqrt(
        (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2 + (p2.z - p1.z) ** 2
    );
};

export class SkeletonPart {
    /**
     * 左右手
     */
    public hand: {
        left: HandPart | null;
        right: HandPart | null;
    } = {
        left: null,
        right: null,
    };

    /**
     * 推断左右手
     * @param hands
     */
    private inferHands(hands: MaybeHand[]) {
        hands.forEach((hand) => {
            const fingerDistanceList = hand.fingers.map((finger) => {
                // 手掌到指尖途径的节点(手指关节)
                const fingerPathwayNodes = getPathwayNodes(hand.root, finger);
                // 计算手掌到指尖的距离
                let distance = 0;
                fingerPathwayNodes.reduce((origin, node) => {
                    distance += calculateVecDistance(origin.pos, node.pos);
                    return node;
                });
                return {
                    distance,
                    // 手指根节点
                    start: fingerPathwayNodes[1],
                    // 手指末端节点
                    end: finger,
                };
            });
            // 以手指长度排序
            fingerDistanceList.sort((b, a) => a.distance - b.distance);
            // 取长度最长的 5 个节点
            const fingerItems = fingerDistanceList.slice(-5);
            // 大拇指、小拇指、无名指、食指、中指
            const finger = {
                // 大拇指
                thumb: [fingerItems[0].start, fingerItems[0].end],
                // 小指
                pinky: [fingerItems[1].start, fingerItems[1].end],
                // 无名指
                ring: [fingerItems[2].start, fingerItems[2].end],
                // 食指
                index: [fingerItems[3].start, fingerItems[3].end],
                // 中指
                middle: [fingerItems[4].start, fingerItems[4].end],
            };
            // 通过大拇指与中指计算出手掌所在面的法向量
            const normalVec = calculateNormalVector(
                createVector(hand.root.pos, finger.thumb[0].pos),
                createVector(hand.root.pos, finger.middle[0].pos)
            );

            // 通过 y 轴分量判断左右手
            const direction = normalVec.y > 0 ? 'left' : 'right';
            this.hand[direction] = {
                root: hand.root,
                finger,
            };
        });
    }
}

检测胸部

找到左右手后往父级找到第一个交汇点就是胸部了。
image.png

export class SkeletonPart {
    private nodes: SkeletonNode[] = [];

    /**
     * path -> node 字典,方便查找目标节点
     */
    public nodesDict: Record<string, SkeletonNode> = {};

    /**
     * 左右手
     */
    public hand: {
        left: HandPart | null;
        right: HandPart | null;
    } = {
        left: null,
        right: null,
    };

    /**
     * 胸部
     */
    public chestNode: SkeletonNode | null = null;

    /**
     * 初始化胸部节点,找到双手的交汇节点则为胸部节点
     */
    private initChestNode() {
        if (!this.hand.left || !this.hand.right) {
            this.chestNode = null;
            return;
        }

        const leftHand = this.hand.left.root;
        const rightHand = this.hand.right.root;
        // 统计左右手途径路径计数,计数为 2 且路径最长的交点为胸部
        const pathCounter: Record<string, number> = {};
        const backtrackingPath = (paths: string[]) => {
            while (paths.length) {
                paths.pop();
                const currentKey = paths.join('/');
                pathCounter[currentKey] = (pathCounter[currentKey] || 0) + 1;
            }
        };
        backtrackingPath(leftHand.path.split('/'));
        backtrackingPath(rightHand.path.split('/'));
        // 找到计算为 2 的路径
        const intersectionPaths = Object.entries(pathCounter)
            .filter(([_, count]) => count === 2)
            .map(([p]) => p);
        // 路径长度排序
        intersectionPaths.sort((a, b) => b.length - a.length);
        if (intersectionPaths.length) {
            // 路径最长的交汇点为胸部
            this.chestNode = this.nodesDict[intersectionPaths[0]];
        }
    }
}

检测左右锁骨

胸部确定后,往左右手走的第一层节点为锁骨。
image.png

export class SkeletonPart {
    private nodes: SkeletonNode[] = [];

    /**
     * path -> node 字典,方便查找目标节点
     */
    public nodesDict: Record<string, SkeletonNode> = {};

    /**
     * 左右手
     */
    public hand: {
        left: HandPart | null;
        right: HandPart | null;
    } = {
        left: null,
        right: null,
    };

    /**
     * 胸部
     */
    public chestNode: SkeletonNode | null = null;

    /**
     * 左右锁骨
     */
    public clavicular: {
        left: SkeletonNode | null;
        right: SkeletonNode | null;
    } = {
        left: null,
        right: null,
    };

    
    /**
     * 初始化左右锁骨,胸部往左右手走的第一层节点为锁骨
     */
    private initClavicularNodes() {
        const { chestNode, hand } = this;
        if (!chestNode || !hand.left || !hand.right) return;

        chestNode.children.forEach((node) => {
            if (hand.left!.root.path.includes(node.path)) {
                this.clavicular.left = node;
                return;
            }
            if (hand.right!.root.path.includes(node.path)) {
                this.clavicular.right = node;
            }
        });
    }
}

检测脖子

image.png
胸部除了分化出左右锁骨、脖子以外可能还有其他的节点,例如女性角色用来支撑欧派的骨骼节点,该如何区分出脖子与其他节点呢?
还是归纳其脖子节点的特征:

  • 脖子节点夹在左右锁骨之间且距离两节点距离相近,实现上可以判断节点左右锁骨节点向量的夹角是否相等
  • 脖子节点几乎是与左右锁骨共面的,实现上可以计算脖子节点向量与左右锁骨所在平面的法向量夹角是否趋于 90 来判断

image.png

export const angleBetweenVectors = (va: Vec3, vb: Vec3): number => {
    const a: number[] = [va.x, va.y, va.z];
    const b: number[] = [vb.x, vb.y, vb.z];

    const dotProduct = (a: number[], b: number[]): number => {
        return a.reduce((acc, cur, i) => acc + cur * b[i], 0);
    };

    const magnitude = (a: number[]): number => {
        return Math.sqrt(a.reduce((acc, cur) => acc + cur ** 2, 0));
    };

    const dot = dotProduct(a, b);
    const magA = magnitude(a);
    const magB = magnitude(b);
    return Math.acos(dot / (magA * magB)) * (180 / Math.PI);
}
export class SkeletonPart {
    /**
     * 脖子节点
     */
    public neckNode: SkeletonNode | null = null;

    /**
     * 通过胸部节点往上找脖子节点
     * @param handNodes
     * @returns
     */
    private initNeckNode() {
        const { chestNode, clavicular } = this;

        if (!chestNode || !clavicular.left || !clavicular.right) return;

        const { left: leftClavicular, right: rightClavicular } = clavicular;
        // 左锁骨节点相对于胸部向量
        const leftClavicularVec = createVector(
            chestNode.pos,
            leftClavicular.pos
        );
        // 右锁骨节点相对于胸部向量
        const rightClavicularVec = createVector(
            chestNode.pos,
            rightClavicular.pos
        );
        // 左右锁骨平面的法向量
        const shoulderNormalVec = calculateNormalVector(
            leftClavicularVec,
            rightClavicularVec
        );

        let neckNode: SkeletonNode | null = null;
        let maxWeight = 0;

        chestNode.children.forEach((node) => {
            const currentPath = node.path;
            // 排除左右锁骨
            if (
                leftClavicular.path.includes(currentPath) ||
                rightClavicular.path.includes(currentPath)
            ) {
                return;
            }

            // 计算当前节点与左右锁骨的夹角
            const nodeVec = createVector(chestNode.pos, node.pos);
            const leftAngle = angleBetweenVectors(nodeVec, leftClavicularVec);
            const rightAngle = angleBetweenVectors(nodeVec, rightClavicularVec);
            // 计算两个夹角比值,用小角度比大角度,相近比值应该趋于 1
            const weight1 =
                Math.min(leftAngle, rightAngle) /
                Math.max(leftAngle, rightAngle);

            // 节点向量与锁骨平面的法向量夹角
            const nodeToShoulderNormalAngle = angleBetweenVectors(
                nodeVec,
                shoulderNormalVec
            );
            // 计算两个夹角比值,用小角度比大角度,相近比值应该趋于 1
            const weight2 =
                Math.min(90, nodeToShoulderNormalAngle) /
                Math.max(90, nodeToShoulderNormalAngle);
            
          	// 使用权重最大的节点
            const weight = weight1 + weight2;
            if (weight > maxWeight) {
                maxWeight = weight;
                neckNode = node;
            }
        });

        this.neckNode = neckNode;
    }
}

检测头部

脖子往下走如果只有一个节点,那么这个节点就是头部节点了,但不排除有些模型会有些干扰节点存在,如下模型,脖子往下分化出两个节点,一个是头部节点,一个脖子挂载装饰物的节点。还是需要提取头部关键特征:

  • 头部节点与脖子一样与左右锁骨夹角应该是相近的
  • 脖子到头部节点构成向量与胸部到脖子构成的向量夹角较小

image.png
image.png
image.png


export class SkeletonPart {
    /**
     * 头部节点
     */
    public headNode: SkeletonNode | null = null;

    /**
     * 通过脖子节点网上找头部节点
     * @param handNodes
     * @returns
     */
    private initHeadNode() {
        const { neckNode, chestNode, clavicular } = this;
        if (!neckNode || !chestNode || !clavicular.left || !clavicular.right)
            return;

        // 只有一个子节点,那么子节点就是头部
        if (neckNode.children.length && neckNode.children.length === 1) {
            this.headNode = neckNode.children[0];
            return;
        }

        const { left: leftClavicular, right: rightClavicular } = clavicular;
        // 胸部 -> 左边锁骨向量
        const leftClavicularVec = createVector(
            chestNode.pos,
            leftClavicular.pos
        );
        // 胸部 -> 右边锁骨向量
        const rightClavicularVec = createVector(
            chestNode.pos,
            rightClavicular.pos
        );
        // 胸部 -> 脖子向量
        const chestToNeckVec = createVector(chestNode.pos, neckNode.pos);

        let maxWeight = 0;
        let headNode: SkeletonNode | null = null;

        neckNode.children.forEach((node) => {
            // 计算当前节点与左右锁骨的夹角
            const chestToNodeVec = createVector(chestNode.pos, node.pos);
            const leftAngle = angleBetweenVectors(
                chestToNodeVec,
                leftClavicularVec
            );
            const rightAngle = angleBetweenVectors(
                chestToNodeVec,
                rightClavicularVec
            );
            // 计算两个夹角比值,用小角度比大角度,相近比值应该趋于 1
            const weight1 =
                Math.min(leftAngle, rightAngle) /
                Math.max(leftAngle, rightAngle);

            // (脖子 -> 当前节点)的向量与(胸 -> 脖子)向量的夹角
            const neckToNodeVec = createVector(neckNode.pos, node.pos);
            const angle = angleBetweenVectors(chestToNeckVec, neckToNodeVec);
            // 夹角越小,权重越大
            const weight2 = 1 - Math.abs(angle) / 180;

            const weight = weight1 + weight2;
            if (weight > maxWeight) {
                maxWeight = weight;
                headNode = node;
            }
        });

        this.headNode = headNode;
    }
}

检测髋关节

髋关节很好找,胸部节点往父级查找,遇到的第一个分化节点且子节点数量大于 3 (左右大腿与脊柱)的节点就是宽关节。
image.png

export class SkeletonPart {
    /**
     * 髋部节点
     */
    public hipNode: SkeletonNode | null = null;

    /**
     * 找到胸部节点后,再往父级找到第一个至少具有 3 个分支子节点(左右腿和上半身)的节点即为髋部节点
     */
    private initHipNode() {
        if (!this.chestNode) return;

        const chestPaths = this.chestNode.path.split('/');

        let hipNode: SkeletonNode | null = null;
        while (chestPaths.length && !hipNode) {
            chestPaths.pop();
            const currentNode = this.nodesDict[chestPaths.join('/')];
            if (currentNode && currentNode.children.length >= 3) {
                hipNode = currentNode;
            }
        }

        this.hipNode = hipNode;
    }
}

检查大腿

一般情况,大腿都是与髋关节直接相连在一起的,排除掉通过胸部的上半身节点后,在剩余的节点中根据骨骼节点的朝向一类的特征就能区分出大腿。
image.png
image.png
一般人物模型的大腿朝向几乎是与脊柱相反的,可以根据这个特征来查找大腿节点,但是需要注意,有些特殊的模型大腿不一定是直接挂载在髋关节,如下模型就将其分成了上下身,所以查找大腿节点还不能直接从髋关节开始,需要特殊处理一下。
image.png
image.png
image.png
归纳下大腿节点几个特征:

  • 大腿需要存在兄弟节点(因为有两只腿)
  • 整个腿部关节点数量为 4 或 5
  • 大腿节点的朝向几乎与上半身相反,实现时可通过大腿第一节关节向量与髋关节到胸部向量夹角来判断。
  • 优先取叶子节点到髋关节的长度越长的节点作为脚尖
  • 优先取叶子节点层级越深的节点作为脚尖
interface LegPart {
    start: SkeletonNode;
    end: SkeletonNode;
}
export class SkeletonPart {
    /**
     * 左右大腿
     */
    public leg: {
        left: LegPart | null;
        right: LegPart | null;
    } = {
        left: null,
        right: null,
    };

    /**
     * 找髋部节点后,进一步推断腿部节点
     */
    private initLegNodes() {
        const { hipNode, chestNode } = this;
        if (!hipNode || !chestNode) return;

        const hipPaths = hipNode.path.split('/');
        hipPaths.pop();
        const rootPath = hipPaths.join('/');
        // 因为不知道大腿根节点在哪个层级,统一从根节点开始早
        const rootNode = this.nodesDict[rootPath] || hipNode;
        // 可能的大腿关节
        const maybeLegNodes: SkeletonNode[] = [];
        traverseNode(rootNode, (node) => {
            // 排除掉根节点、胸部节点及其子节点(上半身节点)
            if (
                node.path.includes(chestNode.path) ||
                node.name === rootNode.name ||
                node.name === hipNode.name
            )
                return;

            const nodePaths = node.path.split('/');
            const currentNodeDepth = nodePaths.length;
            nodePaths.pop();
            const parentNode = this.nodesDict[nodePaths.join('/')];
            // 排除掉不包含兄弟节点的节点
            if (!parentNode || parentNode.children.length < 2) return;

            const { maxLeafDepth } = getNodeInfo(node);
            const depth = maxLeafDepth - currentNodeDepth;
            // 确保节点的层级为 3 或 4
            if (![3, 4].includes(depth)) return;

            maybeLegNodes.push(node);
        });

        // 髋关节到胸部向量
        const hipToChestVec = createVector(hipNode.pos, chestNode.pos);
        // 特征项:夹角、节点长度与深度
        let angleFeatures: number[] = [];
        let distanceFeatures: number[] = [];
        let depthFeatures: number[] = [];
        const legs: Array<LegPart> = [];
        maybeLegNodes.forEach((legNode) => {
            const { leafNodes } = getNodeInfo(legNode);
            let leafAngleFeatures: number[] = [];
            let leafDistanceFeatures: number[] = [];
            let leafDepthFeatures: number[] = [];
            const legNodeDepth = legNode.path.split('/').length;
            // 计算叶子节点的向量夹角特征、关节长度特征、关节深度特征
            const nodeFeatures = leafNodes.map((leafNode) => {
                const legPathwayNodes = getPathwayNodes(legNode, leafNode);
                const legVec = createVector(legPathwayNodes[0].pos, legPathwayNodes[1].pos);
                // 计算腿部前两个节点向量与髋到胸部向量的夹角
                const angle = angleBetweenVectors(hipToChestVec, legVec) || 0;
                leafAngleFeatures.push(angle);

                let distance = 0;
                // 腿部节点的总长度
                legPathwayNodes.reduce((origin, node) => {
                    distance += calculateVecDistance(origin.pos, node.pos);
                    return node;
                });
                leafDistanceFeatures.push(distance);

                // 叶子节点的层级
                const depth = leafNode.path.split('/').length - legNodeDepth;
                leafDepthFeatures.push(depth);

                return {
                    angle,
                    distance,
                    depth,
                    start: legNode,
                    end: leafNode,
                };
            });

            // 数值归一化
            leafAngleFeatures = normalizeArray(leafAngleFeatures);
            leafDistanceFeatures = normalizeArray(leafDistanceFeatures);
            leafDepthFeatures = normalizeArray(leafDepthFeatures);
            // 取权重高的特征节点
            let maxWeight = 0;
            let maxNodeFeature = Object.create(null);
            nodeFeatures.forEach((nodeFeature, index) => {
                const weight =
                    leafAngleFeatures[index] +
                    leafDistanceFeatures[index] +
                    leafDepthFeatures[index];
                if (weight > maxWeight) {
                    maxWeight = weight;
                    maxNodeFeature = nodeFeature;
                }
            });
            angleFeatures.push(maxNodeFeature.angle);
            distanceFeatures.push(maxNodeFeature.distance);
            depthFeatures.push(maxNodeFeature.depth);
            legs.push({ start: maxNodeFeature.start, end: maxNodeFeature.end });
        });

        // 数值归一化
        distanceFeatures = normalizeArray(distanceFeatures);
        angleFeatures = normalizeArray(angleFeatures);
        // 计算各个节点权重
        const nodeWeights = legs.map((leg, index) => {
            return {
                leg,
                weight: distanceFeatures[index] + angleFeatures[index] + depthFeatures[index],
            };
        });
        // 权重排序
        nodeWeights.sort((a, b) => b.weight - a.weight);

        if (nodeWeights.length < 2) return;

        // 推断左右腿
        this.inferLegs(nodeWeights.slice(0, 2).map((it) => it.leg));
    }

    /**
     * 推断左右腿,通过腿部根节点到左右锁骨的距离远近判断
     * @param legs
     */
    private inferLegs(legs: LegPart[]) {}
}

简单概述下流程:

  • 排除掉上半身节点、不包含兄弟节点与到叶子节点层级不符合的节点
  • 在所有的候选节点中计算一下特征权重:
    • 计算所有叶子节点的到候选节点的长度
    • 计算所有叶子节点的到候选节点相对层级
    • 计算所有通往叶子节点通路第一级叶子节点的向量与髋关节到胸部向量的夹角
  • 取特征权重最高的最高的叶子节点作为候选节点的脚尖节点
  • 在所有候选节点中再次计算一次权重,按照权重逆序
  • 取权重的最高的两个节点作为双腿腿节点

这里需要注意一点,在计算节点的特征权重时使用归一化处理,将向量夹角、节点长度、节点深度都统一到了 0~1 范围,再计算他们的权重总和。如直接使用夹角、长度、与深度值,它们量纲差别巨大,夹角的对于最终权重影响会是最大的,因为其取值范围最大,所以需要做归一化处理。

归一化方法有两种形式,一种是把数变为(0,1)之间的小数,一种是把有量纲表达式变为无量纲表达式。主要是为了数据处理方便提出来的,把数据映射到0~1范围之内处理,更加便捷快速,应该归到数字信号处理范畴之内。

/**
 * 数值归一化
 * @param arr
 * @returns
 */
const normalizeArray = (arr: number[]): number[] => {
    // 计算最大值和最小值
    const maxVal = Math.max(...arr);
    const minVal = Math.min(...arr);

    if (maxVal === minVal) {
        return Array.from({ length: arr.length }).fill(1) as number[];
    }

    // 遍历数组并将每个元素归一化
    const normalizedArr = arr.map((val) => (val - minVal) / (maxVal - minVal));

    return normalizedArr;
};

const res = normalizeArray([0, 20, 50, 70, 100]);
// output [ 0, 0.2, 0.5, 0.7, 1 ]

推断左右大腿

大腿节点检查出来就需要推断左右腿了,因为已经知道了左右锁骨,左右的腿的推断也就比较简单了,只需要判断腿部根节点到左右锁骨的距离,距离越接近左边的则是左腿,反之亦然。
image.png

interface LegPart {
    start: SkeletonNode;
    end: SkeletonNode;
}
export class SkeletonPart {
    /**
     * 左右大腿
     */
    public leg: {
        left: LegPart | null;
        right: LegPart | null;
    } = {
        left: null,
        right: null,
    };

    
    /**
     * 推断左右腿,通过腿部根节点到左右锁骨的距离远近判断
     * @param legs
     */
    private inferLegs(legs: LegPart[]) {
        const { clavicular } = this;
        if (!clavicular.left || !clavicular.right) return;

        const features = legs.map((leg) => {
            const leftDistance = calculateVecDistance(leg.start.pos, clavicular.left!.pos);
            const rightDistance = calculateVecDistance(leg.start.pos, clavicular.right!.pos);
            // 计算腿到左右锁骨的距离比值,比值越大则与靠近左边
            const weight = leftDistance / rightDistance;
            return {
                weight,
                leg,
            };
        });

        features.sort((a, b) => b.weight - a.weight);

        const [left, right] = features;
        this.leg.left = left.leg;
        this.leg.right = right.leg;
    }
}

至此我们就已经完成人体主要特征骨骼节点的识别,下面就是映射。

基于骨骼关键节点实现映射

关键节点匹配

人体骨骼基本特征节点检查完成后,映射就很简单了,只需要头对头,手对手将对应关键节点匹配即可。
image.png

class SkeletonMapping {
    constructor(
        private standardPart: SkeletonPart,
        private targetPart: SkeletonPart
    ) {}

    /**
     * 骨骼关键节点映射
     * @returns
     */
    private mappingKeyNodes() {
        const { standardPart, targetPart } = this;
        const results: string[][] = [];

        // 髋部
        if (standardPart.hipNode && targetPart.hipNode) {
            results.push([standardPart.hipNode.path, targetPart.hipNode.path]);
        }

        // 胸部
        if (standardPart.chestNode && targetPart.chestNode) {
            results.push([
                standardPart.chestNode.path,
                targetPart.chestNode.path,
            ]);
        }

        // 脖子
        if (standardPart.neckNode && targetPart.neckNode) {
            results.push([
                standardPart.neckNode.path,
                targetPart.neckNode.path,
            ]);
        }

        // 头部
        if (standardPart.headNode && targetPart.headNode) {
            results.push([
                standardPart.headNode.path,
                targetPart.headNode.path,
            ]);
        }

        // 左手
        if (targetPart.hand.left && standardPart.hand.left) {
            results.push([
                standardPart.hand.left.root.path,
                targetPart.hand.left.root.path,
            ]);
        }

        // 右手
        if (targetPart.hand.right && standardPart.hand.right) {
            results.push([
                standardPart.hand.right.root.path,
                targetPart.hand.right.root.path,
            ]);
        }

        return results;
    }
}

关键路径匹配

关键节点匹配完成后,剩余就是关键节点之间的路径匹配,因为模型的骨骼标准可能不一致,会有些路径对不上的情况:
mapping.jpg
mapping.jpg
mapping.jpg
mapping.jpg
这种情况下确定一种路径分配规则即可,规则可以自行定义,例如:

  • 先将路径末端节点匹配
  • 剩余的节点从根部开始匹配

image.png

class SkeletonMapping {
    /**
     * 用于匹配起始父节点点到子节点途径的节点
     * @param options
     * @returns
     */
    private mappingParentToChildNodes(options: {
        standard: {
            start: SkeletonNode;
            end: SkeletonNode;
        };
        target: {
            start: SkeletonNode;
            end: SkeletonNode;
        };
    }) {
        const results: string[][] = [];
        const standardNodes = getPathwayNodes(
            options.standard.start,
            options.standard.end
        );
        const targetNodes = getPathwayNodes(
            options.target.start,
            options.target.end
        );

        // 末端节点单独匹配
        const standardTailNode = standardNodes.pop();
        const targetTailNode = targetNodes.pop();

        while (standardNodes.length && targetNodes.length) {
            const currentStandardNode = standardNodes.shift();
            const currentTargetNode = targetNodes.shift();
            results.push([currentStandardNode!.path, currentTargetNode!.path]);
        }

        results.push([standardTailNode!.path, targetTailNode!.path]);

        return results;
    }
}

自此,映射完成,给大家扭一个。
demo_mini.webp

其他

基于骨骼名称实现回退

由于这一套的骨骼映射核心是实现人体特征骨骼检测,而检测的关键又是双手的检测,如果有些模型手部节点缺失或者节点特征不明显时,可以考虑在双手特征检测不到的情况下考虑使用命名检测实现回退:
image.png

/**
 * 通过节点名称判断是否为左侧节点
 * @param name
 * @returns
 */
const maybeLeftNode = (name: string) => {
    const reg = /_l|l_|_left|left_/g;
    return reg.test(name);
};

/**
 * 通过节点名称判断是否为右侧节点
 * @param name
 * @returns
 */
const maybeRightNode = (name: string) => {
    const reg = /_r|r_|_right|right_/g;
    return reg.test(name);
};
export class SkeletonPart {
    /**
     * 左右手
     */
    public hand: {
        left: HandPart | null;
        right: HandPart | null;
    } = {
        left: null,
        right: null,
    };

    /**
     * 有些 bvh模型文件是不包含手部的,尝试用名称进行查找
     */
    private initHandNodesByName() {
        const handKeywords = ['hand', 'wrist', '手', '腕'];
        const maybeHandNodes = this.nodes.filter((node) => {
            const uniNodeName = uniformName(node.name);
            return handKeywords.some((keyword) =>
                uniNodeName.includes(keyword)
            );
        });

        // 按层级排序去层级较小节点
        maybeHandNodes.sort((a, b) => a.path.length - b.path.length);
        this.inferHandsByName(maybeHandNodes.slice(0, 2));
    }

    /**
     * 通过名字推断左右手方向
     */
    private inferHandsByName(handNodes: SkeletonNode[]) {
        const inferFingerNodes = (handNode: SkeletonNode) => {
            const items = handNode.children.map((fingerNode) => {
                const { leafNodes } = getNodeInfo(fingerNode);
                let maxDistance = 0;
                let fingerLeafNode: SkeletonNode | null = null;

                leafNodes.forEach((leafNode) => {
                    const fingerPathwayNodes = getPathwayNodes(
                        fingerNode,
                        leafNode
                    );
                    let distance = 0;
                    fingerPathwayNodes.reduce((origin, node) => {
                        distance += calculateVecDistance(origin.pos, node.pos);
                        return node;
                    });

                    if (distance > maxDistance) {
                        maxDistance = distance;
                        fingerLeafNode = leafNode;
                    }
                });

                return {
                    start: fingerNode,
                    end: fingerLeafNode!,
                    weight: maxDistance,
                };
            });
            // 权重排序
            items.sort((a, b) => {
                return a.weight - b.weight;
            });
            // 取长度最长的 5 个节点
            const fingerItems = items.slice(-5);
            const fingerKeys = ['thumb', 'pinky', 'ring', 'index', 'middle'];
            const finger: HandPart['finger'] = {};
            while (fingerItems.length && fingerKeys.length) {
                const fingerItem = fingerItems.pop()!;
                const fingerKey = fingerKeys.pop()!;
                Reflect.set(finger, fingerKey, [
                    fingerItem.start,
                    fingerItem.end,
                ]);
            }

            return finger;
        };

        // 通过手指末端节点到手掌的长度来判断不同手指节点
        handNodes.forEach((handNode) => {
            const paths = handNode.path.split('/');
            const matchedLeftPaths = paths.filter((p) => maybeLeftNode(p));
            const matchedRightPaths = paths.filter((p) => maybeRightNode(p));
            const direction =
                matchedRightPaths.length > matchedLeftPaths.length
                    ? 'right'
                    : 'left';
            this.hand[direction] = {
                root: handNode,
                finger: inferFingerNodes(handNode),
            };
        });
    }
}

基于名称检测,使用的是匹配关键字,例如匹配手的关键字 ['hand', 'wrist', '手', '腕'],至于左右手推断可以通过匹配方向关键字 /_l|l_|_left|left_/g or /_r|r_|_right|right_/g 出现的频率,频率越到则可能越贴近此方向。

一点技巧

在实现人体特征骨骼节点检测时,本质是在一大堆节点找出符合特征的节点,所以得特征的选取就很重要了。将特征转换成权重,需要衡量特征的量纲,比如角度的值的量纲范围是 0 ~ 360,而诸如节点的长度(距离)量纲可能只有 0 ~ 3,一般的操作都是做归一化,将量纲统一到 0 ~ 1。

机器学习

基于特征(规则)实现的映射算法免不了需要经常维护特征规则,还有一种可行思路是使用机器学习通过大量标注骨骼数据训练习得骨骼映射的规则,有机会再研究了~

参考资料