阅读 447

JS实现八叉树octree(js二叉树的遍历算法)

1.什么是八叉树

最近几年无人驾驶闹的比较火热,其中高精地图是无人驾驶走向成功必须具备的基础。通过雷达采集或者经过算法实现的三维重建都会生成PointCloud点云数据,除了使用CloudCompare或者MeshLib等工具可视化点云数据,各个公司也在考虑通过前端来可视化点云数据,目前比较主流的点云可视化开源框架有potree和plas.io,一份点云数据包含几千万上亿点集,一方面需要考虑切割,另一方面需要考虑建立索引。potree以及plas都采用八叉树来建立索引。

八叉树是一种数据结构,其中每个内部节点有8个子节点。八叉树常用表示3维空间中对象之间的关系,通常用于表示三维图像。可实现在O(LogN)时间复杂度下的最近邻接点搜索。二叉树将一维空间分割成两个区域,八叉树将三维空间分割成8个格子,每个格子用一个节点表示。八叉树在存储三维数据时能节省大量空间,特别是空间分布较稀疏的情况下。如果在一个维度为N的三维空间下有k个点,则该树的使用空间为O(kLogN)。

octree.png

八叉树包含Point Node、Empty Node、Region Node共3种节点,Point Node代表一个点云,类型为叶子结点;Empty Node表示该区域没有点云存在,其类型为叶子节点;Region Node作为八叉树的内部节点,表示一个区域,并且一定包含8个子节点。

2.JS实现八叉树

2.1 数据存储

考虑如何存储八叉树,首先定义了Octree构造函数存储八叉树的根节点,构造函数包含三个参数,accuracy表示精度,在空间比较时会考虑精度,例如精度为0.005,在做比较时每个方向都会附加上精度(例如x方向[minX - 0.005, maxX + 0.005);size表示八叉树的空间大小;root为根节点。

/**
 * 八叉树构造函数
 * @param {*} position 初始位置,立方体的(left, bottom, far)位置作为起始坐标
 * @param {*} size 八叉树空间大小
 * @param {*} accuracy 精度,判断点是否相等的精度
 */
 function Octree(position, size, accuracy) {
    this.accuracy = accuracy || 0;
    this.size = size;
    this.root = new Cell(this, position, size, 0);
}复制代码

根节点类型为Cell,Cell为存储节点的数据结构,level为当前节点所在层次,根节点层次为0;points存储点云坐标,data存储点云附加的数据;children为子节点列表,要么为空,要么长度为8。

/**
 * 八叉树节点构造函数
 * @param {*} tree octree容器
 * @param {*} position 起始位置
 * @param {*} size 大小
 * @param {*} level 深度
 */
 function Cell(tree, position, size, level) {
    this.tree = tree;
    this.position = position;
    this.size = size;
    this.level = level;
    this.points = [];
    this.data = [];
    this.children = [];
}复制代码

假如现在我们要增加一个点云到八叉树,需要对Octree扩展add函数,该函数会调用根节点root的add函数。

Octree.prototype.add = function (p, data) {
    this.root.add(p, data);
}复制代码

我们接着在Cell上扩展add函数,首先将点云存储到points列表,并将对应的数据存储到data数组中。如果children不为空,则将点云和数据添加到子节点中,当第一次添加点云,例如new Vec3(0.2, 0, 0),由于points只包含一个点云,if和else中的逻辑都不会命中,假如再添加第二个点new Vec3(0.5, 0, 0),此时points.length > 1,将执行split函数进行切割。

/**
 * 添加点云
 * @param {*} p 点云
 * @param {*} data 
 */
 Cell.prototype.add = function (p, data) {
    this.points.push(p);
    this.data.push(data);
    if (this.children.length > 0) {
      this.addToChildren(p, data);
    } else {
      if (this.points.length > 1 && this.level < Octree.MaxLevel) {
        this.split();
      }
    }
}复制代码

接下来看如果定义split函数,该函数为八叉树节点最重要的函数,通过八叉树的定义我们知道一个Region Node有且仅有8个子节点,假如父节点的坐标为(x, y, z),父节点尺寸为size,每个方向求得半尺寸w2(x方向)、h2(y方向)、d2(z方向),切割的所有子节点深度变为level + 1。例如父节点坐标为[0, 0, 0],size为[4, 4, 4],第一个子节点坐标为[0, 0, 0], 第二个子节点坐标为[2, 0, 0],....,并且所有节点尺寸减半变为[2, 2, 2]。

/**
  * 将当前节点拆分为八个子节点
  */
 Cell.prototype.split = function () {
    var x = this.position.x;
    var y = this.position.y;
    var z = this.position.z;
    // 求得每个方向的尺寸一半
    var w2 = this.size.x / 2;
    var h2 = this.size.y / 2;
    var d2 = this.size.z / 2;
    
    // x,y,z三个方向拆分,2 * 2 * 2

    // 例如父节点坐标[0, 0, 0]、size为[4, 4, 4]
    // 坐标变为[0, 0, 0], size为[2, 2, 2]
    this.children.push(new Cell(this.tree, Vec3.create(x, y, z), Vec3.create(w2, h2, d2), this.level + 1));
    // 坐标变为[2, 0, 0]
    this.children.push(new Cell(this.tree, Vec3.create(x + w2, y, z), Vec3.create(w2, h2, d2), this.level + 1));
    // 坐标变为[0, 0, 2]
    this.children.push(new Cell(this.tree, Vec3.create(x, y, z + d2), Vec3.create(w2, h2, d2), this.level + 1));
    // 坐标变为[2, 0, 2]
    this.children.push(new Cell(this.tree, Vec3.create(x + w2, y, z + d2), Vec3.create(w2, h2, d2), this.level + 1));
    // 坐标变为[0, 2, 0]
    this.children.push(new Cell(this.tree, Vec3.create(x, y + h2, z), Vec3.create(w2, h2, d2), this.level + 1));
    // 坐标变为[2, 2, 0]
    this.children.push(new Cell(this.tree, Vec3.create(x + w2, y + h2, z), Vec3.create(w2, h2, d2), this.level + 1));
    // 坐标变为[0, 2, 2]
    this.children.push(new Cell(this.tree, Vec3.create(x, y + h2, z + d2), Vec3.create(w2, h2, d2), this.level + 1));
    // 坐标变为[2, 2, 2]
    this.children.push(new Cell(this.tree, Vec3.create(x + w2, y + h2, z + d2), Vec3.create(w2, h2, d2), this.level + 1));
    // 将保存的点移动到下一层子节点存储
    for (var i = 0; i < this.points.length; i++) {
      this.addToChildren(this.points[i], this.data[i]);
    }
}复制代码

切割完成后,遍历points中所有点云,并调用addToChildren函数将其移动到对应位置的子节点中。addToChildren函数遍历所有子节点,并通过contains函数判断点云p是否包含在子节点空间范围内,满足条件则将其添加到子节点中。

/**
 * 将点云移动到下一层子节点存储
 * @param {*} p 点云坐标
 * @param {*} data 点云数据
 */
 Cell.prototype.addToChildren = function (p, data) {
    // 遍历8个子节点
    for (var i = 0; i < this.children.length; i++) {
      // 如果点云坐标包含在节点空间内,则添加到对应子节点
      if (this.children[i].contains(p)) {
        this.children[i].add(p, data);
        break;
      }
    }
}复制代码

contains函数定义如下,该函数的判断逻辑比较简单,直接和三个维度的范围进行比较,需要注意的是,在进行比较时需要附加上精度accuracy。假如第一个点云为new Vec3(0.2, 0, 0),满足第一个子节点(坐标为(0,0,0), 尺寸为(2, 2, 2))。再添加第二个点云new Vec3(0.5, 0, 0),还是命中第一个子节点,但此时第一个子节点的points长度大于1,需要递归对该节点进行拆分,其拆分后的子节点深度level变为2.

/**
 * 判断点云是否包含在当前节点空间内
 * @param {*} p 点云坐标
 * @returns 
 */
 Cell.prototype.contains = function (p) {
    // 比较时附加上精度buffer,假如精度为0.005,则x轴比较范围[minX - 0.005, maxX + 0.005]
    return p.x >= this.position.x - this.tree.accuracy
        && p.y >= this.position.y - this.tree.accuracy
        && p.z >= this.position.z - this.tree.accuracy
        && p.x < this.position.x + this.size.x + this.tree.accuracy
        && p.y < this.position.y + this.size.y + this.tree.accuracy
        && p.z < this.position.z + this.size.z + this.tree.accuracy;
  }复制代码

结合add、split、addToChildren、contains函数,可以实现点云数据的递归添加,并将点云数据添加到正确的子节点中。

2.2 数据检索

2.2.1 找最近的点

实现了八叉树的存储结构,接下来考虑如何对存储的数据实现快速检索。假如现在有点云p1(0.3, 0, 0),想找出octree中离p1最近的点,该如何实现?我们在Octree实体上定义findNearestPoint函数,专门用于检索最近的点,函数第二个参数包含三个可选项,includeData是否包含数据、maxDist最大判断距离、notSelf排除自身(距离为0的情况)。函数内部将调用节点Cell实体的findNeareastPoint函数来获取最近的点。

/**
 * 检索最近的点云,options包含部分附加条件
 * @param {*} p 匹配的点云
 * @param {includeData: 是否包含数据, maxDist: 最大距离, notSelf: 不包含自身 } options 可选参数
 * @returns 
 */
 Octree.prototype.findNearestPoint = function (p, options) {
    options.includeData = options.includeData ? options.includeData : false;
    options.bestDist = options.maxDist ? options.maxDist : Infinity;
    options.notSelf = options.notSelf ? options.notSelf : false;
    // 调用根节点的findNearestPoint进行递归遍历
    var result = this.root.findNearestPoint(p, options);
    if (result) {
      if (options.includeData) return result;
      else return result.point;
    }
    else return null;
};复制代码

以下代码为Cell实体定义的获取最近点函数,先判断当前节点有没有数据(points.length>0),如果当且节点有数据并且无子节点,那么直接判断当前节点内部的点距离即可,需要注意的是,内部的点有可能最后一个才是最优值,所有必须遍历完所有的数据。

如果有子节点,则应该从子节点中找最近距离,因为在存储阶段我们知道所有的数据都会存储到子节点中。函数在28行到30行对children做了排序,如果节点空间中心点离被检索的p点越近越排在前面,优先算距离,避免不必要的计算。

接下来遍历所有children,38行到43行判断p是否在节点空间范围内,不在则直接跳过,判断下一个节点。否则,递归调用子节点的findNearestPoint继续查找最优点。通过遍历和递归的策略,找出最终符合条件的最优点。

/**
 * 检索离点最近的节点
 * @param {*} p 被检索的点
 * @param {*} options {includeData: 是否包含数据, bestDist: 最大距离, notSelf: 不包含自身 } options 可选参数
 * @returns 
 */
 Cell.prototype.findNearestPoint = function (p, options) {
    var nearest = null;
    var nearestData = null;
    var bestDist = options.bestDist;
    // 当前节点包含点并且没有子节点,则直接判断当且节点下的point和目标point距离是否满足条件
    if (this.points.length > 0 && this.children.length == 0) {
      // 遍历所有的points找到最近的点
      for (var i = 0; i < this.points.length; i++) {
        var dist = this.points[i].distance(p);
        if (dist <= bestDist) {
          // 如果dist为0,为自身,需排除
          if (dist == 0 && options.notSelf)
            continue;
          bestDist = dist;
          nearest = this.points[i];
          nearestData = this.data[i];
        }
      }
    }
  
    // 每个children按中心点到目标point距离从近到远排序,优先考虑近的
    var children = this.children.map(function(child) { return { child: child, dist: child.squareDistanceToCenter(p) } })
    .sort(function(a, b) { return a.dist - b.dist; })
    .map(function(c) { return c.child; });

  if (children.length > 0) {
    // eslint-disable-next-line no-redeclare
    for (var i = 0; i < children.length; i++) {
      var child = children[i];
      if (child.points.length > 0) {
        // 判断目标点在不在节点空间范围内,不在则直接跳过
        if (p.x < child.position.x - bestDist || p.x > child.position.x + child.size.x + bestDist ||
            p.y < child.position.y - bestDist || p.y > child.position.y + child.size.y + bestDist ||
            p.z < child.position.z - bestDist || p.z > child.position.z + child.size.z + bestDist
          ) {
          continue;
        }
        // 递归遍历所有子节点
        var childNearest = child.findNearestPoint(p, options);
        if (!childNearest || !childNearest.point) {
          continue;
        }
        var childNearestDist = childNearest.point.distance(p);
        // 如果子节点获取的最近点优于当前节点距离,则采用子节点结果
        if (childNearestDist < bestDist) {
          nearest = childNearest.point;
          bestDist = childNearestDist;
          nearestData = childNearest.data;
        }
      }
    }
  }
  return {
    point: nearest,
    data: nearestData
  }
};复制代码

2.2.2 找附近的点

除了找最近的点,某些场景需要找出附近的点集合,例如按半径r查找范围内的点集合。在Octree原型链上定义findNearByPoints函数,函数内部会从根节点root开始查找满足范围r内的点集合。

/**
 * 查找范围内的点集合
 * @param {} p 被检索的点
 * @param {} r 检索半径
 * @param { includeData: 是否包含数据, maxDist: 最大距离, notSelf: 不包含自身 } options 可选参数
 * @returns 
 */
 Octree.prototype.findNearbyPoints = function (p, r, options) {
    options = options || { };
    var result = { points: [], data: [] };
    this.root.findNearbyPoints(p, r, result, options);
    return result;
};复制代码

节点中查找附近的点的实现和findNearestPoint类似,如果当且节点无子节点,则直接遍历其下的points列表,找出和p距离满足r范围内的点集合。否则,第28到33行结合r、p两个参数判断整个节点空间范围是否满足条件,实现初步筛选。筛选通过的再调用子节点的findNearbyPoints实现递归查找,最终满足条件的point都会存储到result。

/**
 * 查找节点范围内的点集合
 * @param {*} p 被检索的点
 * @param {*} r 检索半径
 * @param {*} result 检索结果
 * @param {*} options { includeData: 是否包含数据, maxDist: 最大距离, notSelf: 不包含自身 } options 可选参数
 */
 Octree.Cell.prototype.findNearbyPoints = function (p, r, result, options) {
    if (this.points.length > 0 && this.children.length == 0) {
      for (var i = 0; i < this.points.length; i++) {
        var dist = this.points[i].distance(p);
        if (dist <= r) {
          if (dist == 0 && options.notSelf)
            continue;
          result.points.push(this.points[i]);
          if (options.includeData) result.data.push(this.data[i]);
        }
      }
    }
   
    var children = this.children;
  
    if (children.length > 0) {
      // eslint-disable-next-line no-redeclare
      for (var i = 0; i < children.length; i++) {
        var child = children[i];
        if (child.points.length > 0) {
          if (p.x < child.position.x - r || p.x > child.position.x + child.size.x + r ||
              p.y < child.position.y - r || p.y > child.position.y + child.size.y + r ||
              p.z < child.position.z - r || p.z > child.position.z + child.size.z + r
            ) {
            continue;
          }
          child.findNearbyPoints(p, r, result, options);
        }
      }
    }
  };复制代码

3.优缺点

octree一个立方体等分为八份,并可以持续的分下去,虽然每个节点都要分为八个子立方体,但等分空间,相对于k-d树的图示,容易理解,上手比较快。

八叉树的算法实现比较简单,但大数据量点云的情况下,其使用比较困难的是最小粒度(叶节点)的确认,粒度交大时,有的节点数据量可能比较大,后续查找效率比较低,反之,八叉树的深度增加,需要的内存就比较大,每个非叶子节点需要八个指针,效率也比较低。而等分的划分依据,使得在数据重心有偏移的情况下,手划分深度限制,其效率也不太高。

4.应用

在三维可视化应用场景,可以使用octree存储图形顶点,并且可快速对其进行检索,如potree、plas.io都是使用octree实现存储和检索功能,另外,很多三维场景也借助octree来实现Collision detection,能快速检测出两个三维Object在移动过程中是否发生碰撞。

对于Collision detection, 可以通过octree的root节点一层层往下判断,直到检测到有叶子结点和目标物体有相交,则认为两Object发生了碰撞。


作者:前端下饭菜
链接:https://juejin.cn/post/7040736028109307935


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐