使用D3将Web Mercator瓦片重新投影到任意投影中?

12

距离Jason Davies以Reprojected Raster Tiles惊艳了我们已经过去了几年——由于Mapbox阻止了他的网站,该地图已经无法使用,但Mollweide WatercolourInterrupted Goode Raster仍然是很棒的演示。

现在在Observable HQ上,我看到了最新的d3-geo-projectiond3-tile的文档,但是没有现代的示例来展示如何做到像Jason那样重新投影标准Mercator瓦片集。

我该如何让d3-tile在新的投影下变形?


D3-tile 不支持无重大修改的复制。至于基于 Davies 的构建,请看这里,它很容易适应 v4/5 - 我已经成功地进行了一些调整。尽管在这个例子中(也许是 Davies 的工作中)仍然存在挑战:在叠加要素时的精度;双亲容器:用于矢量图层的 SVG/canvas 和用于重新投影的 canvas 瓦片的 div;性能损失;以及其他问题。我将在本周晚些时候考虑更多关于简化解决方案的想法。 - Andrew Reid
谢谢@AndrewReid,如果您的简化解决方案在它到期后出现,我将很高兴创建一个新的悬赏。同时,我会看一下Alan的bl.ock,非常酷! - Ahmed Fasih
有点晚了——我在远离手机信号的独木舟上——但我仔细思考了一下。没有一个好的开箱即用的解决方案,但我已经采取并调整了一些示例来构建更接近的东西——但还有一些工作要做才能制作出一个漂亮干净的库。希望至少能有所帮助。 - Andrew Reid
1个回答

9

本答案基于以下资源:

这三个资源也相互关联。理解这三个示例将有助于理解下面我的示例中发生的情况。

此答案还以我正在缓慢进行的构建瓦片库为基础。

本答案的目标不是呈现一个最终的资源,而是展示如何组合相关信息的粗略演示。随着我的思考进一步深入,答案也会不断发展。

Web墨卡托瓦片

一张横跨360度经度和约170度纬度(+/- 85度)的墨卡托地图将填满一个正方形(超过85度纬度会导致失真失控,而极点在投影平面上是+/-无穷大,因此不建议包括极点)。
对于一个Web地图服务(使用墨卡托瓦片),这个世界的大部分正方形是缩放级别0。该地图横跨2^0个正方形并且高度也是2^0个正方形。
如果我们将该正方形划分为两个正方形乘以两个正方形的网格,则获得缩放级别1。该地图是2^1乘以2^1个正方形。
因此,缩放级别决定了地图的宽度和高度:2^缩放级别。如果每个正方形的像素大小相同,则每增加一个缩放级别,世界的像素宽度就会增加2倍。
幸运的是,北纬约85度以上没有陆地,而我们很少想显示南极洲,因此这个正方形适用于大多数Web地图应用程序。但是,这意味着如果我们将Web墨卡托瓦片重新投影到任何显示在这些纬度以上的内容,我们将会有一个空白区域:

world map with bald spot

Web-Mercator投影的瓦片被复制以用于Mercator投影,该投影已经旋转以显示北极点。
最后,Web Mercator瓦片在相对于瓦片的可预测和规则投影空间中呈现。如果我们重新投影瓦片,则可能会扩大或缩小每个瓦片的投影空间,我们应该注意这一点。在上面的图像中,北极周围的瓦片被重新投影为比南部更小的瓦片。投影后,瓦片不一定是均匀大小的。

Plate Carree的重新投影和重采样

重新投影网络服务瓦片的最大挑战是时间,不仅是了解投影并阅读像这样的答案所花费的时间。

投影函数是复杂的、耗时的操作,必须对呈现的每个像素进行处理。我看到的所有d3示例都使用了 此处 所见的过程(或类似变体)来进行实际的重投影和重采样。只有使用Plate Carree投影的原始图像才能使用此示例。该过程如下:

  1. 创建一个新的空白图像。
  2. 对于新图像中的每个像素,取其像素位置并反转它(使用所需的投影),以得到纬度和经度。
  3. 确定原始图像中与该经度和纬度重叠的像素。
  4. 将原始图像中该像素的信息分配给新图像中适当的像素(即步骤2中的像素)。

如果原始图像使用平面柱面投影,则不需要使用d3-geoProjection,投影坐标和未投影坐标之间的关系是线性的。例如:如果图像高度为180像素,则每个像素代表1度的纬度。这意味着步骤3与步骤2和projection.invert()相比并不需要很长时间。以下是Mike的第3步函数:

var q = ((90 - φ) / 180 * dy | 0) * dx + ((180 + λ) / 360 * dx | 0) << 2;

第二步所需的时间与重新投影图像所使用的投影方式有关。我看到的所有示例都在上述列表中的第二步中使用了d3.geoProjection.invert()——它可以将新图像中的像素位置转换为其纬度和经度。并非所有投影方式都是相同的。圆柱形投影通常比圆锥形投影效果更好,而圆锥形投影则通常优于方位投影。我还发现,在投影.invert()的计算时间方面,d3v4和d3v5之间存在一些奇怪的速度差异:

relative time for projection.invert

Time to unproject a point on a map with D3 (convert from pixels to lat/long). It is unclear why D3v4 would be faster.
And for completeness, here's a wider range of projections found in d3-geo-projections:

enter image description here

Comparing time for projection. Invert for additional projections.
Notes.
  • 这种方法可能会遇到这里描述的问题,这些问题可以通过那里的答案来解决 - 但不同的解决方案将需要额外的处理时间。

  • 这种方法使用最近邻居方法 - 这可能会导致质量问题。更高级的采样,如双线性或立方,将增加处理时间,但可能会产生更理想的图像。

  • 如果基础图像有文本,则文本可能会被旋转或以其他方式操作,使其难以阅读。

  • Mike的示例是针对单个图像的,而对于瓷砖,则程度上会有所改变,我们现在正在创建多个图像,这需要知道每个原始瓷砖的边界以及每个重新投影的瓷砖的边界(以度为单位)以及前者的瓷砖单位和后者的像素 - 细节问题。

Web墨卡托投影和重采样

当我开始研究这个问题时,我参考了Alan McConchie的solution/example。花了一段时间才注意到,在此示例中的第三步(我相信Jason Davies的工作也是如此),在重采样中没有考虑Web墨卡托瓦片,只考虑了确定瓦片边界。但是,y轴上像素之间的关系不再是像Plate Carree中那样线性的。
这意味着瓦片放置在正确的位置,但在每个瓦片内部,采样处理y轴时将其视为线性。当使用低级别的瓦片缩放级别(位于瓦片中央/上方)显示整个世界时,这种失真最为明显,并且可能是Alan提到奇怪压缩时所说的。
解决方法是在上述第3步中为每个纬度/经度对正确投影纬度。这会增加时间 - 该函数涉及Math.atan和Math.exp,但差异不应太大。在Alan和Jason的工作中,这是使用一个简单的公式完成的(但仅用于瓦片边界,而不是每个像素)。
Math.atan(Math.exp(-y * Math.PI / 180)) * 360 / Math.PI - 90;

在我的下面的例子中,我只是使用了d3.geoMercator()来制作更清晰的比例因子,使用投影包括一个额外的操作来转换x坐标。
否则,4个步骤的过程仍然相同。
找到合适的瓦片
我只看到一种干净的方法来找到要显示的瓷砖,Jason Davies的d3.quadTile,可以在这里看到。我相信Alan McConchie使用未经压缩的版本,可能会被修改。还有这个github库,提供另一个非常类似的d3.quadTiles版本。
对于McConchie/Davies,d3.quadTile将根据剪辑范围(而不是剪辑角度)和瓦片深度给出一个投影,从而提取所有与视图范围相交的瓦片。
在Alan McConchie的solution/example中,缩放级别基于投影比例尺 - 但这未必是最明智的选择:每个投影都有不同的缩放因子,一个比例尺为100的比例尺在另一个比例尺上显示的范围将会有所不同。此外,在柱面投影中,比例尺值与地图大小之间的关系可能是线性的,而非柱面投影可能具有非线性的地图大小和比例尺之间的关系。
我稍微修改了这种方法 - 我使用一个比例因子来确定初始瓷砖深度,然后如果d3.quadTile返回的瓷砖数超过一定数量,就会减少该瓷砖深度。
geoTile.tileDepth = function(z) {
    // rough starting value, needs improvement:
    var a = [w/2-1,h/2]; // points in pixels
    var b = [w/2+1,h/2];
    var dx = d3.geoDistance(p.invert(a), p.invert(b)) ; // distance between in radians      
    var scale = 2/dx*tk;
    var z = Math.max(Math.log(scale) / Math.LN2 - 8, 2);
    z = Math.min(z,15) | 0;

    // Refine:
    var maxTiles = w*h/256/128;
    var e = p.clipExtent();
    p.clipExtent([[0,0],[w,h]])
    while(d3.quadTiles(p, z).length > maxTiles) {
        z--;
    }
    p.clipExtent(e);

    return z;
}

然后,使用d3.quadTile函数提取相关的瓦片:
geoTile.tiles = function() {
    // Use Jason Davies' quad tree method to find out what tiles intercept the viewport:
    var z = geoTile.tileDepth();
    var e = p.clipExtent(); // store and put back after.

    p.clipExtent([[-1,-1],[w+1,h+1]]) // screen + 1 pixel margin on outside.
    var set = d3.quadTiles(p, Math.max(z0,Math.min(z,z1))); // Get array detailing tiles
    p.clipExtent(e);

    return set;
}

起初我认为从多个缩放深度中提取瓷砖(以解决重新投影瓷砖大小不一致的问题)是理想的方法,但这将遇到栅格中线条粗细以及不连续注释等问题。
采用Jason和Alan的工作
我使用geoTile.tiles()生成上面的瓦片集,并使用瓦片坐标(在瓦片坐标、行、列、缩放深度中)作为键值,将image元素附加到父gsvg中,通过输入/更新/退出循环运行。加载图像时,一旦图像加载完成,我们调用一个onload函数来进行实际的重投影。这与Jason和Alan的代码基本相同,我解决了以下挑战:
  • 重采样没有考虑Web墨卡托(如上所述)
  • 瓦片深度选择不当(如上所述)
  • 瓦片被重新投影为放置在div中的canvas,而不是SVG——创建了两个父容器,一个用于每种类型的特征:瓦片或矢量。
我相信我的示例已经解决了这些问题,只需要进行非常小的调整。我还添加了一些更详细的注释以供查看。
    function onload(d, that) { // d is datum, that is image element.

        // Create and fill a canvas to work with.
        var mercatorCanvas = d3.create("canvas")
          .attr("width",tileWidth)
          .attr("height",tileHeight);
        var mercatorContext = mercatorCanvas.node().getContext("2d");           
        mercatorContext.drawImage(d.image, 0, 0, tileWidth, tileHeight); // move the source tile to a canvas.

        //
        var k = d.key; // the tile address.
        var tilesAcross = 1 << k[2]; // how many tiles is the map across at a given tile's zoom depth?

        // Reference projection:
        var webMercator = d3.geoMercator()
          .scale(tilesAcross/Math.PI/2) // reference projection fill square tilesAcross units wide/high.
          .translate([0,0])
          .center([0,0])

        // Reprojected tile boundaries in pixels.           
        var reprojectedTileBounds = path.bounds(d),
        x0 = reprojectedTileBounds[0][0] | 0,
        y0 = reprojectedTileBounds[0][1] | 0,
        x1 = (reprojectedTileBounds[1][0] + 1) | 0,
        y1 = (reprojectedTileBounds[1][1] + 1) | 0;

        // Get the tile bounds:
        // Tile bounds in latitude/longitude:
        var λ0 = k[0] / tilesAcross * 360 - 180,                     // left        
        λ1 = (k[0] + 1) / tilesAcross * 360 - 180,                   // right
        φ1 = webMercator.invert([0,(k[1] - tilesAcross/2) ])[1],     // top
        φ0 = webMercator.invert([0,(k[1] + 1 - tilesAcross/2) ])[1]; // bottom.             

        // Create a new canvas to hold the what will become the reprojected tile.
        var newCanvas = d3.create("canvas").node();

        newCanvas.width = x1 - x0,      // pixel width of reprojected tile.
        newCanvas.height = y1 - y0;     // pixel height of reprojected tile.
        var newContext = newCanvas.getContext("2d");    

        if (newCanvas.width && newCanvas.height) {
            var sourceData = mercatorContext.getImageData(0, 0, tileWidth, tileHeight).data,
                target = newContext.createImageData(newCanvas.width, newCanvas.height),
                targetData = target.data;

            // For every pixel in the reprojected tile's bounding box:
            for (var y = y0, i = -1; y < y1; ++y) {
              for (var x = x0; x < x1; ++x) {
                // Invert a pixel in the new tile to find out it's lat long
                var pt = p.invert([x, y]), λ = pt[0], φ = pt[1];

                // Make sure it falls in the bounds:
                if (λ > λ1 || λ < λ0 || φ > φ1 || φ < φ0) { i += 4; targetData[i] = 0; continue; }  
                    // Find out what pixel in the source tile matches the destination tile:
                    var top = (((tilesAcross + webMercator([0,φ])[1]) * tileHeight | 0) % 256  | 0) * tileWidth;
                    var q = (((λ - λ0) / (λ1 - λ0) * tileWidth | 0) + (top)) * 4;

                    // Take the data from a pixel in the source tile and assign it to a pixel in the new tile.
                    targetData[++i] = sourceData[q];
                    targetData[++i] = sourceData[++q];
                    targetData[++i] = sourceData[++q];
                    targetData[++i] = 255;
              }
            }
            // Draw the image.
            if(target) newContext.putImageData(target, 0, 0);
        }

        // Add the data to the image in the SVG:
        d3.select(that)
          .attr("xlink:href", newCanvas.toDataURL()) // convert to a dataURL so that we can embed within the SVG.
          .attr("x", x0)
          .attr("width", newCanvas.width)
          .attr("height",newCanvas.height)
          .attr("y", y0);
    }

将其放入更大的结构中。

具有覆盖功能的常规瓷砖地图具有几个坐标系:

  • 瓦片单位(3D),它标记每个瓦片的列、行和缩放级别(分别为x、y、z)
  • 地理坐标(3D),它标记三维球体上点的纬度和经度。
  • 缩放单位(3D),它跟踪缩放平移(x、y)和缩放比例(k)。
  • 投影单位(2D),像素单位,其中纬度和经度被投影到。

任何滑动地图的目标都是在可用的系统中统一这些坐标。

当我们重新投影瓷砖时,我们需要添加一个坐标空间:

  • 瓦片集投影。
我觉得示例没有很清楚地展示所有坐标系的联系。因此,我将上述方法放在了一个geoTile对象中,正如你可能已经看到的,该对象来自于一个瓦片库的个人项目。这样做的目的是为了更加平滑地协调不同的单元。我并不想推销它,实际上它仍在开发中(只是太忙了,无法完成它); 然而,我会尝试利用d3-tile来制作一个示例,如果时间允许的话。
前进时面临的挑战
缩放速度和响应能力是我看到的最大的挑战。为了解决这个问题,我设置了缩放函数在缩放结束时触发——这在平移事件中最明显,因为通常平移会连续触发缩放函数,这可以通过转换现有图像来解决。然而,使用这种方式最可靠的方法是在静态地图上。对于已经绘制的图像实现平移将是处理平移事件的理想选择,而不是当前的重新采样方法。
动画这样的地图可能是不可能的。

也许有优化将像素转换为纬度的计算的空间,但这可能会很困难。

示例

不幸的是,代码太长了,无法在片段中展示,因此我制作了一些示例来演示。

这些内容只经过了最少的测试,如果我能完成基础瓦片库,我会为此目的派生它,同时它可以作为一个示例。代码的核心在于d3-reprojectSlippy.js文件中的geoTile.tile(),其中包含进入/更新/退出循环(相当基本)和上述的onload函数。由于我会在一边处理瓦片,所以我会保持这个答案的更新。
备选方案
重新投影瓦片很麻烦且耗时。如果可能的话,另一种选择是在所需的投影中生成瓦片集。这已经在OSM tiles中完成,但同样麻烦和耗时 - 只适用于地图制作者,而不是浏览器。
简而言之
重新投影的墨卡托瓦片需要时间,你应该阅读上面的内容。

谢谢!我本来希望这个会更容易一些,但是我非常高兴有了这些详细的信息!我非常想看到一个d3/pure-JS方法,并将其与Cesium.js/WebGL进行对比,所以这太棒了。如果可以的话,您能否在“invert”的时间条形图中添加卫星投影? - Ahmed Fasih
我回家后会运行更多的数字来进行卫星投影 - 我也会用它做一个例子。使用WebGL方法可能更好,尽管我的经验有限,而且我没有使用过Cesium。从d3的角度来看,主要挑战(除了处理时间)是在重新采样和平铺示例中缺乏文档 - 我已经在这里尝试解决这个问题,但将努力以某种形式加以改进(同时寻找优化)。 - Andrew Reid
抱歉耽搁了,卫星投影似乎比其反演时间慢一些 - 但也可能是我的系统负载过重。我认为,倾斜角度越大,性能越差,但我还没有太多时间去研究它。这里有一个示例(静态),它肯定不快。 - Andrew Reid
除了优秀的内容外,还有一些细节很可能会被大多数读者忽略。例如使用Unicode来打印小型大写字母...哎呀,你真是要把我搞崩啊! - altocumulus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接