hiroki0008 commited on
Commit
eabd7c7
·
verified ·
1 Parent(s): 4afef44

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +89 -249
app.py CHANGED
@@ -1,30 +1,25 @@
1
- # app.py
2
- # pip install keplergl pandas numpy geopandas shapely gradio requests openpyxl
3
 
4
  import os
5
  import time
6
- import re
7
  import urllib.parse
8
- import tempfile
9
  import requests
10
  import pandas as pd
11
  import numpy as np
12
- import geopandas as gpd
13
- from shapely.geometry import Point
14
  import gradio as gr
15
- from keplergl import KeplerGl
16
- import base64
17
-
18
 
19
  # ----------------------------
20
  # 設定
21
  # ----------------------------
22
  GSI_USER_AGENT = os.environ.get(
23
  "GSI_USER_AGENT",
24
- "jp-gsi-geocoding-demo (contact: your_email@example.com)"
25
  )
26
  GSI_TIMEOUT_SEC = float(os.environ.get("GSI_TIMEOUT_SEC", "10"))
27
- GEOCODE_DELAY_SEC = float(os.environ.get("GSI_RATE_LIMIT_SEC", "0.0"))
 
28
  GSI_GEOCODE_URL = "https://msearch.gsi.go.jp/address-search/AddressSearch"
29
 
30
  CACHE_DIR = "data/cache"
@@ -38,7 +33,8 @@ def load_cache():
38
  if os.path.exists(CACHE_PATH):
39
  try:
40
  df = pd.read_csv(CACHE_PATH)
41
- if set(["address_input", "lat", "lon", "CF"]).issubset(df.columns):
 
42
  df["CF"] = pd.to_numeric(df["CF"], errors="coerce")
43
  df["lat"] = pd.to_numeric(df["lat"], errors="coerce")
44
  df["lon"] = pd.to_numeric(df["lon"], errors="coerce")
@@ -62,7 +58,10 @@ def make_gsi_session() -> requests.Session:
62
  return s
63
 
64
  def gsi_geocode_once(address: str, session: requests.Session) -> tuple[float, float]:
65
- """国土地理院 住所検索APIを1回呼び出し、(lat, lon) を返す。失敗時は (nan, nan)。"""
 
 
 
66
  try:
67
  if not address or str(address).strip() == "" or str(address).strip().lower() in ("nan", "none"):
68
  return (np.nan, np.nan)
@@ -75,7 +74,7 @@ def gsi_geocode_once(address: str, session: requests.Session) -> tuple[float, fl
75
  coords = (feat.get("geometry") or {}).get("coordinates") or []
76
  if isinstance(coords, (list, tuple)) and len(coords) >= 2:
77
  lon, lat = float(coords[0]), float(coords[1])
78
- return (lat, lon) # APIは [lon, lat] なので入れ替え
79
  except Exception:
80
  pass
81
  return (np.nan, np.nan)
@@ -92,7 +91,7 @@ def geocode_with_cache(addresses, CFs, use_internet=True):
92
 
93
  # cache hit
94
  if a in cache_map:
95
- lat, lon, _ = cache_map[a]
96
  if pd.notna(lat) and pd.notna(lon):
97
  results.append({"address_input": a, "CF": cf_num, "lat": float(lat), "lon": float(lon)})
98
  continue
@@ -122,246 +121,87 @@ def geocode_with_cache(addresses, CFs, use_internet=True):
122
  return df
123
 
124
  # ----------------------------
125
- # 埋め込み&パッチ
126
  # ----------------------------
127
- def _iframe_from_dataurl(html_text: str, height: int = 640) -> str:
128
- # iframe 埋め込み用(URLエンコード)
129
- data_url = "data:text/html;charset=utf-8," + urllib.parse.quote(html_text)
130
- # 予備:新規タブで開く用(Base64)
131
- link = "data:text/html;base64," + base64.b64encode(html_text.encode("utf-8")).decode("ascii")
132
- return (
133
- f'<iframe src="{data_url}" style="border:0;width:100%;height:{height}px"></iframe>'
134
- f'<div style="margin-top:6px;"><a href="{link}" target="_blank" rel="noopener">↗ 地図を新しいタブで開く</a></div>'
135
- )
136
 
137
- def _inject_relaxed_csp(html_text: str) -> str:
138
- """親ページのCSPに阻害される場合の緩和(ローカル/検証用途向け)"""
139
- csp = (
140
- "<meta http-equiv=\"Content-Security-Policy\" "
141
- "content=\"default-src * data: blob: 'unsafe-inline' 'unsafe-eval'; "
142
- "img-src * data: blob:; style-src * 'unsafe-inline'; "
143
- "script-src * 'unsafe-inline' 'unsafe-eval' https: http: data: blob:; "
144
- "connect-src * data: blob:;\">"
145
- )
146
- return html_text.replace("<head>", "<head>" + csp, 1)
147
 
148
- def _patch_to_mapbox113(html_text: str) -> str:
149
- """
150
- keplergl の保存 HTML 内の mapbox-gl 参照を、互換性が高くトークン不要の
151
- Mapbox GL JS v1.13.0(BSD)に差し替える。
152
- """
153
- html_text = re.sub(
154
- r"https://api\.tiles\.mapbox\.com/mapbox-gl-js/v[\d.]+/mapbox-gl\.css",
155
- "https://cdnjs.cloudflare.com/ajax/libs/mapbox-gl/1.13.0/mapbox-gl.css",
156
- html_text
157
- )
158
- html_text = re.sub(
159
- r"https://api\.tiles\.mapbox\.com/mapbox-gl-js/v[\d.]+/mapbox-gl\.js",
160
- "https://cdnjs.cloudflare.com/ajax/libs/mapbox-gl/1.13.0/mapbox-gl.js",
161
- html_text
162
- )
163
- return html_text
164
-
165
- # ----------------------------
166
- # Kepler.gl HTML 生成(Mapbox GL v1.13 + 地理院タイル)
167
- # ----------------------------
168
- def make_kepler_html(df_points: pd.DataFrame, height: int = 640) -> str:
169
  df_valid = df_points.dropna(subset=["lat", "lon"]).copy()
170
- df_valid["lat"] = pd.to_numeric(df_valid["lat"], errors="coerce")
171
- df_valid["lon"] = pd.to_numeric(df_valid["lon"], errors="coerce")
172
- if "CF" in df_valid.columns:
173
- df_valid["CF"] = pd.to_numeric(df_valid["CF"], errors="coerce")
174
-
175
- # 地図中心
176
- map_state = {"bearing": 0, "pitch": 0, "zoom": 6}
177
- if not df_valid.empty:
178
- map_state["latitude"] = float(df_valid["lat"].median())
179
- map_state["longitude"] = float(df_valid["lon"].median())
180
  else:
181
- map_state.update({"latitude": 35.0, "longitude": 135.0, "zoom": 4})
182
-
183
- # ポイントレイヤ
184
- layer_config = {
185
- "id": "point_layer",
186
- "type": "point",
187
- "config": {
188
- "dataId": "points",
189
- "label": "Points",
190
- "color": [18, 147, 154],
191
- "columns": {"lat": "lat", "lng": "lon"},
192
- "isVisible": True,
193
- "visConfig": {"radius": 10, "opacity": 0.9, "outline": False},
194
- },
195
- "visualChannels": {"sizeScale": "sqrt"}
196
- }
 
 
 
 
 
 
 
 
197
  if "CF" in df_valid.columns and df_valid["CF"].notna().any():
198
- layer_config["visualChannels"]["sizeField"] = {"name": "CF", "type": "real"}
199
-
200
- # 地理院タイル(標準地図)を Mapbox Style Spec v8 互換で直書き
201
- gsi_raster_style = {
202
- "version": 8,
203
- "sources": {
204
- "gsi": {
205
- "type": "raster",
206
- "tiles": [
207
- "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"
208
- ],
209
- "tileSize": 256,
210
- "attribution": "© <a href='https://www.gsi.go.jp/' target='_blank'>GSI</a>"
211
- }
212
- },
213
- "layers": [
214
- {"id": "gsi", "type": "raster", "source": "gsi", "minzoom": 0, "maxzoom": 19}
215
- ]
216
- }
217
-
218
- config = {
219
- "version": "v1",
220
- "config": {
221
- "visState": {
222
- "filters": [],
223
- "layers": [layer_config],
224
- "interactionConfig": {
225
- "tooltip": {
226
- "enabled": True,
227
- "fieldsToShow": {
228
- "points": [
229
- {"name":"address_input","format":None},
230
- {"name":"CF","format":None},
231
- {"name":"lat","format":None},
232
- {"name":"lon","format":None},
233
- ]
234
- },
235
- "compareMode": False,
236
- "compareType": "absolute",
237
- }
238
- },
239
- "layerBlending": "normal",
240
- },
241
- "mapState": map_state,
242
- "mapStyle": {
243
- "styleType": "gsi_std",
244
- "topLayerGroups": {},
245
- "visibleLayerGroups": {"label": True, "road": True, "border": False, "building": False, "water": True, "land": True},
246
- "mapStyles": {
247
- "gsi_std": {
248
- "id": "gsi_std",
249
- "label": "GSI Standard",
250
- "style": gsi_raster_style,
251
- "accessToken": None
252
- }
253
- }
254
- },
255
- },
256
- }
257
-
258
- # Kepler インスタンス(Mapbox トークン不要)
259
- m = KeplerGl(height=height, data={}, config=config, mapbox_api_key="")
260
- if not df_valid.empty:
261
- m.add_data(data=df_valid[["lat","lon","address_input","CF"]], name="points")
262
-
263
- # 保存→読込(安全に path を確保)
264
- fd, path = tempfile.mkstemp(suffix=".html")
265
- try:
266
- os.close(fd) # Windows のロック回避
267
- m.save_to_html(file_name=path, read_only=True)
268
- with open(path, "r", encoding="utf-8") as fh:
269
- kepler_html = fh.read()
270
- finally:
271
- try:
272
- os.remove(path)
273
- except Exception:
274
- pass
275
-
276
- # “白紙”対策:Mapbox GL を v1.13 に置換 + CSP を緩和 + data:URL で埋め込み
277
- kepler_html = _patch_to_mapbox113(kepler_html)
278
- kepler_html = _inject_relaxed_csp(kepler_html)
279
- return _iframe_from_dataurl(kepler_html, height=height)
280
-
281
- # ----------------------------
282
- # 実行パイプライン(ポイントのみ)
283
- # ----------------------------
284
- def _parse_indexer(x):
285
- try:
286
- return int(x)
287
- except Exception:
288
- return x
289
-
290
- def run(excel_file, sheet_name, header_row, address_col, power_col, use_inet):
291
- if excel_file is None or not hasattr(excel_file, "name"):
292
- table_df = pd.DataFrame(columns=["address_input", "CF", "lat", "lon"])
293
- return "", table_df, "Excelファイルを指定してください。"
294
-
295
- try:
296
- df = pd.read_excel(excel_file.name, sheet_name=sheet_name, header=int(header_row))
297
- except Exception as e:
298
- empty_df = pd.DataFrame(columns=["address_input", "CF", "lat", "lon"])
299
- return "", empty_df, f"Excel の読み込みに失敗しました: {e}"
300
-
301
- addr_series = df.iloc[:, address_col] if isinstance(address_col, int) else df[address_col]
302
- cf_series = df.iloc[:, power_col] if isinstance(power_col, int) else df[power_col]
303
-
304
- addresses = addr_series.astype(str).tolist()
305
- cfs = cf_series.tolist()
306
-
307
- geo_df = geocode_with_cache(addresses, cfs, use_internet=bool(use_inet))
308
- table_df = geo_df[["address_input", "CF", "lat", "lon"]].copy()
309
-
310
- # GeoDataFrame(将来拡張用)
311
- geometry = [
312
- Point(lon, lat) if (pd.notna(lat) and pd.notna(lon)) else None
313
- for lat, lon in zip(geo_df["lat"], geo_df["lon"])
314
- ]
315
- gdf_pts = gpd.GeoDataFrame(geo_df, geometry=geometry, crs="EPSG:4326")
316
-
317
- try:
318
- html_iframe = make_kepler_html(table_df, height=640)
319
- except Exception as e:
320
- html_iframe = f"<p>Kepler.gl描画に失敗しました: {e}</p>"
321
-
322
- info = [f"ポイント数(有効座標): {int(gdf_pts.geometry.notnull().sum())} / {len(gdf_pts)}"]
323
- return html_iframe, table_df, "\n".join(info)
324
-
325
- # ----------------------------
326
- # Gradio UI(ポイントのみ)
327
- # ----------------------------
328
- with gr.Blocks(title="Excel住所 → Kepler.gl(無料タイル/Mapbox不要)") as demo:
329
- gr.Markdown("## Excelの住所を国土地理院APIでジオコーディング → Kepler.gl に **ポイントのみ** を描画(Mapboxトークン不要)")
330
-
331
- with gr.Row():
332
- xlsx_in = gr.File(label="Excelファイル(住所付き)", file_count="single", file_types=[".xlsx", ".xls"])
333
-
334
- with gr.Row():
335
- sheet = gr.Textbox(label="シート名", value="認定設備")
336
- header_row = gr.Number(label="ヘッダー行番号(0始まり)", value=2, precision=0)
337
-
338
- with gr.Row():
339
- address_col = gr.Textbox(label="住所列(列名 or 0始まり列番号)", value="発電設備の所在地")
340
- power_col = gr.Textbox(label="数値列(任意:列名 or 0始まり列番号)", value="発電出力(kW)")
341
-
342
- with gr.Row():
343
- use_inet = gr.Checkbox(label="国土地理院APIに問い合わせ(オフでキャッシュのみ使用)", value=True)
344
-
345
- run_btn = gr.Button("描画")
346
-
347
- out_html = gr.HTML(label="インタラクティブ地図(Kepler.gl:ポイントのみ)")
348
- out_table = gr.Dataframe(label="ジオコーディング結果(住所・緯度・経度・CF)", wrap=True)
349
- out_info = gr.Textbox(label="メタ情報", lines=2)
350
-
351
- def _parse(x):
352
- try:
353
- return int(x)
354
- except Exception:
355
- return x
356
 
357
- def app_run(xls, s, h, a, p, inet):
358
- return run(xls, s, int(h), _parse(a), _parse(p), inet)
359
 
360
- run_btn.click(
361
- fn=app_run,
362
- inputs=[xlsx_in, sheet, header_row, address_col, power_col, use_inet],
363
- outputs=[out_html, out_table, out_info],
364
- )
365
 
366
- if __name__ == "__main__":
367
- demo.launch()
 
 
 
 
 
1
+ # app.py (Kepler.glを使わない安定版:Folium + 無料タイル)
2
+ # pip install folium gradio pandas numpy requests openpyxl
3
 
4
  import os
5
  import time
6
+ import base64
7
  import urllib.parse
 
8
  import requests
9
  import pandas as pd
10
  import numpy as np
 
 
11
  import gradio as gr
 
 
 
12
 
13
  # ----------------------------
14
  # 設定
15
  # ----------------------------
16
  GSI_USER_AGENT = os.environ.get(
17
  "GSI_USER_AGENT",
18
+ "jp-gsi-geocoding-demo (contact: your_email@example.com)" # 連絡先付き推奨
19
  )
20
  GSI_TIMEOUT_SEC = float(os.environ.get("GSI_TIMEOUT_SEC", "10"))
21
+ GEOCODE_DELAY_SEC = float(os.environ.get("GSI_RATE_LIMIT_SEC", "0.0")) # 連続呼び出し間隔(秒)
22
+
23
  GSI_GEOCODE_URL = "https://msearch.gsi.go.jp/address-search/AddressSearch"
24
 
25
  CACHE_DIR = "data/cache"
 
33
  if os.path.exists(CACHE_PATH):
34
  try:
35
  df = pd.read_csv(CACHE_PATH)
36
+ need = {"address_input", "lat", "lon", "CF"}
37
+ if need.issubset(df.columns):
38
  df["CF"] = pd.to_numeric(df["CF"], errors="coerce")
39
  df["lat"] = pd.to_numeric(df["lat"], errors="coerce")
40
  df["lon"] = pd.to_numeric(df["lon"], errors="coerce")
 
58
  return s
59
 
60
  def gsi_geocode_once(address: str, session: requests.Session) -> tuple[float, float]:
61
+ """
62
+ 国土地理院住所検索APIを1回呼び出し、(lat, lon) を返す(失敗時は (nan, nan))。
63
+ APIは [lon, lat] を返すため、順を入れ替えて返す。
64
+ """
65
  try:
66
  if not address or str(address).strip() == "" or str(address).strip().lower() in ("nan", "none"):
67
  return (np.nan, np.nan)
 
74
  coords = (feat.get("geometry") or {}).get("coordinates") or []
75
  if isinstance(coords, (list, tuple)) and len(coords) >= 2:
76
  lon, lat = float(coords[0]), float(coords[1])
77
+ return (lat, lon)
78
  except Exception:
79
  pass
80
  return (np.nan, np.nan)
 
91
 
92
  # cache hit
93
  if a in cache_map:
94
+ lat, lon, _cached_cf = cache_map[a]
95
  if pd.notna(lat) and pd.notna(lon):
96
  results.append({"address_input": a, "CF": cf_num, "lat": float(lat), "lon": float(lon)})
97
  continue
 
121
  return df
122
 
123
  # ----------------------------
124
+ # Folium 地図生成(無料タイル)
125
  # ----------------------------
126
+ import folium
127
+ from folium.plugins import MarkerCluster
 
 
 
 
 
 
 
128
 
129
+ TILE_CATALOG = {
130
+ "GSI 標準地図": "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png",
131
+ "GSI 淡色地図": "https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png",
132
+ "GSI 写真(シームレス)": "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg",
133
+ "OpenStreetMap": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
134
+ }
 
 
 
 
135
 
136
+ def _make_folium_map(df_points: pd.DataFrame, base_name: str, cluster: bool, height_px: int = 640) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  df_valid = df_points.dropna(subset=["lat", "lon"]).copy()
138
+ if df_valid.empty:
139
+ # 日本中心
140
+ center_lat, center_lon, zoom = 35.0, 135.0, 4
 
 
 
 
 
 
 
141
  else:
142
+ center_lat = float(df_valid["lat"].median())
143
+ center_lon = float(df_valid["lon"].median())
144
+ zoom = 6
145
+
146
+ # ベースマップ
147
+ tiles_url = TILE_CATALOG.get(base_name, TILE_CATALOG["GSI 標準地図"])
148
+ m = folium.Map(location=[center_lat, center_lon], zoom_start=zoom, control_scale=True, tiles=None)
149
+
150
+ # 各タイルをレイヤとして追加(UIから切替可能)
151
+ for name, url in TILE_CATALOG.items():
152
+ folium.TileLayer(
153
+ tiles=url,
154
+ name=name,
155
+ attr=f"© {name}",
156
+ overlay=False,
157
+ control=True,
158
+ max_zoom=20,
159
+ ).add_to(m)
160
+
161
+ # マーカー(CF でサイズ可変)
162
+ if cluster:
163
+ mc = MarkerCluster(name="Points").add_to(m)
164
+
165
+ # サイズスケーリング
166
  if "CF" in df_valid.columns and df_valid["CF"].notna().any():
167
+ cf = df_valid["CF"].clip(lower=0)
168
+ # 0~1正規化 → 3~15px
169
+ cf_norm = (cf - cf.min()) / (cf.max() - cf.min() + 1e-9)
170
+ sizes = (cf_norm * 12 + 3).fillna(6).tolist()
171
+ else:
172
+ sizes = [6] * len(df_valid)
173
+
174
+ for (_, row), r in zip(df_valid.iterrows(), sizes):
175
+ lat, lon = float(row["lat"]), float(row["lon"])
176
+ addr = str(row.get("address_input", ""))
177
+ cfv = row.get("CF", np.nan)
178
+ popup = folium.Popup(
179
+ folium.IFrame(
180
+ html=f"<b>住所:</b> {addr}<br><b>CF:</b> {'' if pd.isna(cfv) else cfv}",
181
+ width=260, height=80
182
+ ),
183
+ max_width=260
184
+ )
185
+ marker = folium.CircleMarker(
186
+ location=(lat, lon),
187
+ radius=float(r),
188
+ weight=1,
189
+ color="#117a8b",
190
+ fill=True,
191
+ fill_opacity=0.8,
192
+ fill_color="#12939A",
193
+ popup=popup,
194
+ )
195
+ (mc if cluster else m).add_child(marker)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
+ folium.LayerControl(position="topright").add_to(m)
 
198
 
199
+ # HTML 文字列
200
+ html = m.get_root().render()
 
 
 
201
 
202
+ # iframe 埋め込み(data:URL)+ 新規タブ用リンク
203
+ data_url = "data:text/html;charset=utf-8," + urllib.parse.quote(html)
204
+ link_url = "data:text/html;base64," + base64.b64encode(html.encode("utf-8")).decode("ascii")
205
+ iframe = (
206
+ f'<iframe src="{data_url}" style="border:0;width:100%;height:{height_px}px"></iframe>'
207
+ f'<div style="margin-top:6px;"><a href="{link_url}" target="_blank" rel="noopener">↗ 地図を新しいタブで開く<