jimmy60504 commited on
Commit
ef96627
·
1 Parent(s): 9a7fc67

docs: enhance input station mapping and intensity visualization using Plotly

Browse files
Files changed (2) hide show
  1. app.py +224 -266
  2. requirements.txt +10 -10
app.py CHANGED
@@ -9,6 +9,7 @@ from scipy.signal import detrend, iirfilter, sosfilt, zpk2sos
9
  from scipy.spatial import cKDTree
10
  import pandas as pd
11
  from loguru import logger
 
12
 
13
  # 設定 matplotlib 中文字體支援
14
  plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
@@ -702,6 +703,137 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
702
 
703
  return waveforms, station_info_list, valid_stations, missing_components_count
704
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
 
706
  def plot_waveform(st, selected_stations, start_time, duration):
707
  """
@@ -800,27 +932,11 @@ def get_intensity_color(intensity):
800
 
801
 
802
  def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_lon=None):
803
- """使用 Folium 創建互動式震度分布地圖"""
804
- import folium
805
- from folium import plugins
806
-
807
- # 創建地圖,固定高度 800(寬度 100%)
808
- m = folium.Map(
809
- location=[23.5, 121],
810
- zoom_start=7,
811
- tiles='OpenStreetMap',
812
- width='100%',
813
- height='800px'
814
- )
815
 
816
- # 如果有央位置,標記震央
817
- if epicenter_lat and epicenter_lon:
818
- folium.Marker(
819
- [epicenter_lat, epicenter_lon],
820
- popup=f'震央<br>({epicenter_lat:.3f}, {epicenter_lon:.3f})',
821
- icon=folium.Icon(color='red', icon='star', prefix='fa'),
822
- tooltip='震央位置'
823
- ).add_to(m)
824
 
825
  # 添加震度測站標記
826
  for i, target_name in enumerate(target_names):
@@ -830,82 +946,75 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
830
  lon = target["longitude"]
831
  intensity = calculate_intensity(pga_list[i])
832
  intensity_label = calculate_intensity(pga_list[i], label=True)
833
- color = get_intensity_color(intensity)
834
  pga = pga_list[i]
835
 
836
- popup_html = f"""
837
- <div style="font-family: Arial; min-width: 150px;">
838
- <h4 style="margin: 0 0 10px 0;">{target_name}</h4>
839
- <table style="width:100%;">
840
- <tr><td><b>震度:</b></td><td style="color: {color}; font-weight: bold; font-size: 16px;">{intensity_label}</td></tr>
841
- <tr><td><b>PGA:</b></td><td>{pga:.4f} m/s²</td></tr>
842
- <tr><td><b>位置:</b></td><td>({lat:.3f}, {lon:.3f})</td></tr>
843
- </table>
844
- </div>
845
- """
846
-
847
- folium.CircleMarker(
848
- location=[lat, lon],
849
- radius=12,
850
- popup=folium.Popup(popup_html, max_width=250),
851
- tooltip=f'{target_name}: 震度 {intensity_label}',
852
- color='black',
853
- fillColor=color,
854
- fillOpacity=0.8,
855
- weight=2
856
- ).add_to(m)
857
-
858
- folium.Marker(
859
- [lat, lon],
860
- icon=folium.DivIcon(html=f'''
861
- <div style="
862
- font-size: 10px;
863
- font-weight: bold;
864
- color: black;
865
- text-align: center;
866
- text-shadow: 1px 1px 2px white, -1px -1px 2px white;
867
- ">{intensity_label}</div>
868
- ''')
869
- ).add_to(m)
870
-
871
- # 圖例
872
- legend_html = '''
873
- <div style="
874
- position: fixed;
875
- top: 10px; left: 10px;
876
- width: 180px;
877
- background-color: white;
878
- border: 2px solid grey;
879
- z-index: 9999;
880
- font-size: 14px;
881
- padding: 10px;
882
- border-radius: 5px;
883
- box-shadow: 2px 2px 6px rgba(0,0,0,0.3);
884
- ">
885
- <h4 style="margin: 0 0 10px 0;">震度等級 Intensity</h4>
886
- <table style="width: 100%;">
887
- '''
888
-
889
- intensity_levels = ["0", "1", "2", "3", "4", "5-", "5+", "6-", "6+", "7"]
890
- for idx, level in enumerate(intensity_levels):
891
- color = get_intensity_color(idx)
892
- legend_html += f'''
893
- <tr>
894
- <td style="width: 30px; height: 20px; background-color: {color}; border: 1px solid black;"></td>
895
- <td style="padding-left: 5px;">{level}</td>
896
- </tr>
897
- '''
898
-
899
- legend_html += '''
900
- </table>
901
- </div>
902
- '''
903
-
904
- m.get_root().html.add_child(folium.Element(legend_html))
905
-
906
- plugins.Fullscreen().add_to(m)
907
-
908
- return m
909
 
910
 
911
  def load_observed_intensity_image(event_name):
@@ -946,136 +1055,25 @@ def on_event_change(event_name, start_time, duration, epicenter_lon, epicenter_l
946
  當選擇事件時,同時更新波形地圖、波形圖、實際觀測圖
947
 
948
  Returns:
949
- (station_map_html, waveform_plot, info_text, observed_intensity_path)
950
  """
951
  try:
952
  # 同時更新波形地圖
953
- station_map_html, waveform_plot, info_text, _ = load_and_display_waveform(
954
  event_name, start_time, duration, epicenter_lon, epicenter_lat
955
  )
956
 
957
  # 同時更新實際觀測圖
958
  observed_intensity_path = load_observed_intensity_image(event_name)
959
 
960
- return station_map_html, waveform_plot, info_text, observed_intensity_path
961
 
962
  except Exception as e:
963
  logger.error(f"事件切換時發生錯誤: {e}")
964
  return None, None, f"錯誤: {str(e)}", None
965
 
966
 
967
- def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
968
- """創建輸入測站分布地圖:顯示所有測站 + 突顯被選中的 25 個"""
969
- import folium
970
- from folium import plugins
971
-
972
- # 創建地圖,固定高度 800(寬度 100%)
973
- m = folium.Map(
974
- location=[epicenter_lat, epicenter_lon],
975
- zoom_start=8,
976
- tiles='OpenStreetMap',
977
- width='100%',
978
- height='800px'
979
- )
980
-
981
- selected_station_codes = {s["station"] for s in selected_stations}
982
-
983
- logger.info(f"繪製所有測站 ({len(site_info)} 個)...")
984
- for idx, row in site_info.iterrows():
985
- station_code = row["Station"]
986
- lat = row["Latitude"]
987
- lon = row["Longitude"]
988
-
989
- if station_code in selected_station_codes:
990
- continue
991
-
992
- folium.CircleMarker(
993
- location=[lat, lon],
994
- radius=2,
995
- popup=f'{station_code}',
996
- tooltip=station_code,
997
- color='gray',
998
- fillColor='lightgray',
999
- fillOpacity=0.4,
1000
- weight=1
1001
- ).add_to(m)
1002
-
1003
- folium.Marker(
1004
- [epicenter_lat, epicenter_lon],
1005
- popup=f'<b>震央</b><br>({epicenter_lat:.3f}, {epicenter_lon:.3f})',
1006
- icon=folium.Icon(color='red', icon='star', prefix='fa'),
1007
- tooltip='震央位置',
1008
- zIndexOffset=1000
1009
- ).add_to(m)
1010
 
1011
- for i, station_data in enumerate(selected_stations):
1012
- station_code = station_data["station"]
1013
- lat = station_data["latitude"]
1014
- lon = station_data["longitude"]
1015
- distance = station_data["distance"]
1016
-
1017
- popup_html = f"""
1018
- <div style="font-family: Arial; min-width: 150px;">
1019
- <h4 style="margin: 0 0 10px 0; color: #d63031;">{station_code}</h4>
1020
- <table style="width:100%;">
1021
- <tr><td><b>狀態:</b></td><td><span style="color: #00b894;">✓ 已選中</span></td></tr>
1022
- <tr><td><b>順序:</b></td><td>第 {i+1} 近</td></tr>
1023
- <tr><td><b>距離:</b></td><td>{distance:.2f}°</td></tr>
1024
- <tr><td><b>位置:</b></td><td>({lat:.3f}, {lon:.3f})</td></tr>
1025
- </table>
1026
- </div>
1027
- """
1028
-
1029
- if i < 5:
1030
- color = 'green'
1031
- elif i < 15:
1032
- color = 'blue'
1033
- else:
1034
- color = 'orange'
1035
-
1036
- folium.CircleMarker(
1037
- location=[lat, lon],
1038
- radius=10,
1039
- popup=folium.Popup(popup_html, max_width=250),
1040
- tooltip=f'✓ {station_code} (第{i+1}近)',
1041
- color='black',
1042
- fillColor=color,
1043
- fillOpacity=0.8,
1044
- weight=2,
1045
- zIndexOffset=500
1046
- ).add_to(m)
1047
-
1048
- total_stations = len(site_info)
1049
- legend_html = f'''
1050
- <div style="
1051
- position: fixed;
1052
- top: 10px; left: 10px;
1053
- width: 220px;
1054
- background-color: white;
1055
- border: 2px solid grey;
1056
- z-index: 9999;
1057
- font-size: 13px;
1058
- padding: 10px;
1059
- border-radius: 5px;
1060
- box-shadow: 2px 2px 6px rgba(0,0,0,0.3);
1061
- ">
1062
- <h4 style="margin: 0 0 10px 0;">測站分布</h4>
1063
- <p style="margin: 5px 0;"><span style="color: red; font-size: 18px;">★</span> 震央</p>
1064
- <p style="margin: 5px 0;"><span style="color: lightgray;">●</span> 所有測站 ({total_stations} 個)</p>
1065
- <hr style="margin: 8px 0; border: none; border-top: 1px solid #ddd;">
1066
- <p style="margin: 5px 0; font-weight: bold;">被選中的測站:</p>
1067
- <p style="margin: 5px 0;"><span style="color: green; font-size: 16px;">●</span> 前 5 近</p>
1068
- <p style="margin: 5px 0;"><span style="color: blue; font-size: 16px;">●</span> 6-15 近</p>
1069
- <p style="margin: 5px 0;"><span style="color: orange; font-size: 16px;">●</span> 16-25 近</p>
1070
- <p style="margin: 5px 0; font-size: 11px; color: #666;">共選擇 {len(selected_stations)} 個測站</p>
1071
- </div>
1072
- '''
1073
-
1074
- m.get_root().html.add_child(folium.Element(legend_html))
1075
-
1076
- plugins.Fullscreen().add_to(m)
1077
-
1078
- return m
1079
 
1080
 
1081
  def load_and_display_waveform(event_name, start_time, duration):
@@ -1109,7 +1107,6 @@ def load_and_display_waveform(event_name, start_time, duration):
1109
 
1110
  # 4. 創建輸入測站地圖
1111
  station_map = create_input_station_map(selected_stations, epicenter_lat, epicenter_lon)
1112
- station_map_html = station_map._repr_html_()
1113
 
1114
  # 明示實際用站數(少於 25 站時顯示警告)
1115
  info_text = f"✅ 已載入波形資料\n"
@@ -1122,7 +1119,7 @@ def load_and_display_waveform(event_name, start_time, duration):
1122
  info_text += "\n請確認波形範圍後,點擊「執行預測」按鈕"
1123
 
1124
  logger.info("波形載入完成")
1125
- return station_map_html, waveform_plot, info_text, gr.update(interactive=True)
1126
 
1127
  except Exception as e:
1128
  logger.error(f"波形載入發生錯誤: {e}")
@@ -1235,7 +1232,6 @@ def predict_intensity(event_name, start_time, duration):
1235
 
1236
  # 繪製互動式地圖(固定高度 800)
1237
  intensity_map = create_intensity_map(pga_list, target_names, epicenter_lat, epicenter_lon)
1238
- map_html = intensity_map._repr_html_()
1239
 
1240
  # 載入實際觀測震度圖(filepath;左側以 800 高顯示)
1241
  observed_intensity_path = load_observed_intensity_image(event_name)
@@ -1253,7 +1249,7 @@ def predict_intensity(event_name, start_time, duration):
1253
  stats += f"預測最大震度: {max_intensity}"
1254
 
1255
  logger.info("預測完成!")
1256
- return observed_intensity_path, map_html, stats
1257
 
1258
  except Exception as e:
1259
  logger.error(f"預測過程發生錯誤: {e}")
@@ -1262,26 +1258,6 @@ def predict_intensity(event_name, start_time, duration):
1262
  return None, None, f"錯誤: {str(e)}"
1263
 
1264
 
1265
- def on_event_change(event_name, start_time, duration):
1266
- """
1267
- 事件切換或波形參數變更時,更新波形視圖(不執行推論)
1268
-
1269
- 返回:station_map_html, waveform_plot, info_text, observed_img
1270
- (與事件變更事件綁定的回調函數)
1271
-
1272
- spec #2:測站選擇上限 (25 站)、波形取樣率 (100 Hz)、時間窗長度 (30 秒)
1273
- """
1274
- try:
1275
- station_map_html, waveform_plot, info_text, _ = load_and_display_waveform(
1276
- event_name, start_time, duration
1277
- )
1278
- observed_img = load_observed_intensity_image(event_name)
1279
- return station_map_html, waveform_plot, info_text, observed_img
1280
- except Exception as e:
1281
- logger.error(f"事件變更回調發生錯誤: {e}")
1282
- return None, None, f"錯誤: {str(e)}", None
1283
-
1284
-
1285
  def on_full_workflow(event_name, start_time, duration):
1286
  """
1287
  執行完整的工作流:波形載入 → 測站選擇 → 推論 → 結果展示
@@ -1289,38 +1265,37 @@ def on_full_workflow(event_name, start_time, duration):
1289
  此函數用於首次應用加載與事件切換時自動執行完整流程
1290
 
1291
  返回所有必要的 UI 組件輸出:
1292
- (station_map_html, waveform_plot, info_text, predicted_map_html, stats_text, observed_img)
1293
 
1294
  spec #2:測站選擇上限 (25 站)、波形取樣率 (100 Hz)、時間窗長度 (30 秒)
1295
  spec #3:推論流程、PGA → 震度轉換
1296
- spec #3:推論流程、PGA → 震度轉換
1297
  """
1298
  try:
1299
  logger.info(f"[on_full_workflow] 開始執行完整工作流 - 事件: {event_name}")
1300
 
1301
  # 步驟 1: 載入波形
1302
  logger.info(f"[on_full_workflow] 步驟 1/3: 波形載入...")
1303
- station_map_html, waveform_plot, info_text, _ = load_and_display_waveform(
1304
  event_name, start_time, duration
1305
  )
1306
 
1307
- if station_map_html is None:
1308
  logger.error("[on_full_workflow] 波形載入失敗")
1309
  return None, None, info_text, None, "波形載入失敗", None
1310
 
1311
  # 步驟 2: 執行推論
1312
  logger.info(f"[on_full_workflow] 步驟 2/3: 模型推論...")
1313
- observed_img, predicted_map_html, stats_text = predict_intensity(
1314
  event_name, start_time, duration
1315
  )
1316
 
1317
- if predicted_map_html is None:
1318
  logger.error("[on_full_workflow] 推論失敗")
1319
- return station_map_html, waveform_plot, info_text, None, stats_text, observed_img
1320
 
1321
  logger.info(f"[on_full_workflow] 步驟 3/3: 完成")
1322
 
1323
- return station_map_html, waveform_plot, info_text, predicted_map_html, stats_text, observed_img
1324
 
1325
  except Exception as e:
1326
  logger.error(f"[on_full_workflow] 完整工作流發生錯誤: {e}")
@@ -1331,7 +1306,7 @@ def on_full_workflow(event_name, start_time, duration):
1331
 
1332
  # ============ Gradio 介面 ============
1333
 
1334
- with gr.Blocks(title="TTSAM 震度預測系統") as demo:
1335
  gr.Markdown("# 🌏 TTSAM 震度預測系統")
1336
 
1337
  # ========== 上層:使用說明與參數設定 ==========
@@ -1363,20 +1338,11 @@ with gr.Blocks(title="TTSAM 震度預測系統") as demo:
1363
 
1364
  with gr.Row():
1365
  start_slider = gr.Slider(0, 300, value=0, step=1, label="開始時間 (秒)")
1366
- # 將時間長度滑桿限制為 0–30 秒,與模型固定窗口對齊;小於 30 秒將於尾段 0 填充
1367
  duration_slider = gr.Slider(0, 30, value=30, step=1, label="時間長度 (秒)")
1368
  # 說明:模型最多 30 秒;小於 30 秒會自動以 0 填充至 30 秒(3000 samples @ 100 Hz)
1369
  gr.Markdown("> 模型最多 30 秒;小於 30 秒會自動以 0 填充至 30 秒(3000 samples @ 100 Hz)。")
1370
 
1371
- gr.Markdown("### 震央位置")
1372
- gr.Markdown("> 震央位置由選定的地震事件自動決定,並在地圖上標示")
1373
- epicenter_info_display = gr.Textbox(
1374
- label="震央座標",
1375
- value="緯度: 23.88° | 經度: 121.57°",
1376
- interactive=False,
1377
- lines=1
1378
- )
1379
-
1380
  with gr.Row():
1381
  load_waveform_btn = gr.Button("📊 載入波形", variant="secondary", scale=1)
1382
  predict_btn = gr.Button("🔮 執行預測", variant="primary", scale=1, interactive=False)
@@ -1387,19 +1353,21 @@ with gr.Blocks(title="TTSAM 震度預測系統") as demo:
1387
  # 中左:輸入測站地圖
1388
  with gr.Column(scale=1):
1389
  gr.Markdown("## 輸入測站分布")
1390
- input_station_map = gr.HTML(label="輸入測站地圖")
1391
 
1392
  # 中右:輸入波形
1393
  with gr.Column(scale=1):
1394
  gr.Markdown("## 輸入波形")
1395
- waveform_plot = gr.Plot(label="地震波形(選定的 25 個測站)")
 
 
1396
 
1397
  # ========== 下層:實際觀測 vs 預測結果 ==========
1398
  with gr.Row():
1399
  # 左下:預測震度地圖
1400
  with gr.Column(scale=1):
1401
  gr.Markdown("## 預測震度分布")
1402
- predicted_intensity_map = gr.HTML(label="互動式震度地圖", elem_id="predicted_intensity_map")
1403
 
1404
  # 右下:實際觀測震度圖
1405
  with gr.Column(scale=1):
@@ -1411,22 +1379,13 @@ with gr.Blocks(title="TTSAM 震度預測系統") as demo:
1411
  value=load_observed_intensity_image(list(EARTHQUAKE_EVENTS.keys())[0])
1412
  )
1413
 
1414
-
1415
-
1416
- # New function: update epicenter display when event changes
1417
- def update_epicenter_display(event_name):
1418
- # Update epicenter coordinate display
1419
- lat, lon = _get_epicenter_coords(event_name)
1420
- return f"Latitude: {lat:.2f} | Longitude: {lon:.2f}"
1421
-
1422
  # 綁定事件
1423
  event_dropdown.change(
1424
  fn=lambda event_name, start_time, duration: (
1425
  *on_full_workflow(event_name, start_time, duration),
1426
- update_epicenter_display(event_name)
1427
  ),
1428
  inputs=[event_dropdown, start_slider, duration_slider],
1429
- outputs=[input_station_map, waveform_plot, info_output, predicted_intensity_map, stats_output, observed_intensity_image, epicenter_info_display]
1430
  )
1431
 
1432
  load_waveform_btn.click(
@@ -1445,10 +1404,9 @@ with gr.Blocks(title="TTSAM 震度預測系統") as demo:
1445
  demo.load(
1446
  fn=lambda event_name, start_time, duration: (
1447
  *on_full_workflow(event_name, start_time, duration),
1448
- update_epicenter_display(event_name)
1449
  ),
1450
  inputs=[event_dropdown, start_slider, duration_slider],
1451
- outputs=[input_station_map, waveform_plot, info_output, predicted_intensity_map, stats_output, observed_intensity_image, epicenter_info_display]
1452
  )
1453
 
1454
  demo.launch()
 
9
  from scipy.spatial import cKDTree
10
  import pandas as pd
11
  from loguru import logger
12
+ import plotly.graph_objs as go
13
 
14
  # 設定 matplotlib 中文字體支援
15
  plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
 
703
 
704
  return waveforms, station_info_list, valid_stations, missing_components_count
705
 
706
+ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
707
+ """創建輸入測站分布地圖:顯示所有測站 + 突顯被選中的 25 個(使用 Plotly)"""
708
+
709
+ selected_station_codes = {s["station"] for s in selected_stations}
710
+
711
+ # 準備所有測站資料(未選中的測站)
712
+ all_stations_lat = []
713
+ all_stations_lon = []
714
+ all_stations_text = []
715
+
716
+ logger.info(f"繪製所有測站 ({len(site_info)} 個)...")
717
+ for idx, row in site_info.iterrows():
718
+ station_code = row["Station"]
719
+ if station_code not in selected_station_codes:
720
+ all_stations_lat.append(row["Latitude"])
721
+ all_stations_lon.append(row["Longitude"])
722
+ all_stations_text.append(station_code)
723
+
724
+ # 準備選中測站資料(按距離分組)
725
+ selected_group1_lat, selected_group1_lon, selected_group1_text = [], [], [] # 前 5 近
726
+ selected_group2_lat, selected_group2_lon, selected_group2_text = [], [], [] # 6-15 近
727
+ selected_group3_lat, selected_group3_lon, selected_group3_text = [], [], [] # 16-25 近
728
+
729
+ for i, station_data in enumerate(selected_stations):
730
+ station_code = station_data["station"]
731
+ lat = station_data["latitude"]
732
+ lon = station_data["longitude"]
733
+ distance = station_data["distance"]
734
+
735
+ hover_text = f"{station_code}<br>✓ 已選中<br>第 {i+1} 近<br>距離: {distance:.2f}°<br>({lat:.3f}, {lon:.3f})"
736
+
737
+ if i < 5:
738
+ selected_group1_lat.append(lat)
739
+ selected_group1_lon.append(lon)
740
+ selected_group1_text.append(hover_text)
741
+ elif i < 15:
742
+ selected_group2_lat.append(lat)
743
+ selected_group2_lon.append(lon)
744
+ selected_group2_text.append(hover_text)
745
+ else:
746
+ selected_group3_lat.append(lat)
747
+ selected_group3_lon.append(lon)
748
+ selected_group3_text.append(hover_text)
749
+
750
+ # 創建 Plotly 地圖
751
+ fig = go.Figure()
752
+
753
+ # 添加所有測站(灰色小點)
754
+ fig.add_trace(go.Scattermapbox(
755
+ lat=all_stations_lat,
756
+ lon=all_stations_lon,
757
+ mode='markers',
758
+ marker=dict(size=4, color='lightgray', opacity=0.4),
759
+ text=all_stations_text,
760
+ hovertemplate='%{text}<extra></extra>',
761
+ name=f'所有測站 ({len(all_stations_lat)} 個)',
762
+ showlegend=True
763
+ ))
764
+
765
+ # 添加選中測站 - 前 5 近(綠色)
766
+ if selected_group1_lat:
767
+ fig.add_trace(go.Scattermapbox(
768
+ lat=selected_group1_lat,
769
+ lon=selected_group1_lon,
770
+ mode='markers',
771
+ marker=dict(size=12, color='green', opacity=0.8),
772
+ text=selected_group1_text,
773
+ hovertemplate='%{text}<extra></extra>',
774
+ name='前 5 近',
775
+ showlegend=True
776
+ ))
777
+
778
+ # 添加選中測站 - 6-15 近(藍色)
779
+ if selected_group2_lat:
780
+ fig.add_trace(go.Scattermapbox(
781
+ lat=selected_group2_lat,
782
+ lon=selected_group2_lon,
783
+ mode='markers',
784
+ marker=dict(size=12, color='blue', opacity=0.8),
785
+ text=selected_group2_text,
786
+ hovertemplate='%{text}<extra></extra>',
787
+ name='6-15 近',
788
+ showlegend=True
789
+ ))
790
+
791
+ # 添加選中測站 - 16-25 近(橘色)
792
+ if selected_group3_lat:
793
+ fig.add_trace(go.Scattermapbox(
794
+ lat=selected_group3_lat,
795
+ lon=selected_group3_lon,
796
+ mode='markers',
797
+ marker=dict(size=12, color='orange', opacity=0.8),
798
+ text=selected_group3_text,
799
+ hovertemplate='%{text}<extra></extra>',
800
+ name='16-25 近',
801
+ showlegend=True
802
+ ))
803
+
804
+ # 添加震央(紅色星星)
805
+ fig.add_trace(go.Scattermapbox(
806
+ lat=[epicenter_lat],
807
+ lon=[epicenter_lon],
808
+ mode='markers',
809
+ marker=dict(size=20, color='red', symbol='star'),
810
+ text=[f'震央<br>({epicenter_lat:.3f}, {epicenter_lon:.3f})'],
811
+ hovertemplate='%{text}<extra></extra>',
812
+ name='震央',
813
+ showlegend=True
814
+ ))
815
+
816
+ # 設置地圖佈局
817
+ fig.update_layout(
818
+ mapbox=dict(
819
+ style="open-street-map",
820
+ center=dict(lat=epicenter_lat, lon=epicenter_lon),
821
+ zoom=7
822
+ ),
823
+ height=600, # 設置固定高度以適應 Gradio 容器
824
+ margin=dict(l=0, r=0, t=0, b=0),
825
+ showlegend=True,
826
+ legend=dict(
827
+ yanchor="top",
828
+ y=0.99,
829
+ xanchor="left",
830
+ x=0.01,
831
+ bgcolor="rgba(255, 255, 255, 0.8)"
832
+ )
833
+ )
834
+
835
+ return fig
836
+
837
 
838
  def plot_waveform(st, selected_stations, start_time, duration):
839
  """
 
932
 
933
 
934
  def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_lon=None):
935
+ """使用 Plotly 創建互動式震度分布地圖"""
 
 
 
 
 
 
 
 
 
 
 
936
 
937
+ # 度等級分組資料
938
+ intensity_groups = {i: {'lat': [], 'lon': [], 'text': [], 'color': get_intensity_color(i)}
939
+ for i in range(10)}
 
 
 
 
 
940
 
941
  # 添加震度測站標記
942
  for i, target_name in enumerate(target_names):
 
946
  lon = target["longitude"]
947
  intensity = calculate_intensity(pga_list[i])
948
  intensity_label = calculate_intensity(pga_list[i], label=True)
 
949
  pga = pga_list[i]
950
 
951
+ hover_text = (f"{target_name}<br>"
952
+ f"震度: {intensity_label}<br>"
953
+ f"PGA: {pga:.4f} m/s²<br>"
954
+ f"位置: ({lat:.3f}, {lon:.3f})")
955
+
956
+ intensity_groups[intensity]['lat'].append(lat)
957
+ intensity_groups[intensity]['lon'].append(lon)
958
+ intensity_groups[intensity]['text'].append(hover_text)
959
+
960
+ # 創建 Plotly 地圖
961
+ fig = go.Figure()
962
+
963
+ # 添加各震度等級的測站
964
+ intensity_labels = ["0", "1", "2", "3", "4", "5-", "5+", "6-", "6+", "7"]
965
+ for intensity_level in range(10):
966
+ group = intensity_groups[intensity_level]
967
+ if group['lat']: # 只添加有資料的震度等級
968
+ fig.add_trace(go.Scattermapbox(
969
+ lat=group['lat'],
970
+ lon=group['lon'],
971
+ mode='markers',
972
+ marker=dict(
973
+ size=14,
974
+ color=group['color'],
975
+ opacity=0.8
976
+ ),
977
+ text=group['text'],
978
+ hovertemplate='%{text}<extra></extra>',
979
+ name=f'震度 {intensity_labels[intensity_level]}',
980
+ showlegend=True
981
+ ))
982
+
983
+ # 如果有震央位置,標記震央
984
+ if epicenter_lat and epicenter_lon:
985
+ fig.add_trace(go.Scattermapbox(
986
+ lat=[epicenter_lat],
987
+ lon=[epicenter_lon],
988
+ mode='markers',
989
+ marker=dict(size=20, color='red', symbol='star'),
990
+ text=[f'震央<br>({epicenter_lat:.3f}, {epicenter_lon:.3f})'],
991
+ hovertemplate='%{text}<extra></extra>',
992
+ name='震央',
993
+ showlegend=True
994
+ ))
995
+
996
+ # 設置地圖佈局
997
+ fig.update_layout(
998
+ mapbox=dict(
999
+ style="open-street-map",
1000
+ center=dict(lat=23.5,
1001
+ lon=121),
1002
+ zoom=7
1003
+ ),
1004
+ height=800, # 設置固定高度以適應 Gradio 容器
1005
+ margin=dict(l=0, r=0, t=0, b=0),
1006
+ showlegend=True,
1007
+ legend=dict(
1008
+ yanchor="top",
1009
+ y=0.9,
1010
+ xanchor="left",
1011
+ x=0.01,
1012
+ bgcolor="rgba(255, 255, 255, 0.8)"
1013
+ )
1014
+ )
1015
+
1016
+ return fig
1017
+
 
 
 
 
 
 
1018
 
1019
 
1020
  def load_observed_intensity_image(event_name):
 
1055
  當選擇事件時,同時更新波形地圖、波形圖、實際觀測圖
1056
 
1057
  Returns:
1058
+ (station_map, waveform_plot, info_text, observed_intensity_path)
1059
  """
1060
  try:
1061
  # 同時更新波形地圖
1062
+ station_map, waveform_plot, info_text, _ = load_and_display_waveform(
1063
  event_name, start_time, duration, epicenter_lon, epicenter_lat
1064
  )
1065
 
1066
  # 同時更新實際觀測圖
1067
  observed_intensity_path = load_observed_intensity_image(event_name)
1068
 
1069
+ return station_map, waveform_plot, info_text, observed_intensity_path
1070
 
1071
  except Exception as e:
1072
  logger.error(f"事件切換時發生錯誤: {e}")
1073
  return None, None, f"錯誤: {str(e)}", None
1074
 
1075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1076
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1077
 
1078
 
1079
  def load_and_display_waveform(event_name, start_time, duration):
 
1107
 
1108
  # 4. 創建輸入測站地圖
1109
  station_map = create_input_station_map(selected_stations, epicenter_lat, epicenter_lon)
 
1110
 
1111
  # 明示實際用站數(少於 25 站時顯示警告)
1112
  info_text = f"✅ 已載入波形資料\n"
 
1119
  info_text += "\n請確認波形範圍後,點擊「執行預測」按鈕"
1120
 
1121
  logger.info("波形載入完成")
1122
+ return station_map, waveform_plot, info_text, gr.update(interactive=True)
1123
 
1124
  except Exception as e:
1125
  logger.error(f"波形載入發生錯誤: {e}")
 
1232
 
1233
  # 繪製互動式地圖(固定高度 800)
1234
  intensity_map = create_intensity_map(pga_list, target_names, epicenter_lat, epicenter_lon)
 
1235
 
1236
  # 載入實際觀測震度圖(filepath;左側以 800 高顯示)
1237
  observed_intensity_path = load_observed_intensity_image(event_name)
 
1249
  stats += f"預測最大震度: {max_intensity}"
1250
 
1251
  logger.info("預測完成!")
1252
+ return observed_intensity_path, intensity_map, stats
1253
 
1254
  except Exception as e:
1255
  logger.error(f"預測過程發生錯誤: {e}")
 
1258
  return None, None, f"錯誤: {str(e)}"
1259
 
1260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1261
  def on_full_workflow(event_name, start_time, duration):
1262
  """
1263
  執行完整的工作流:波形載入 → 測站選擇 → 推論 → 結果展示
 
1265
  此函數用於首次應用加載與事件切換時自動執行完整流程
1266
 
1267
  返回所有必要的 UI 組件輸出:
1268
+ (station_map, waveform_plot, info_text, predicted_map, stats_text, observed_img)
1269
 
1270
  spec #2:測站選擇上限 (25 站)、波形取樣率 (100 Hz)、時間窗長度 (30 秒)
1271
  spec #3:推論流程、PGA → 震度轉換
 
1272
  """
1273
  try:
1274
  logger.info(f"[on_full_workflow] 開始執行完整工作流 - 事件: {event_name}")
1275
 
1276
  # 步驟 1: 載入波形
1277
  logger.info(f"[on_full_workflow] 步驟 1/3: 波形載入...")
1278
+ station_map, waveform_plot, info_text, _ = load_and_display_waveform(
1279
  event_name, start_time, duration
1280
  )
1281
 
1282
+ if station_map is None:
1283
  logger.error("[on_full_workflow] 波形載入失敗")
1284
  return None, None, info_text, None, "波形載入失敗", None
1285
 
1286
  # 步驟 2: 執行推論
1287
  logger.info(f"[on_full_workflow] 步驟 2/3: 模型推論...")
1288
+ observed_img, predicted_map, stats_text = predict_intensity(
1289
  event_name, start_time, duration
1290
  )
1291
 
1292
+ if predicted_map is None:
1293
  logger.error("[on_full_workflow] 推論失敗")
1294
+ return station_map, waveform_plot, info_text, None, stats_text, observed_img
1295
 
1296
  logger.info(f"[on_full_workflow] 步驟 3/3: 完成")
1297
 
1298
+ return station_map, waveform_plot, info_text, predicted_map, stats_text, observed_img
1299
 
1300
  except Exception as e:
1301
  logger.error(f"[on_full_workflow] 完整工作流發生錯誤: {e}")
 
1306
 
1307
  # ============ Gradio 介面 ============
1308
 
1309
+ with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1310
  gr.Markdown("# 🌏 TTSAM 震度預測系統")
1311
 
1312
  # ========== 上層:使用說明與參數設定 ==========
 
1338
 
1339
  with gr.Row():
1340
  start_slider = gr.Slider(0, 300, value=0, step=1, label="開始時間 (秒)")
1341
+ # 將時間長度滑桿限制為 0–30 秒,與模型固定窗口對齊;小於 30 秒會自動以 0 填充
1342
  duration_slider = gr.Slider(0, 30, value=30, step=1, label="時間長度 (秒)")
1343
  # 說明:模型最多 30 秒;小於 30 秒會自動以 0 填充至 30 秒(3000 samples @ 100 Hz)
1344
  gr.Markdown("> 模型最多 30 秒;小於 30 秒會自動以 0 填充至 30 秒(3000 samples @ 100 Hz)。")
1345
 
 
 
 
 
 
 
 
 
 
1346
  with gr.Row():
1347
  load_waveform_btn = gr.Button("📊 載入波形", variant="secondary", scale=1)
1348
  predict_btn = gr.Button("🔮 執行預測", variant="primary", scale=1, interactive=False)
 
1353
  # 中左:輸入測站地圖
1354
  with gr.Column(scale=1):
1355
  gr.Markdown("## 輸入測站分布")
1356
+ input_station_map = gr.Plot(label="輸入測站地圖")
1357
 
1358
  # 中右:輸入波形
1359
  with gr.Column(scale=1):
1360
  gr.Markdown("## 輸入波形")
1361
+ waveform_plot = gr.Plot(
1362
+ label="地震波形(選定的 25 個測站)",
1363
+ )
1364
 
1365
  # ========== 下層:實際觀測 vs 預測結果 ==========
1366
  with gr.Row():
1367
  # 左下:預測震度地圖
1368
  with gr.Column(scale=1):
1369
  gr.Markdown("## 預測震度分布")
1370
+ predicted_intensity_map = gr.Plot(label="互動式震度地圖")
1371
 
1372
  # 右下:實際觀測震度圖
1373
  with gr.Column(scale=1):
 
1379
  value=load_observed_intensity_image(list(EARTHQUAKE_EVENTS.keys())[0])
1380
  )
1381
 
 
 
 
 
 
 
 
 
1382
  # 綁定事件
1383
  event_dropdown.change(
1384
  fn=lambda event_name, start_time, duration: (
1385
  *on_full_workflow(event_name, start_time, duration),
 
1386
  ),
1387
  inputs=[event_dropdown, start_slider, duration_slider],
1388
+ outputs=[input_station_map, waveform_plot, info_output, predicted_intensity_map, stats_output, observed_intensity_image]
1389
  )
1390
 
1391
  load_waveform_btn.click(
 
1404
  demo.load(
1405
  fn=lambda event_name, start_time, duration: (
1406
  *on_full_workflow(event_name, start_time, duration),
 
1407
  ),
1408
  inputs=[event_dropdown, start_slider, duration_slider],
1409
+ outputs=[input_station_map, waveform_plot, info_output, predicted_intensity_map, stats_output, observed_intensity_image]
1410
  )
1411
 
1412
  demo.launch()
requirements.txt CHANGED
@@ -1,14 +1,14 @@
1
- gradio
2
- transformers
3
  datasets
4
- torch
5
- obspy
6
- numpy
7
  matplotlib
8
- xarray
9
  netCDF4
10
- scipy
 
11
  pandas
12
- loguru
13
- huggingface_hub
14
- folium
 
 
 
 
 
1
  datasets
2
+ gradio
3
+ huggingface_hub
4
+ loguru
5
  matplotlib
 
6
  netCDF4
7
+ numpy
8
+ obspy
9
  pandas
10
+ plotly
11
+ scipy
12
+ torch
13
+ transformers
14
+ xarray