禾金晶 發表於 2021-6-11 14:11:00

【Javascript + Vue】实现对任意迷宫图片的自动寻路

<h2 id="前言">前言</h2>
<p>可以直接体验最终效果:https://maze-vite-g36nww6hh-judgeou.vercel.app/</p>
<p>寻路前:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202105/466734-20210524173428692-994179956.png"></p>
<p>寻路后,自动在图片上生成红色路径,蓝色是探索过的区域:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202105/466734-20210524173618546-1150791393.png"></p>
<p>这里我故意用手机斜着角度拍,就是为了展示程序完全可以处理手机从现实拍摄的迷宫图片。</p>
<p>整个程序我准备用 Vue 3 + Vite 来写,但其实用不用 Vue 都一样,不会涉及复杂的界面,用别的框架甚至不用框架其实也完全可以。</p>
<h2 id="二维数组一本道">二维数组,一本道</h2>
<p>说了要从零开始,所以先尝试从非常简单的迷宫入手吧</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202105/466734-20210524180851644-1425584418.png"></p>
<p>对于我们人类来说,这个迷宫十分简单,显而易见的只有一条路。但是计算机解决这样的迷宫还是得稍微花费那么一点力气的。</p>
<p>二维数组很适合用来表达这个迷宫:</p>
<pre><code class="language-js">const m = [
,
,
,

]
</code></pre>
<p>1 代表可以走的格子,0 代表不能走的格子。每个格子可以使用 来表示,比如这里起点是 ,终点是 。</p>
<p>那么应该有这么一个函数: <code>function solveMaze (matrix, begin, end) {//...}</code>,matrix是描述迷宫的二维数组,begin 是起点,end 是终点,最终返回值是一个有序集合,每一个元素是一个格子,代表正确的路线轨迹。</p>
<p>这里我们准备采用的策略十分简单,从起点开始,看看周围上下左右(不能斜着走)哪些是可以走的格子,然后移动到这个格子,接着循环上面的步骤,直到遇到 end 格子,注意还需要记录上一步的格子,把它排除,以免走回头路。具体看以下代码:</p>
<pre><code class="language-js">// maze.js

function getPoint(m, x, y) {
if (x &gt;= 0 &amp;&amp; y &gt;= 0 &amp;&amp; x &lt; m.length &amp;&amp; y &lt; m.length) {
    return m
} else {
    return 0
}
}

function getNextPoint(m, current, lastPoint) {
let = current
let nextPoint = [
    , , ,
].find(p =&gt; {
    let r1 = getPoint(m, p, p) &gt; 0
    let r2 = !isSamePoint(p, lastPoint)
    return r1 &amp;&amp; r2
})
return nextPoint
}

function isSamePoint (p1, p2) {
return p1 === p2 &amp;&amp; p1 === p2
}

function solveMaze (matrix, begin, end) {
let path = []

// 当前点
let current = begin
path.push(current)

// 上次走过的路
let lastPoint = begin

// 随便挑一个可以走的点
while (1) {
    if (isSamePoint(current, end)) {
      break
    }

    let validPoint = getNextPoint(matrix, current, lastPoint)

    path.push(validPoint)
    lastPoint = current
    current = validPoint
}
return path
}

const m = [
,
,
,

]

console.log(
solveMaze(m, , )
)
</code></pre>
<p>getNextPoint 获取可以下一步通行的格子,solveMaze 获取最终可行走的路径,控制台输出:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202105/466734-20210525175848917-1460348152.png"></p>
<p>这些坐标就是最终的行走轨迹,目测是正确的。</p>
<h2 id="映射基础界面">映射基础界面</h2>
<p>为了方便测试观察,得把我们的数据结构映射到网页上面。</p>
<pre><code class="language-js">// maze.js
// ...

// 导出 solveMaze 函数,让vue组件调用
export {
solveMaze
}
</code></pre>
<pre><code class="language-html">&lt;template&gt;
&lt;div&gt;
    &lt;div class="div-matrix"&gt;
      &lt;div v-for="(row, x) in matrix" :key="x"&gt;
      &lt;div class="cell"
          :class="{ black: p &lt;= 0, path: isPath(x, y) }"
          v-for="(p, y) in row" :key="y" :style="{ left: `${y * 2.5}em`, top: `${x * 2.5}em` }"&gt;
          {{ begin === x &amp;&amp; begin === y ? 'B' : '' }}
          {{ end === x &amp;&amp; end === y ? 'E' : '' }}
      &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
import { solveMaze } from './maze'

export default {
data () {
    return {
      begin: ,
      end: ,
      matrix: [],
      paths: []
    }
},
methods: {
    isPath (x, y) {
      const p = this.paths.find(path =&gt; path === x &amp;&amp; path === y)
      return p
    }
},
created () {
    const m = this.matrix = [
      ,
      ,
      ,
      
    ]
    this.paths = solveMaze(m, this.begin, this.end)
}
}
&lt;/script&gt;

&lt;style&gt;
.top {
margin-bottom: 1em;
}
.div-matrix {
position: relative;
}
.cell {
border: 1px black solid;
width: 2em;
height: 2em;
position:absolute;
text-align: center;
}
.cell.path {
border: 1px red solid;
}
.black {
background: black;
}
&lt;/style&gt;
</code></pre>
<p>最终效果:<br>
https://codesandbox.io/embed/brave-jones-y1c29?fontsize=14&amp;hidenavigation=1&amp;theme=dark</p>
<p>其实就是通过二维数组 matrix 生成对应 div,数组里面元素值决定黑白。paths 数组存储结果路径,把路径用红色边框标记出来。这样就方便以后测试结果的查看了。</p>
<h2 id="广度优先地毯式搜索">广度优先,地毯式搜索</h2>
<p>看看下面这个迷宫:</p>
<pre><code class="language-js">const m = this.matrix = [
,
,
,

]
</code></pre>
<p>如果把他应用到上面的代码,会发现页面卡死了,这是因为这个迷宫含有岔路,导致算法一直在绕圈。我们需要一些手段,把走过的路记住,以后就不再走了,方法不难,只要把当前可以走的格子,放进一个队列中,然后要做的,就是不断对这个队列里的格子,作出同样处理,直到遇到终点格子。</p>
<p>为了方便我们用算法进行处理,需要使用新的数据结构来表达,它就是 <strong>图(Graph)</strong> 结构。</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202105/466734-20210531174842057-132483244.png"></p>
<p>图结构和链表很像,最大不同是每个节点可以有多个指针指向不同的节点。</p>
<p>同时把二维数组给他降维打击,变成一维:</p>
<pre><code class="language-js">const m = [
1, 1, 0, 0, 0,
1, 1, 1, 1, 1,
0, 1, 0, 1, 0,
0, 1, 0, 1, 1
]
</code></pre>
<p>虽然这样访问起来不那么直接,但是只需要一次寻址,复制传输也比二维数组方便得多。</p>
<p>然后创建一个 Node 类代表节点,NodeGraph 类代表整个图结构。</p>
<pre><code class="language-js">class Node {
constructor (x, y, value) {
    this.x = x
    this.y = y
    this.value = value
    this.checked = false
    this.nearNodes = []
}
}

class NodeGraph {
constructor (matrix, width, height) {
    this.nodes = []
    this.matrix = matrix
    this.width = width
    this.height = height
}

buildNodeGraph () {
    let { width, height } = this
   
    for (let y = 0; y &lt; height; y++) {
      for (let x = 0; x &lt; width; x++) {
      let node = this.getNode(x, y)

      let up = this.getNode(x, y - 1)
      let down = this.getNode(x, y + 1)
      let left = this.getNode(x - 1, y)
      let right = this.getNode(x + 1, y)
      node.nearNodes = [ up, down, left, right].filter(node =&gt; node &amp;&amp; node.value === 1)
      }
    }
}

getNode (x, y) {
    let { nodes, width, matrix } = this
    if (x &gt;= 0 &amp;&amp; y &gt;= 0) {
      let node = nodes
      if (!node) {
      let value = matrix
      if (value !== undefined) {
          node = new Node(x, y, value)
          nodes = node
      }
      }
      return node
    } else {
      return null
    }
}
}
</code></pre>
<p>buildNodeGraph 把 matrix 数组转换为图结构,每个节点的 nearNodes 就相当于是下一步可以走的格子集合,checked 表示这个节点是否已经探索过。</p>
<p>为了方便查看每一步状态的变化,需要把当前所在格子和队列中的格子,在画面上标记出来,完整代码我就不贴了,codesandbox 可以随意看:</p>
<p>https://codesandbox.io/embed/maze-vite-2-bofki?fontsize=14&amp;hidenavigation=1&amp;theme=dark</p>
<p>蓝色边框就是队列中的格子,小人就是当前所在的格子,当它走到右下角时就会停下来。</p>
<p>目前做的,只是顺利让小人移动到终点而已,但是怎么得出我们要的路径呢?方法就是在 Node 多加一个属性 parent,记录上一个 Node。当取出 nearNodes 时,把当前节点赋值到每一个 nearNode 的 parent 即可:</p>
<pre><code class="language-js">// ...
for (let node of current.nearNodes) {
if (node.checked === false) {
        node.parent = current
        queue.push(node)
}
}
// ...
</code></pre>
<p>然后就是从当前节点,读取 parent 一个一个回溯即可:</p>
<pre><code class="language-js">function buildPath (endNode) {
let path = []
let node = endNode

while (node) {
    path.push(node)
    node = node.parent
}

return path
}
</code></pre>
<p>效果:</p>
<p>https://codesandbox.io/s/maze-vite-3-fqn5f</p>
<p>当小人到达终点时,红色方格就是最短路径了。</p>
<h2 id="地图编辑">地图编辑</h2>
<p>稍微改动下代码,让我们可以实时编辑迷宫,测试更加方便。</p>
<p>操作:点击方格可以改变黑白状态,按住alt点击可以设置新的目标点。</p>
<p>https://codesandbox.io/s/maze-vite-4-yiq0r</p>
<p>逐渐把 solveMaze 里面的局部变量移到 NodeGraph 类里面,这样外部访问更加便利。注意当寻路结束的时候,不要结束函数,而是使用 while 循环一直等待新的目标点被设置:</p>
<pre><code class="language-js">// ...

    if (equalsNode(current, nodeGraph.endNode)) {
      while (equalsNode(current, nodeGraph.endNode)) {
      await sleep(1000)
      }
      continue
    }
// ...
</code></pre>
<h2 id="优化寻路算法">优化寻路算法</h2>
<p>想象一下这种情形:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210602111018014-1843948442.png"></p>
<p>起点和终点一条直线,中间毫无阻挡,但是这个小人竟然到处跑,好一会才到终点,这样下去不行的,必须要优化。</p>
<p>在 Node 类加一个属性 endDistance 记录每个节点到终点的距离,getDistance 函数根据坐标可以直接计算出距离,这个距离是没有考虑到中间可能出现的障碍的,但对路线的评估也十分有用:</p>
<pre><code class="language-js">class Node {
constructor (x, y, value) {
    this.x = x
    this.y = y
    this.value = value
    this.endDistance = 0 // 与终点的距离,忽略中间的障碍
    this.checked = false
    this.parent = null
    this.nearNodes = []
}
}

function getDistance (nodeA, nodeB) {
const x = Math.abs(nodeB.x - nodeA.x)
const y = Math.abs(nodeB.y - nodeA.y)
return (x + y)
}
</code></pre>
<p>每次通过 popBestNextNode 方法从队列取出 endDistance 最小的 Node:</p>
<pre><code class="language-js">class NodeGraph {
// ...

popBestNextNode () {
    let { queue } = this
    let bestNode = queue
    let bestNodeIndex = 0
    let { length } = queue

    for (let i = 0; i &lt; length; i++) {
      let node = queue
      if (node.endDistance &lt; bestNode.endDistance) {
      bestNode = node
      bestNodeIndex = i
      }
    }

    queue.splice(bestNodeIndex, 1)
    return bestNode
}
// ...
}
</code></pre>
<p>既然有 endDistance,那也要有 beginDistance,用来记录从起点走过来的步数。这个数值不直接从坐标计算,而是随着前进累加,这样 beginDistance 就不是估算值了,而是实际值:</p>
<pre><code class="language-js">// ...
for (let node of current.nearNodes) {
if (node.checked === false &amp;&amp; node.value) {
        node.parent = current
        node.checked = true
        node.endDistance = getDistance(node, nodeGraph.endNode)
        node.beginDistance = current.beginDistance + 1
        node.cost = node.endDistance + node.beginDistance
        nodeGraph.queue.push(node)
}
}
// ...
</code></pre>
<p>考虑到以后可能会有新的因素加入,所以直接添加一个 cost 属性,用来表达 <strong>成本</strong>。目前 cost 就是 endDistance + beginDistance,cost 越小,优先级越高。</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210603115642999-1690986881.png"></p>
<p>像这样的情况,小人一开始企图从上方靠近,但是随着不断前进,经过的步数越来越多,倒不如走下面了,于是就放弃的上面的路线。</p>
<p>现在,小人的行动变成更加靠谱了:</p>
<p>https://codesandbox.io/embed/maze-vite-5-n5p6p?fontsize=14&amp;hidenavigation=1&amp;theme=dark</p>
<h2 id="对图片进行寻路">对图片进行寻路</h2>
<p>拿这张我随便画的图来作为参数:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210604173528210-516083079.png"></p>
<p>目标是从 B 点到达 E 点,我们只需要读取这张图片的像素,根据黑白颜色,转换成为一个数组,放到 solveMaze 函数即可。</p>
<p>为此,需要有一个 input 标签,type="file",用来选择图片,通过 URL.createObjectURL(File) 生成一个 URL,然后使用 new Image() 创建一个 img 标签,它不需要添加进 body,把 url 给到 img.src。通过 CanvasRenderingContext2D.drawImage() 复制进 Canvas,再调用 CanvasRenderingContext2D.getImageData() 即可获取像素数组。</p>
<p>这时不能再用 div 去渲染了,因为这张图几万个像素,每个像素一个 div 的话,浏览器也吃不消,再加上将来图片将会更大。</p>
<p>所以这里改用 Canvas 进行渲染,安排三个 Canvas,一个显示迷宫的原图,一个显示探索过的节点,一个显示当前最短路径,也就是 path 数组里面的节点,然后把这三个 Canvas 叠在一起即可,最后就是在回调里面去更新后面两个 Canvas 即可。</p>
<p>把我上面的图片下载到自己电脑,点击选择文件按钮选择图片,然后就能看到效果了,选别的图片也可以,只是我的起点和终点坐标是写死的,而且代码没有优化过,太大的图片恐怕难以处理。</p>
<p>https://codesandbox.io/embed/maze-vite-6-933b3?fontsize=14&amp;hidenavigation=1&amp;module=%2Fsrc%2FApp.vue&amp;theme=dark&amp;view=preview</p>
<p><strong>注意:</strong>如果遇到跨域问题那就要自己上传图片了。</p>
<h2 id="自定义起始点以及随时变更路线">自定义起始点,以及随时变更路线</h2>
<p>利用点击事件中的 offsetX、offsetY 即可知道点击坐标,把起点和终点的坐标保存起来,一旦有变化,则停止之前的寻路函数,重新执行当前的寻路函数,因此需要在循环中作一个判断来决定是否退出函数,这件事情适合在回调里面做。</p>
<p>然后提供一个输入框,自由调整经历多少个循环才更新一次画面,这样能调整寻路的速度。</p>
<p>https://codesandbox.io/embed/maze-vite-7-bzrrd?fontsize=14&amp;hidenavigation=1&amp;theme=dark&amp;view=preview</p>
<h2 id="处理彩色图片">处理彩色图片</h2>
<p>预览:https://codesandbox.io/s/maze-vite-8-h845p?file=/src/App.vue (注意如果出现跨域错误,就自己上传图片吧)</p>
<p>不放iframe了,感觉放太多了,让这个页面已经相当卡。</p>
<p>前面都是默认白色像素是可以行走的区域,现在尝试把颜色相似的附近像素作为行走区域,这样效果会更加好。</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210610150334768-1494382948.png"></p>
<p>直接把 rgba 颜色数据传入 solveMaze,然后在循环中计算出相邻节点与当前节点的颜色差距,差距过大则不放入队列。</p>
<p>把一个 rgba 整数的各个通道拆分开来可以这么写:</p>
<pre><code class="language-js">/**
* 获取 Node 的 RGB 值
* @param {Node} node
* @returns
*/
function getNodeRGB (node) {
let { value } = node
let r = value &amp; 0xFF
let g = value &gt;&gt; 8 &amp; 0xFF
let b = value &gt;&gt; 16 &amp; 0xFF
return [ r, g, b ]
}
</code></pre>
<p>求 rgb 颜色的相似度,最朴素的方法是把两个颜色看成是两个三维坐标的点,然后求其欧几里得距离,但这不符合人眼的感知模型。详细方法wiki已经有了:https://zh.wikipedia.org/wiki/颜色差异</p>
<p>关于这方面的运算有点复杂,我都写到了 color.js 里面了。</p>
<p>结果:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210610153851612-1653594551.png"></p>
<p>注意代码里的 colorDiffThreshold,目前我用的 2.25,如果太高,会导致穿墙,太低则容易找不到路失败。</p>
<h2 id="性能优化">性能优化</h2>
<p>当点击两次画面后,需要稍微等一会才会开始寻路,这里应该耗费了不少CPU。打开 DevTools 的性能分析器分析下到底是哪里消耗最多性能:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210610173335181-934354226.png"></p>
<p>很明显 solveMaze 函数占据了大多数时间,展开下面的 Call Tree:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210610173743457-742255791.png"></p>
<p>buildNodeGraph 和 getNode 是优化重点,打开代码,可以直接看到每句话耗费的CPU时间:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210610173913027-1417919161.png"></p>
<p>57 行的 <code>if (!node) {...}</code> 明明是简单的判断,却耗费了不少时间,试试看改成 <code>node === undefined</code>,并且 value 也不再需要判断了。对 nodes 数组的访问与读取也耗费不少时间,尝试一开始用 <code>new Array(length)</code> 初始化,应该会好一些。优化之后的代码:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210611093115976-1977469749.png"></p>
<p>看起来稍微好一些。</p>
<p>在寻路途中进行性能监测:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210611093908583-257849857.png"></p>
<p>发现 buildPath 相当的耗费时间,这也是理所应当的,毕竟每次循环都调用,并且完整遍历整个链条。处理办法也简单,只在最后出结果时调用他即可,根据前面的经验,while (node) 改为 while (node !== null)。</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210611100950879-1159362136.png"></p>
<p>现在他完全没有存在感了。</p>
<p>然后是 for...of 语句,建议改为传统的数组下标自减,这是最快的,当然日常使用 for...of 可读性更强。</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210611102018806-1765386445.png"></p>
<p>然后是 popBestNextNode:</p>
<p><img src="https://img2020.cnblogs.com/blog/466734/202106/466734-20210611102109051-1064416049.png"></p>
<p>这里每次都完整遍历整个数组找出cost最小的节点,最后还有一个数组元素移除的操作。我真的很难判断 JavaScript 的数组到底是存储在连续内存里面还是分散的,但是不管怎么样,这里完全可以使用二叉堆替代来获取更好的性能。具体就不自己实现了,直接用现成的:https://www.npmjs.com/package/heap-js。注意 new Heap() 的时候传入自定义的比较器,然后把 popBestNextNode 的实现改为 return this.queue.pop() 即可。</p>
<p>最后,把 buildNodeGraph 的那两层for循环全部移除,不再预先初始化所有的 Node,给 NodeGraph 添加一个新方法:</p>
<pre><code class="language-js">getNearNodes (node) {
    let { x, y } = node
    let up = this.getNode(x, y - 1)
    let down = this.getNode(x, y + 1)
    let left = this.getNode(x - 1, y)
    let right = this.getNode(x + 1, y)
    return [ up, down, left, right ].filter(node =&gt; node !== null)
}
</code></pre>
<p>在后面的寻路循环中再去调用 getNearNodes 来获取相邻的节点,这样就大幅缩减了一开始的卡顿了。</p>
<p>最后,提供两种策略:</p>
<ul>
<li>严格:当相邻像素颜色差距小于某个值,才加入队列。适合行走区域的颜色变化不大的图片,得出的结果几乎就是最短的路径。</li>
<li>模糊:相邻像素无论颜色的差距,都加入队列,其差距值乘以某些系数,作为节点的 cost。适合任何图片,最终总是能找到一条路。。。</li>
</ul>
<pre><code class="language-js">let nearNodes = nodeGraph.getNearNodes(current)
for (let i = nearNodes.length - 1; i &gt;= 0; i--) {
let node = nearNodes
if (node.checked === false) {
        node.checked = true

        let colordiff = getNodeColorDiff(node, current)
        const colorDiffThreshold = 2 // 容许通过的颜色差异,范围 0~100

        node.parent = current
        node.endDistance = getDistance(node, nodeGraph.endNode)
        node.beginDistance = current.beginDistance + 1

        if (mode === "1") { // 严格
          node.cost = node.endDistance + node.beginDistance

          if (colordiff &lt; colorDiffThreshold) {
                nodeGraph.queue.push(node)
          }
        } else if (mode === "2") { // 模糊
          node.cost = node.endDistance + node.beginDistance + (colordiff * width * height)
          nodeGraph.queue.push(node)
        }
}
}
</code></pre>
<p>源代码:https://gitee.com/judgeou/maze-vite</p><br><br>
来源:https://www.cnblogs.com/judgeou/p/14805429.html
頁: [1]
查看完整版本: 【Javascript + Vue】实现对任意迷宫图片的自动寻路