Gridfinder Implementation
Published: 2020-12-05 01:33:23

<a href="#background">Background</a></h2>

<p>When playing <a href="https://en.wikipedia.org/wiki/Role-playing_game#Tabletop">Role Playing Games</a> (like <a href="https://dnd.wizards.com/">Dungeons &amp; Dragons</a>) on a Virtual Tabletop (like <a href="https://roll20.net/">Roll20</a>). You often want to configure a grid for the map. This allows measuring distances and allowing players to snap to the grid. It can also make drawing virtual walls and doors easier.</p>

<p>Furthermore I often like to use premade maps such as those posted to <a href="https://battlemaps.reddit.com">https://battlemaps.reddit.com</a>. While it can be difficult to find a premade map that fits your story perfectly I find that adjusting my story slightly to fit the map is worth the benefit of an excellent map that looks great and has a lot of detail that I wouldn’t think to include if I made it myself.</p>

<p>However a common issue with premade maps is that you don’t know what grid size will best fit. So using gridfinder you can quickly identify the dimensions. For example <a href="https://gridfinder.kevincox.ca/?i=https%3A%2F%2Fi.redd.it%2Fm6l4go8l56v51.jpg">click here to try it out with this smithy map</a> by <a href="https://www.patreon.com/morvoldpress">Morvold Press</a>.   as an example. This example <a href="https://www.reddit.com/r/battlemaps/comments/jhnfrp/lmop_phandalin_building_thagrens_smithy_35_x_24/">does have the dimensions listed</a>, but you can imagine how the tool is helpful if they weren’t.</p>

<h2 id="the-tool"><a href="#the-tool">The Tool</a></h2>

<p>The tool is <a href="https://gridfinder.kevincox.ca">Gridfinder</a> and is very simple. You just add your map (enter URL, select and image file, drag &amp; drop and copy+paste all work) then click a couple of “intersection” points. I have <a href="https://youtu.be/omnuXWuW6Ls">a video tutorial</a> you can watch to see how it works.</p>

<p>The tool is <a href="https://gitlab.com/kevincox/gridfinder">completely open source</a>, both the website and a JavaScript package to use the algorithm. Let me know if you integrate it into any projects!</p>

<p>The rest of the article discusses the algorithm in use. The tool could definitely use some UX improvements but I’m not a UI developer and am unlikely to share great wisdom on creating UIs. (UX patches welcome though 😉)</p>

<h2 id="the-algorithm"><a href="#the-algorithm">The Algorithm</a></h2>

<p>The full algorithm as of the time of writing is <a href="https://gitlab.com/kevincox/gridfinder/-/blob/142e498b2d979f591745d351e9f4760e9c32865e/lib/lib.js">available here</a>. This code in this post isn’t simply copied from the source, instead it has been rewritten in a simplified form. Some optimizations have been omitted in the name of readability so please check the current version before filing bugs or sending patches.</p>

<p>The algorithm is currently very simple but seems to work well. It simply takes the list of clicks as input and returns a guess at the grid size and offset. Here is how it works.</p>

<h3 id="find-extents"><a href="#find-extents">Find Extents</a></h3>

<p>The first thing it does is calculate the leftmost, rightmost, topmost and bottommost coordinate of the provided points. From those it then derives the width and hight of the smallest rectangle surrounding the clicks. Note that it does <strong>not</strong> use the size of the image. Everything is based off of the clicks.</p>

<div class="highlight"><pre class="highlight javascript"><code><span class="kd">let</span> <span class="nx">minX</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(...</span><span class="nx">points</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">x</span><span class="p">));</span>
<span class="kd">let</span> <span class="nx">maxX</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">max</span><span class="p">(...</span><span class="nx">points</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">x</span><span class="p">));</span>
<span class="kd">let</span> <span class="nx">minY</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">min</span><span class="p">(...</span><span class="nx">points</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">y</span><span class="p">));</span>
<span class="kd">let</span> <span class="nx">maxY</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">max</span><span class="p">(...</span><span class="nx">points</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="nx">p</span><span class="p">.</span><span class="nx">y</span><span class="p">));</span>

<span class="kd">let</span> <span class="nx">width</span> <span class="o">=</span> <span class="nx">maxX</span> <span class="o">-</span> <span class="nx">minX</span><span class="p">;</span>
<span class="kd">let</span> <span class="nx">height</span> <span class="o">=</span> <span class="nx">maxY</span> <span class="o">-</span> <span class="nx">minY</span><span class="p">;</span>
</code></pre></div>
<h3 id="reference-point"><a href="#reference-point">Reference Point</a></h3>

<p>The algorithm will use <code>(minX, minY)</code> as a reference point for some operations. This is to avoid rounding points in the wrong direction. You can imagine that if the offset was roughly half of the grid size the nearest intersection for each point would change based on small imprecision in the click. To avoid this problem we use a reference point that is expected to be close to an intersection. If the reference point is way off we will round a point the wrong way and come up with a bad result however if the reference point is good (as it usually is) everything will run smoothly.</p>

<div class="highlight"><pre class="highlight javascript"><code><span class="kd">let</span> <span class="nx">reference</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Point</span><span class="p">(</span><span class="nx">minX</span><span class="p">,</span> <span class="nx">minY</span><span class="p">);</span>
</code></pre></div>
<h3 id="rank-candidates"><a href="#rank-candidates">Rank Candidates</a></h3>

<p>The algorithm then considers every possible way to subdivide the width as a candidate. Then it simply calculates the mean squared error for every candidate cell size.</p>

<div class="highlight"><pre class="highlight javascript"><code><span class="kd">const</span> <span class="nx">MIN_PIXELS_PER_CELL</span> <span class="o">=</span> <span class="mi">8</span><span class="p">;</span>

<span class="kd">let</span> <span class="nx">minCells</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
<span class="kd">let</span> <span class="nx">maxCells</span> <span class="o">=</span> <span class="nx">width</span> <span class="o">/</span> <span class="nx">MIN_PIXELS_PER_CELL</span> <span class="o">|</span><span class="mi">0</span><span class="p">;</span>

<span class="kd">let</span> <span class="nx">candidates</span> <span class="o">=</span> <span class="p">[];</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">cells</span> <span class="o">=</span> <span class="nx">minCells</span><span class="p">;</span> <span class="nx">cells</span> <span class="o">&lt;=</span> <span class="nx">maxCells</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
	<span class="kd">let</span> <span class="nx">cellSize</span> <span class="o">=</span> <span class="nx">width</span> <span class="o">/</span> <span class="nx">cells</span><span class="p">;</span>

	<span class="kd">let</span> <span class="nx">offset</span> <span class="o">=</span> <span class="nx">calculateOffset</span><span class="p">(</span><span class="nx">cellSize</span><span class="p">,</span> <span class="nx">points</span><span class="p">);</span>
	<span class="kd">let</span> <span class="nx">error</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
	<span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">point</span> <span class="k">of</span> <span class="nx">points</span><span class="p">)</span> <span class="p">{</span>
		<span class="kd">let</span> <span class="nx">offsetCorrected</span> <span class="o">=</span> <span class="nx">point</span><span class="p">.</span><span class="nx">sub</span><span class="p">(</span><span class="nx">reference</span><span class="p">);</span>
		<span class="kd">let</span> <span class="nx">nearestIntersection</span> <span class="o">=</span> <span class="nx">offsetCorrected</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">cellSize</span><span class="p">);</span>
		<span class="kd">let</span> <span class="nx">missedBy</span> <span class="o">=</span> <span class="nx">offsetCorrected</span><span class="p">.</span><span class="nx">sub</span><span class="p">(</span><span class="nx">nearestIntersection</span><span class="p">);</span>
		<span class="kd">let</span> <span class="nx">relativeMiss</span> <span class="o">=</span> <span class="nx">missedBy</span><span class="p">.</span><span class="nx">divide</span><span class="p">(</span><span class="nx">cellSize</span><span class="p">);</span>
		<span class="nx">error</span> <span class="o">+=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">pow</span><span class="p">(</span><span class="nx">relativeMiss</span><span class="p">.</span><span class="nx">length</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
	<span class="p">}</span>

	<span class="nx">candidates</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span><span class="nx">cellSize</span><span class="p">,</span> <span class="nx">error</span><span class="p">});</span>
<span class="p">}</span>
<span class="nx">candidates</span><span class="p">.</span><span class="nx">sort</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
	<span class="c1">// Prefer smallest error.</span>
	<span class="k">if</span> <span class="p">(</span><span class="nx">a</span><span class="p">.</span><span class="nx">error</span> <span class="o">!=</span> <span class="nx">b</span><span class="p">.</span><span class="nx">error</span><span class="p">)</span> <span class="k">return</span> <span class="nx">a</span><span class="p">.</span><span class="nx">error</span> <span class="o">-</span> <span class="nx">b</span><span class="p">.</span><span class="nx">error</span><span class="p">;</span>

	<span class="c1">// Break ties with largest cell size.</span>
	<span class="k">return</span> <span class="nx">b</span><span class="p">.</span><span class="nx">cellSize</span> <span class="o">-</span> <span class="nx">b</span><span class="p">.</span><span class="nx">cellSize</span><span class="p">;</span>
<span class="p">})</span>
</code></pre></div>
<p>There are a couple of important things to note here:</p>

<ul>
  <li>The error is calculated relative to the cell size. So if your click was ¼ of a cell high and ¼ of a cell right; it would calculate the error as <code>(-0.25, 0.25)</code> error. This encourages picking a larger grid as the same absolute distance away will result in less relative error.</li>
  <li>Ties are broken by the largest grid. Ties should be really rare, only if you manage to click perfectly (however since the website uses sub-pixel accuracy for reporting clicks that is almost impossible).</li>
  <li>It uses a hardcoded minimum cell size. This both saves some work and avoids picking tiny grids because they happen to be a “perfect match”.</li>
</ul>

<h4 id="example-execution"><a href="#example-execution">Example Execution</a></h4>

<h5 id="clicks"><a href="#clicks">Clicks</a></h5>

<p>Say we have the following three clicks.</p>

<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/emlpnzbsq2mw2presljv.png" alt="clicks on the grid"></p>

<h5 id="bounding-box"><a href="#bounding-box">Bounding Box</a></h5>

<p>First we calculate the bounding box of the clicks.</p>

<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/727gx3zezv9rgic1q1kw.png" alt="clicks with bounding box"></p>

<h5 id="score-candidates"><a href="#score-candidates">Score Candidates</a></h5>

<p>Next we start dividing the bounding box’s width and calculating the errors between the click locations and the nearest intersection. In the following images the clicks are the green crosses, the candidate grid is blue and the error for each point to the nearest intersection is shown in red. A selection of interesting divisions are shown.</p>

<h5 id="by-2"><a href="#by-2">By 2</a></h5>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/7y6phr0sl6m5zwnxvqc0.png"></p>

<h5 id="by-3"><a href="#by-3">By 3</a></h5>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/4fbxwtsx9aoch5xwkvlg.png" alt="clicks with width divided by 3"></p>

<h5 id="by-4"><a href="#by-4">By 4</a></h5>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/du15y0j03d97p265somw.png" alt="clicks with width divided by 4"></p>

<h5 id="by-6"><a href="#by-6">By 6</a></h5>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/pc4ixb8fhyk6f8ab84ug.png" alt="clicks with width divided by 6"></p>

<p>After running the numbers we end up with:</p>

<table>
  <thead>
    <tr>
      <th>Cells</th>
      <th>Cell Size</th>
      <th>Click 1 Error</th>
      <th>Click 2 Error</th>
      <th>Click 3 Error</th>
      <th>Squared Summed Error</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2</td>
      <td>1.6</td>
      <td>6.3%</td>
      <td>25%</td>
      <td>31.3%</td>
      <td>0.199</td>
    </tr>
    <tr>
      <td>3</td>
      <td>1.067</td>
      <td>9.38%</td>
      <td>12.6%</td>
      <td>3.0%</td>
      <td>0.026</td>
    </tr>
    <tr>
      <td>4</td>
      <td>0.8</td>
      <td>12.5%</td>
      <td>50%</td>
      <td>37.5%</td>
      <td>0.406</td>
    </tr>
    <tr>
      <td>6</td>
      <td>0.534</td>
      <td>18.7%</td>
      <td>25.1%</td>
      <td>6.0%</td>
      <td>0.102</td>
    </tr>
  </tbody>
</table>

<p>You can see that dividing the width by 3 and 6 are the top two candidates. This makes sense as they were very close to the grid size of 1 that I used when making the example. The numbers are not perfect because the clicks are not. However you can see that it managed to make a very good guess.</p>

<p>Also notice that the <strong>absolute</strong> error of the 3 and 6 division are identical. This will be true for any multiple of the optimal grid size. However because we normalize the error by the cell size the calculation will prefer the larger grid. If the user wants the smaller grid they will have to select a new intersection that isn’t on the grid. That intersection will then be <strong>much</strong> closer for the 6 division grid and will cause that to be selected. See a possible disambiguating click below:</p>

<p><img src="https://dev-to-uploads.s3.amazonaws.com/i/50j6pezclws7xyj3rziq.png" alt="4 clicks to select a finer grid"></p>

<h3 id="calculating-offset"><a href="#calculating-offset">Calculating Offset</a></h3>

<p>The algorithm was designed to be indifferent grid offsets. Offsets are not uncommon as many maps start mid-cell for stylistic reasons or simply because the image has a border before the grid begins. This is done by detecting and correcting for any offset. This detected offset is also reported to the user so that they can configure their VTT to account for any offset.</p>

<p>The algorithm used is trivial. It simply takes the average error of all of the points (as a vector) and reports that as the offset.</p>

<div class="highlight"><pre class="highlight javascript"><code><span class="kd">function</span> <span class="nx">findOffsetPoint</span><span class="p">(</span><span class="nx">cellSize</span><span class="p">,</span> <span class="nx">points</span><span class="p">)</span> <span class="p">{</span>
	<span class="kd">let</span> <span class="nx">relativeShift</span> <span class="o">=</span> <span class="nx">points</span>
		<span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">p</span> <span class="o">=&gt;</span> <span class="p">{</span>
			<span class="kd">let</span> <span class="nx">offsetCorrected</span> <span class="o">=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">sub</span><span class="p">(</span><span class="nx">reference</span><span class="p">);</span>
			<span class="kd">let</span> <span class="nx">nearestIntersection</span> <span class="o">=</span> <span class="nx">offsetCorrected</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">cellSize</span><span class="p">);</span>
			<span class="k">return</span> <span class="nx">nearestIntersection</span><span class="p">.</span><span class="nx">sub</span><span class="p">(</span><span class="nx">offsetCorrected</span><span class="p">);</span>
		<span class="p">})</span>
		<span class="p">.</span><span class="nx">reduce</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">a</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="nx">b</span><span class="p">))</span>
		<span class="p">.</span><span class="nx">div</span><span class="p">(</span><span class="nx">points</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>

	<span class="kd">let</span> <span class="nx">nearestIntersection</span> <span class="o">=</span> <span class="nx">reference</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">cellSize</span><span class="p">);</span>
	<span class="kd">let</span> <span class="nx">referenceError</span> <span class="o">=</span> <span class="nx">nearestIntersection</span><span class="p">.</span><span class="nx">sub</span><span class="p">(</span><span class="nx">reference</span><span class="p">);</span>
	<span class="k">return</span> <span class="nx">relativeShift</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="nx">referenceError</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>
<h3 id="future-work"><a href="#future-work">Future Work</a></h3>

<h4 id="non-rectangular-grids"><a href="#non-rectangular-grids">Non-rectangular grids.</a></h4>

<p>Right now I run the algorithm in two dimensions. But you could also run it for each dimension separately. This would allow you to identify non-rectangular grids. The downside is that some combinations of points that work with the square assumption don’t provide enough information without it. For example the clicks used in the <a href="#example-execution">demonstration above</a> would be able to solve the X cell size but don’t have enough information to produce the correct Y size without assuming that it is the same. However having this option behind an option would still be useful as right now you would have to manually solve each dimension separately.</p>

<h4 id="better-extent-detection"><a href="#better-extent-detection">Better Extent Detection</a></h4>

<p>Currently the furthest coordinate in each direction is used as the extent. However if you have multiple clicks around the border it would make sense to do some sort of average. As it is now, as you add more clicks it becomes more likely that the estimated grid will be too large simply due to some clicks being outside of the grid.</p>