1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

foliumで認証付きタイル(WMTS)を表示する

Last updated at Posted at 2025-08-17

はじめに

folium(Leaflet を Python から扱うラッパー)で 認証付きの独自タイルサーバ を使いたい――そんな場面は意外と多いのですが、標準の TileLayer では任意ヘッダ(Authorizationx-api-key)を付けてタイル取得はできません。

本記事では、folium の拡張手法(MacroElementTemplate)を使って カスタム GridLayer を作り、ブラウザ側の fetch() で認証ヘッダを付けて WMTS タイルを描画する手順を紹介します。サンプルでは ZENRIN Maps API の WMTS タイルを利用します。

ポイントは以下の3つです。

  • Python 側で OAuth2(client_credentials)によりアクセストークンを取得
  • folium の Template で JavaScript を埋め込み、fetch() に認証ヘッダを付与
  • 生成した 静的 HTML をそのまま開いて動作確認

今回の例では、ZENRIN Maps API の WMTS タイルサーバを利用しています。
参考: ZENRIN Maps API - WMTS Get Tile

今回は動作確認を優先した実装です
HTML にアクセストークンが埋め込まれるため、本番利用ではトークンの寿命・権限制御・再生成フローなどの運用を前提にしてください

まずは完成形

  • OAuth2(client_credentials)でアクセストークンを取得し、HTML に埋め込み
  • MacroElement + Template認証ヘッダ付き fetch を行うカスタム GridLayer を実装
  • Authorizationx-api-key を同じヘッダオブジェクトで付与
  • Retina 表示・TMS 反転・簡易エラープレースホルダ対応
  • map.whenReady() でレイヤを追加
  • 中心:東京駅([35.6804, 139.7670])、zoom_start=15
# =========================
# 事前に環境変数を設定してください
#   set ZMAPS_CLIENT_ID=...
#   set ZMAPS_CLIENT_SECRET=...
#   set ZMAPS_TOKEN_URL=https://test-auth.zmaps-api.com/oauth2/token
# =========================

import os
import time
import base64
import requests
import folium
from folium.elements import MacroElement
from jinja2 import Template

# ===== 設定 =====
CLIENT_ID = os.environ.get("ZMAPS_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("ZMAPS_CLIENT_SECRET", "")
TOKEN_URL = os.environ.get("ZMAPS_TOKEN_URL", "https://test-auth.zmaps-api.com/oauth2/token")

TILE_URL = (
    "https://test-web.zmaps-api.com/map/wmts_tile/"
    "ZpcFaeMB/default/Z3857_3_21/{z}/{y}/{x}.png"
)
ATTRIBUTION = "© ZENRIN Maps API"

# 追加ヘッダ
X_API_KEY = os.environ.get("ZMAPS_X_API_KEY", "")

def fetch_access_token(timeout_sec: int = 10):
    """
    OAuth2 Client Credentialsでアクセストークン取得
    (サーバ仕様に合わせ、Basic + x-www-form-urlencoded)
    """
    credentials = f"{CLIENT_ID}:{CLIENT_SECRET}"
    encoded = base64.b64encode(credentials.encode()).decode()
    headers = {
        "Authorization": f"Basic {encoded}",
        "Content-Type": "application/x-www-form-urlencoded",
    }
    data = {"grant_type": "client_credentials"}

    resp = requests.post(TOKEN_URL, headers=headers, data=data, timeout=timeout_sec)
    resp.raise_for_status()
    j = resp.json()
    token = j.get("access_token")
    token_type = j.get("token_type", "Bearer")
    expires_in = j.get("expires_in")
    if not token:
        raise RuntimeError(f"アクセストークン取得失敗: {j}")
    return {
        "access_token": token,
        "token_type": token_type,
        "expires_in": expires_in,
        "fetched_at": int(time.time()),
    }

oauth = fetch_access_token()
ACCESS_TOKEN = oauth["access_token"]
TOKEN_TYPE = oauth.get("token_type", "Bearer")
EXPIRES_IN = oauth.get("expires_in")

class SecureGridLayer(MacroElement):
    """
    fetch() + Authorization / x-api-key でタイル取得し、canvasに描画する GridLayer
    - Retina対応(devicePixelRatio)
    - map.whenReady() で追加
    """
    _template = Template("""
        {% macro script(this, kwargs) %}
        (function(){
          // foliumのMap変数
          var map = {{this._parent.get_name()}};

          // 認証ヘッダ
          var headers = {
            "Authorization": "{{this.token_type}} {{this.token}}"
            {% if this.api_key %}, "x-api-key": "{{this.api_key}}"{% endif %}
          };

          // デバッグ用:有効期限が返ってきていれば通知(静的HTMLのため自動更新は不可)
          {% if this.expires_in is not none %}
          if (console && console.log) console.log("[ZMaps] token expires_in(s): {{this.expires_in}}");
          {% endif %}

          // エラータイル描画(任意)
          function drawErrorTile(tile, size, err) {
            try {
              var ctx = tile.getContext('2d');
              ctx.fillStyle = '#f5f5f5';
              ctx.fillRect(0,0,size.x,size.y);
              ctx.strokeStyle = '#ddd';
              for (var x=0;x<size.x;x+=16){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,size.y); ctx.stroke(); }
              for (var y=0;y<size.y;y+=16){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(size.x,y); ctx.stroke(); }
              ctx.fillStyle = '#c00';
              ctx.font = '12px sans-serif';
              var msg = (err && err.message) ? err.message : 'tile error';
              ctx.fillText(msg, 6, size.y - 6);
            } catch(_) {}
          }

          // カスタムGridLayer
          var SecureLayer = L.GridLayer.extend({
            createTile: function(coords, done) {
              var tile = document.createElement('canvas');
              var size = this.getTileSize();

              // Retina対応:内部解像度を上げる
              var ratio = (window && window.devicePixelRatio) ? window.devicePixelRatio : 1;
              tile.width  = size.x * ratio;
              tile.height = size.y * ratio;
              tile.style.width  = size.x + "px";
              tile.style.height = size.y + "px";

              var z = coords.z, x = coords.x, y = coords.y;
              // TMS の場合は y を反転
              var useTms = {{ 'true' if this.tms else 'false' }};
              if (useTms) { y = Math.pow(2, z) - 1 - y; }

              var url = "{{this.tile_url}}"
                .replace('{z}', z)
                .replace('{x}', x)
                .replace('{y}', y);

              fetch(url, { headers: headers, mode: 'cors', credentials: 'omit', cache: 'default' })
                .then(function(res){
                  if (!res.ok) throw new Error("HTTP " + res.status);
                  return res.blob();
                })
                .then(function(blob){
                  var img = new Image();
                  img.crossOrigin = 'anonymous';
                  img.onload = function(){
                    try {
                      var ctx = tile.getContext('2d');
                      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
                      ctx.drawImage(img, 0, 0, size.x, size.y);
                      URL.revokeObjectURL(img.src);
                      done(null, tile);
                    } catch(e) {
                      drawErrorTile(tile, size, e);
                      done(e, tile);
                    }
                  };
                  img.onerror = function(e){
                    drawErrorTile(tile, size, new Error('image onerror'));
                    done(e, tile);
                  };
                  img.src = URL.createObjectURL(blob);
                })
                .catch(function(err){
                  if (console && console.debug) console.debug('[Tile fetch error]', err, url);
                  drawErrorTile(tile, size, err);
                  done(err, tile);
                });

              return tile;
            }
          });

          // マップ初期化後に追加
          map.whenReady(function(){
            var layer = new SecureLayer({
              minZoom: {{this.min_zoom}},
              maxZoom: {{this.max_zoom}},
              tileSize: {{this.tile_size}}
              {% if this.max_native_zoom is not none %}, maxNativeZoom: {{this.max_native_zoom}}{% endif %}
            });
            layer.addTo(map);

            if (map.attributionControl) {
              map.attributionControl.addAttribution("{{this.attribution}}");
            }
          });
        })();
        {% endmacro %}
    """)

    def __init__(
        self,
        tile_url: str,
        token: str,
        token_type: str = "Bearer",
        api_key: str = "",
        attribution: str = "",
        min_zoom: int = 0,
        max_zoom: int = 22,
        tile_size: int = 256,
        tms: bool = False,
        max_native_zoom: int | None = None,
        expires_in: int | None = None,
    ):
        super().__init__()
        self._name = "SecureGridLayer"
        self.tile_url = tile_url
        self.token = token
        self.token_type = token_type
        self.api_key = api_key
        self.attribution = attribution
        self.min_zoom = min_zoom
        self.max_zoom = max_zoom
        self.tile_size = tile_size
        self.tms = tms
        self.max_native_zoom = max_native_zoom
        self.expires_in = expires_in

# ===== folium マップ作成 =====
m = folium.Map(
    location=[35.6804, 139.7670],  # 東京駅
    zoom_start=15,
    tiles=None
)

# Map に直接アタッチ
m.add_child(
    SecureGridLayer(
        tile_url=TILE_URL,
        token=ACCESS_TOKEN,
        token_type=TOKEN_TYPE,
        api_key=X_API_KEY,
        attribution=ATTRIBUTION,
        min_zoom=0,
        max_zoom=21,
        tile_size=256,
        tms=False,
        max_native_zoom=21,
        expires_in=EXPIRES_IN
    )
)

folium.LayerControl(collapsed=False).add_to(m)
m.save("map.html")
print("map.html を出力しました。")

コードの説明

アクセストークンの取得

まずは Python 側で OAuth2 の client_credentials フローを使い、ZENRIN Maps API のアクセストークンを取得します。

以下のコードでは requests を使い、環境変数から CLIENT_ID / CLIENT_SECRET を読み取る構成にしています。
(記事のサンプルでは直書きでも動きますが、本番利用では必ず 環境変数や Secret Manager を利用してください)

import os
import time
import requests
from requests.adapters import HTTPAdapter, Retry

# =========================
# 事前に環境変数を設定してください
#   set ZMAPS_CLIENT_ID=...
#   set ZMAPS_CLIENT_SECRET=...
#   set ZMAPS_TOKEN_URL=https://test-auth.zmaps-api.com/oauth2/token
# =========================

CLIENT_ID = os.environ.get("ZMAPS_CLIENT_ID", "")
CLIENT_SECRET = os.environ.get("ZMAPS_CLIENT_SECRET", "")
TOKEN_URL = os.environ.get("ZMAPS_TOKEN_URL", "https://test-auth.zmaps-api.com/oauth2/token")

def fetch_access_token(max_retries: int = 3, timeout_sec: int = 10):
    """OAuth2 Client Credentials フローでアクセストークンを取得"""
    session = requests.Session()
    retries = Retry(
        total=max_retries,
        backoff_factor=0.8,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["POST"]
    )
    session.mount("https://", HTTPAdapter(max_retries=retries))

    resp = session.post(
        TOKEN_URL,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET
        },
        timeout=timeout_sec
    )
    resp.raise_for_status()
    return resp.json()

oauth = fetch_access_token()
print(oauth)

出力例

{
  "access_token": "eyJraWQiOiJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}

foliumでカスタムタイルレイヤーを組み込む

folium の Template を使って、認証付きのカスタムタイルレイヤーを組み込みます。

SecureGridLayer の定義

SecureGridLayer は、folium の MacroElementTemplate を用いて Leaflet の L.GridLayer を拡張するクラスです。ブラウザ側で fetch() に認証ヘッダを付与し、WMTS タイル画像を <canvas> に描画します。

  • 役割

    • Authorization,x-api-key をまとめたヘッダを生成
    • {{z}}/{{y}}/{{x}} を置換してタイル URL を組み立て、fetch() で取得
    • 取得した BlobImage 化し、<canvas> に描画(Retina 対応:devicePixelRatio
    • tms=True の場合は Y 座標を反転(TMS 座標系)
    • map.whenReady(...) でレイヤを追加
  • 主な引数

    • tile_url: WMTS タイル URL(例:.../{z}/{y}/{x}.png
    • token, token_type: 認証トークンと種別(通常 "Bearer"
    • api_key: 追加ヘッダ
    • attribution: 表示する帰属テキスト(例:© ZENRIN Maps API
    • min_zoom, max_zoom, tile_size: ズーム・タイル設定(多くは 256)
    • tms: True で TMS 座標系(Y 反転)を使用
    • max_native_zoom: 配信上限に合わせると拡大時の画質が安定
    • expires_in: トークンの有効期限秒
from folium.elements import MacroElement
from jinja2 import Template

class SecureGridLayer(MacroElement):
    """
    認証ヘッダ付きで WMTS タイルを fetch() し、<canvas> に描画する Leaflet GridLayer。
    - folium の MacroElement + Template で JavaScript を埋め込み
    - Authorization / x-api-key を1つのヘッダオブジェクトに集約
    - Retina 対応(devicePixelRatio)
    - 失敗時の簡易エラープレースホルダ
    - map.whenReady() で安全にレイヤ追加
    """
    _template = Template("""
        {% macro script(this, kwargs) %}
        (function(){
          // folium が生成する map 変数
          var map = {{this._parent.get_name()}};

          // 認証ヘッダをまとめて定義
          var headers = {
            "Authorization": "{{this.token_type}} {{this.token}}"
            {% if this.api_key %}, "x-api-key": "{{this.api_key}}"{% endif %}
          };

          // 任意:トークン有効期限をログ(静的HTMLなので自動更新は不可)
          {% if this.expires_in is not none %}
          if (console && console.log) console.log("[token expires_in(s)] {{this.expires_in}}");
          {% endif %}

          // 失敗時の簡易エラープレースホルダ
          function drawErrorTile(tile, size, err) {
            try {
              var ctx = tile.getContext('2d');
              ctx.fillStyle = '#f5f5f5';
              ctx.fillRect(0,0,size.x,size.y);
              ctx.strokeStyle = '#ddd';
              for (var x=0;x<size.x;x+=16){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,size.y); ctx.stroke(); }
              for (var y=0;y<size.y;y+=16){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(size.x,y); ctx.stroke(); }
              ctx.fillStyle = '#c00';
              ctx.font = '12px sans-serif';
              var msg = (err && err.message) ? err.message : 'tile error';
              ctx.fillText(msg, 6, size.y - 6);
            } catch(_) {}
          }

          // カスタム GridLayer 実装
          var SecureLayer = L.GridLayer.extend({
            createTile: function(coords, done) {
              var tile = document.createElement('canvas');
              var size = this.getTileSize();

              // Retina 対応:内部解像度のみ上げる(CSSサイズは等倍)
              var ratio = (window && window.devicePixelRatio) ? window.devicePixelRatio : 1;
              tile.width  = size.x * ratio;
              tile.height = size.y * ratio;
              tile.style.width  = size.x + "px";
              tile.style.height = size.y + "px";

              var z = coords.z, x = coords.x, y = coords.y;

              // TMS の場合は y を反転(XYZ の場合はそのまま)
              var useTms = {{ 'true' if this.tms else 'false' }};
              if (useTms) { y = Math.pow(2, z) - 1 - y; }

              var url = "{{this.tile_url}}"
                .replace('{z}', z)
                .replace('{x}', x)
                .replace('{y}', y);

              fetch(url, { headers: headers, mode: 'cors', credentials: 'omit', cache: 'default' })
                .then(function(res){
                  if (!res.ok) throw new Error("HTTP " + res.status);
                  return res.blob();
                })
                .then(function(blob){
                  var img = new Image();
                  img.crossOrigin = 'anonymous'; // canvas に描画するため
                  img.onload = function(){
                    try {
                      var ctx = tile.getContext('2d');
                      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
                      ctx.drawImage(img, 0, 0, size.x, size.y);
                      URL.revokeObjectURL(img.src);
                      done(null, tile);
                    } catch(e) {
                      drawErrorTile(tile, size, e);
                      done(e, tile);
                    }
                  };
                  img.onerror = function(e){
                    drawErrorTile(tile, size, new Error('image onerror'));
                    done(e, tile);
                  };
                  img.src = URL.createObjectURL(blob);
                })
                .catch(function(err){
                  if (console && console.debug) console.debug('[Tile fetch error]', err, url);
                  drawErrorTile(tile, size, err);
                  done(err, tile);
                });

              return tile;
            }
          });

          // マップ初期化後に追加
          map.whenReady(function(){
            var layer = new SecureLayer({
              minZoom: {{this.min_zoom}},
              maxZoom: {{this.max_zoom}},
              tileSize: {{this.tile_size}}
              {% if this.max_native_zoom is not none %}, maxNativeZoom: {{this.max_native_zoom}}{% endif %}
            });
            layer.addTo(map);

            if (map.attributionControl) {
              map.attributionControl.addAttribution("{{this.attribution}}");
            }
          });
        })();
        {% endmacro %}
    """)

    def __init__(
        self,
        tile_url: str,
        token: str,
        token_type: str = "Bearer",
        api_key: str = "",
        attribution: str = "",
        min_zoom: int = 0,
        max_zoom: int = 22,
        tile_size: int = 256,
        tms: bool = False,
        max_native_zoom: int | None = None,
        expires_in: int | None = None,
    ):
        super().__init__()
        self._name = "SecureGridLayer"
        self.tile_url = tile_url
        self.token = token
        self.token_type = token_type
        self.api_key = api_key
        self.attribution = attribution
        self.min_zoom = min_zoom
        self.max_zoom = max_zoom
        self.tile_size = tile_size
        self.tms = tms
        self.max_native_zoom = max_native_zoom
        self.expires_in = expires_in

foliumに追加して表示

  • 手順

    1. 取得した access_token を用意(token_type は通常 Bearer
    2. folium.Map を作成
      • 例:中心 [35.6804, 139.7670](東京駅)、zoom_start=15tiles=None
    3. SecureGridLayerMap に直接取り付け
      • 主な引数:tile_url, token, token_type, api_key(任意), attribution
      • 表示系:min_zoom, max_zoom, tile_size(通常256), max_native_zoom(配信上限に合わせる)
      • 仕様系:tms=True(TMS座標系のときのみ)
    4. 必要に応じて folium.LayerControl を追加
    5. map.html に保存してブラウザで開く
  • 確認ポイント(ブラウザDevTools)

    • Network タブでタイルリクエストのステータスが 200/304 になっているか
    • Request Headers に Authorization , x-api-key が付与されているか
    • Console に token expires_in(s) のログが出ているか(有効期限の把握)

まとめ

本記事では、folium の標準機能では対応できない「認証付きタイルサーバ」を扱う方法を紹介しました。
ポイントは以下の通りです:

  • Python 側で OAuth2 のアクセストークンを取得
  • folium の MacroElementTemplate を用いてカスタム GridLayer を定義
  • Leaflet 側で fetch() + Authorization ヘッダを付与してタイルを取得
  • 出力された HTML をそのまま開いて動作確認可能

この仕組みにより、認証付きタイルサーバを folium/Leaflet に組み込むことができます。
利用したタイルサーバは ZENRIN Maps API (WMTS) です。

あくまで 静的 HTML での動作確認 を目的としたサンプルですので、実運用ではトークン管理や自動更新などの工夫が必要になります。

参考リンク

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?