はじめに
folium(Leaflet を Python から扱うラッパー)で 認証付きの独自タイルサーバ を使いたい――そんな場面は意外と多いのですが、標準の TileLayer
では任意ヘッダ(Authorization
や x-api-key
)を付けてタイル取得はできません。
本記事では、folium の拡張手法(MacroElement
と Template
)を使って カスタム 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 を実装 -
Authorization
とx-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 の MacroElement
と Template
を用いて Leaflet の L.GridLayer
を拡張するクラスです。ブラウザ側で fetch()
に認証ヘッダを付与し、WMTS タイル画像を <canvas>
に描画します。
-
役割
-
Authorization
,x-api-key
をまとめたヘッダを生成 -
{{z}}/{{y}}/{{x}}
を置換してタイル URL を組み立て、fetch()
で取得 - 取得した
Blob
をImage
化し、<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に追加して表示
-
手順
- 取得した
access_token
を用意(token_type
は通常Bearer
) -
folium.Map
を作成- 例:中心
[35.6804, 139.7670]
(東京駅)、zoom_start=15
、tiles=None
- 例:中心
-
SecureGridLayer
を Map に直接取り付け- 主な引数:
tile_url
,token
,token_type
,api_key(任意)
,attribution
- 表示系:
min_zoom
,max_zoom
,tile_size(通常256)
,max_native_zoom(配信上限に合わせる)
- 仕様系:
tms=True
(TMS座標系のときのみ)
- 主な引数:
- 必要に応じて
folium.LayerControl
を追加 -
map.html
に保存してブラウザで開く
- 取得した
-
確認ポイント(ブラウザDevTools)
- Network タブでタイルリクエストのステータスが 200/304 になっているか
- Request Headers に Authorization , x-api-key が付与されているか
- Console に
token expires_in(s)
のログが出ているか(有効期限の把握)
まとめ
本記事では、folium の標準機能では対応できない「認証付きタイルサーバ」を扱う方法を紹介しました。
ポイントは以下の通りです:
- Python 側で OAuth2 のアクセストークンを取得
- folium の
MacroElement
とTemplate
を用いてカスタム GridLayer を定義 - Leaflet 側で
fetch()
+ Authorization ヘッダを付与してタイルを取得 - 出力された HTML をそのまま開いて動作確認可能
この仕組みにより、認証付きタイルサーバを folium/Leaflet に組み込むことができます。
利用したタイルサーバは ZENRIN Maps API (WMTS) です。
あくまで 静的 HTML での動作確認 を目的としたサンプルですので、実運用ではトークン管理や自動更新などの工夫が必要になります。
参考リンク
-
ZENRIN Maps API — WMTS GetTile(REST方式)
https://developers.zmaps-api.com/v20/reference/webAPI/wmts_get_tile.html -
Folium — 公式ドキュメント(Latest)
https://python-visualization.github.io/folium/ -
Leaflet — API Reference(GridLayer)
https://leafletjs.com/reference.html#gridlayer