🎨

OpenLayersでXYZタイルのpixel値を取得する

2022/02/01に公開

OpenLayers V6でXYZタイルのクリックした地点のpixelの値を取得する方法が軽く調べても見つからなかったので、サンプルをまとめておく。

サンプルサイト

<body>
    <div id="map" class="map"></div>
    <div id="result"></div>
    <script type="text/javascript">
      // https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#ECMAScript_.28JavaScript.2FActionScript.2C_etc..29
      const deg2tile = (
        lon,
        lat,
        zoom,
        options
      ) => {
        const { min, max } = Object.assign({}, options);
        let z = min && min > zoom ? min : max && max < zoom ? max : zoom;
        z = z > 0 ? Math.ceil(z) : 0;
        return {
          x: Math.floor(((lon + 180) / 360) * Math.pow(2, z)),
          y: Math.floor(
            ((1 -
              Math.log(
                Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)
              ) /
                Math.PI) /
              2) *
              Math.pow(2, z)
          ),
          z,
        };
      };
      const tile2deg = (x, y, z) => {
        // タイルの左上のlon,lat
        const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z);
        return [
          (x / Math.pow(2, z)) * 360 - 180,
          (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
        ];
      };

      const deg2pixel = (
        lon,
        lat,
        tile,
        size = 256
      ) => {
        if (![256, 512].includes(size)) throw new Error("unexpected tile size.");
        const lefttopLonLat = tile2deg(tile.x, tile.y, tile.z);
        const target = deg2tile(lon, lat, tile.z + (size === 512 ? 9 : 8));
        const lefttop = deg2tile(
          lefttopLonLat[0],
          lefttopLonLat[1],
          tile.z + (size === 512 ? 9 : 8)
        );
        return [target.x - lefttop.x, target.y - lefttop.y];
      };

      const getPixelValue = (
        image,
        x,
        y
      ) => {
        const canvas = document.createElement("canvas");
        const context = canvas.getContext("2d");
        if (context) {
          canvas.width = image.width;
          canvas.height = image.height;
          context.drawImage(image, 0, 0);

          return Array.from(context.getImageData(x, y, 1, 1).data);
        } else {
          throw new Error("can't get value.");
        }
      };

      const dsm = (rgb, u = 100) => {
        // https://maps.gsi.go.jp/development/demtile.html
        const h = Math.floor(rgb[0] * 256 * 256 + rgb[1] * 256 + rgb[2]);
        if (h === 8388608) return NaN;
        if (h > 8388608) return (h - 16777216) / u;
        return h / u;
      };

            const layer = new ol.layer.Tile({
        source: new ol.source.XYZ({
          maxZoom: 8,
          url: "https://cyberjapandata.gsi.go.jp/xyz/demgm_png/{z}/{x}/{y}.png",
          attributions:
            "国土地理院(https://maps.gsi.go.jp/development/ichiran.html)",
          tilePixelRatio:
            window && window.devicePixelRatio ? window.devicePixelRatio : 1,
          crossOrigin: "anonymous", // 設定し忘れるとcanvasで値が取れない
        }),
      });
      const map = new ol.Map({
        target: "map",
        layers: [layer],
        view: new ol.View({
            center: ol.proj.fromLonLat([0, 0]),
            zoom: 1,
        })
      });
      const clicked = (evt) => {
        let zoom = evt.map.getView().getZoom();
        const lonlat = ol.proj.toLonLat(evt.coordinate);
        if (zoom && lonlat.length > 1) {
          try {
            const source = layer.getSource();
            while (zoom >= 0) {
              const xyzCoord = deg2tile(lonlat[0], lonlat[1], zoom, {
                min: 0,
                max: 8,
              });
              const image = source
                .getTile(xyzCoord.z, xyzCoord.x, xyzCoord.y)
                .getImage();

              // ロードされてないときはsrcが空
              if (image.src.length > 0) {
                const pixel = deg2pixel(
                  lonlat[0],
                  lonlat[1],
                  xyzCoord,
                  image.width
                );
                document.getElementById("result").innerText = `lon: ${lonlat[0].toFixed(
                  4
                )}[deg], lat: ${lonlat[1].toFixed(4)}[deg], height: ${dsm(
                  getPixelValue(image, pixel[0], pixel[1])
                )}[m]`;
                break;
              }
              zoom--;
            }
            if (zoom < 0) throw new Error("can't get image.");
          } catch (e) {
            console.log(e);
            document.getElementById("result").innerText = "";
          }
        }
      };

      map.getViewport().style.cursor = "pointer";
      map.on("singleclick", clicked);
    </script>
</body>

流れ

  1. クリックした地点の経度緯度を取得
  2. 表示中のズーム率(z)と、経度緯度からタイル座標(x, y)を計算する。
  3. z/x/yのタイルの画像を取得する。取得できないときはzを-1して2に戻る。画像が取得できるまで繰り返す。
  4. z/x/yのタイルのクリックした地点のピクセル位置を取得する。
  5. canvasを使ってタイル画像のピクセル位置の値を取得する。

クリックした地点の経度緯度を取得

これはOpenlayersに用意されているメソッドを使えばいいだけ。

const clicked = (evt) => {
  let zoom = evt.map.getView().getZoom();
  const lonlat = ol.proj.toLonLat(evt.coordinate);
  // 略
}

表示中のズーム率(z)と、経度緯度からタイル座標(x, y)を計算する。

openstreetmapのサンプルを利用して、経度緯度からその座標を含むタイルの座標を計算します。
タイルには通常用意されているズーム率の範囲があるので、その中に収まるようにしています。
またzはintにまるめる必要がありますが、より詳細な値を取得したいであろうと考え、Math.ceilでzを大きい側に倒しています。

const deg2tile = (lon, lat, zoom, options) => {
  const { min, max } = Object.assign({}, options);
  let z = min && min > zoom ? min : max && max < zoom ? max : zoom;
  z = z > 0 ? Math.ceil(z) : 0;
  return {
    x: Math.floor(((lon + 180) / 360) * Math.pow(2, z)),
    y: Math.floor(
      ((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * Math.pow(2, z)),
    z,
  };
};

z/x/yのタイルの画像を取得する。

その座標のタイルがまだ画像を取得していない場合があるため、取得できるまでズーム率を下げていく。
基本的には1回目でみつからなくてもzoom - 1で見つかるはずである。

while (zoom >= 0) {
  const xyzCoord = deg2tile(lonlat[0], lonlat[1], zoom, {
    min: 0,
    max: 8, // 地理院標高タイルの最大ズーム率
  });
  const image = source
                .getTile(xyzCoord.z, xyzCoord.x, xyzCoord.y)
                .getImage();

  // ロードされてないときはsrcが空
  if (image.src.length > 0) {
    // 処理
    break;
  }
  zoom--;
}
if (zoom < 0) throw new Error("can't get image.");

z/x/yのタイルのクリックした地点のピクセル位置を取得する

タイルは256*256px512*512pxのはずなので、256px(2^8)なら、元のタイルのzに8を加えたズーム率pixelZで、クリックした点のタイル座標(target.x, target.y)と元のタイルの左上のタイル座標(lefttop.x, lefttop.y)を計算すれば、その差分からクリックした点のピクセル座標を計算できる。

const tile2deg = (x, y, z) => {
  // タイルの左上のlon,lat
  const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z);
  return [
    (x / Math.pow(2, z)) * 360 - 180,
    (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
  ];
};

const deg2pixel = (lon, lat, tile, size = 256) => {
  if (![256, 512].includes(size)) throw new Error("unexpected tile size.");
  const lefttopLonLat = tile2deg(tile.x, tile.y, tile.z);
  const target = deg2tile(lon, lat, tile.z + (size === 512 ? 9 : 8));
  const lefttop = deg2tile(
    lefttopLonLat[0],
    lefttopLonLat[1],
    tile.z + (size === 512 ? 9 : 8)
  );
  return [target.x - lefttop.x, target.y - lefttop.y];
};
const pixel = deg2pixel(
  lonlat[0],
  lonlat[1],
  xyzCoord,
  image.width
);

canvasを使ってタイル画像のピクセル位置の値を取得する

const getPixelValue = (image, x, y) => {
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");
  if (context) {
    canvas.width = image.width;
    canvas.height = image.height;
    context.drawImage(image, 0, 0);

    return Array.from(context.getImageData(x, y, 1, 1).data);
  } else {
    throw new Error("can't get value.");
  }
};

canvasのcontext.getImageDataを使って画像の指定したピクセルの値を取得できる。
タイルにcrossOriginが設定されていないと値が取得できないので注意。

おまけ:標高を計算する

単純にRGBを拾っただけではつまらないので、地理院の標高タイルを使ってその地点の高さを計算してます。
標高タイルについて

RGBの値から標高を計算できます。賢い。

const dsm = (rgb, u = 100) => {
  const h = Math.floor(rgb[0] * 256 * 256 + rgb[1] * 256 + rgb[2]);
  if (h === 8388608) return NaN;
  if (h > 8388608) return (h - 16777216) / u;
  return h / u;
};  

よりよいやり方や、見落としているだけで簡単に実現できるメソッドがあるかもしれませんが、OpenLayers V6でタイルのピクセル値を取得する方法はこんな感じです。
とはいえタイルのピクセルの値が欲しいケースなんてほとんどないと思います。
それこそ標高タイルくらいでしか使うことはなさそう。

Discussion