From 145b52490f429de019c04e2569ad7e6acb95048f Mon Sep 17 00:00:00 2001 From: deng Date: Wed, 10 Dec 2025 19:33:52 +0800 Subject: [PATCH 1/2] add annotation --- Dockerfile | 4 ++-- hiking_assistant/app.py | 15 ++++++++------- hiking_assistant/elevation.py | 18 +++++++++++------- hiking_assistant/gpx.py | 24 +++++++++++++----------- hiking_assistant/map.py | 15 ++++++++++++--- hiking_assistant/utils.py | 2 +- hiking_assistant/weather.py | 12 +++++++----- pyproject.toml | 2 +- uv.lock | 2 +- 9 files changed, 56 insertions(+), 38 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0dc7894..8d8d4fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,5 +47,5 @@ EXPOSE 8800 CMD ["python", "-m", "streamlit", "run", "app.py"] # Build -# docker build --platform=linux/amd64 -t hiking_assistant:1.0.0 . -# docker save -o hiking_assistant.tar hiking_assistant:1.0.0 \ No newline at end of file +# docker build --platform=linux/amd64 -t hiking_assistant:1.0.1 . +# docker save -o hiking_assistant.tar hiking_assistant:1.0.1 \ No newline at end of file diff --git a/hiking_assistant/app.py b/hiking_assistant/app.py index 277abbb..b9152a8 100644 --- a/hiking_assistant/app.py +++ b/hiking_assistant/app.py @@ -16,14 +16,15 @@ from weather import WeatherFetcher class HikingAssistant: - def __init__(self): - self._config = self._load_config() + def __init__(self, config_path: str = 'assets/config.yaml') -> None: + self._config = self._load_config(config_path) - def _load_config(self): - with open('assets/config.yaml', 'r') as f: + @staticmethod + def _load_config(config_path: str) -> dict: + with open(config_path, 'r') as f: return yaml.safe_load(f) - def _set_page_background(self, image_path, opacity=0.8): + def _set_page_background(self, image_path: str, opacity: float = 0.8) -> None: """Set background image for the application. Args: @@ -46,7 +47,7 @@ class HikingAssistant: unsafe_allow_html=True, ) - def _add_custom_title(self, title, image_path): + def _add_custom_title(self, title: str, image_path: str) -> None: favicon_data = convert_image_to_base64(image_path) ext = image_path.split('.')[-1] st.markdown( @@ -59,7 +60,7 @@ class HikingAssistant: unsafe_allow_html=True, ) - def run(self): + def run(self) -> None: """Hiking Assistant application""" # Page configuration st.set_page_config( diff --git a/hiking_assistant/elevation.py b/hiking_assistant/elevation.py index 10fbfc1..02080ee 100644 --- a/hiking_assistant/elevation.py +++ b/hiking_assistant/elevation.py @@ -3,13 +3,15 @@ # author: deng # date: 20251127 +from typing import Optional + import plotly.graph_objects as go class ElevationRenderer: """Render elevation profiles with gradient-based coloring.""" - def __init__(self): + def __init__(self) -> None: """Initialize elevation profile renderer.""" self.color = { 'steep_ascent': 'rgb(139, 69, 19)', @@ -32,7 +34,7 @@ class ElevationRenderer: 'gentle_descent': '緩下坡 (> -20%)', } - def _get_gradient_category(self, gradient): + def _get_gradient_category(self, gradient: float) -> str: """Determine gradient category based on slope thresholds. Args: @@ -50,7 +52,9 @@ class ElevationRenderer: else: return 'gentle_descent' - def _downsample_data(self, distances, elevations, gradients, max_points=800): + def _downsample_data( + self, distances: list[float], elevations: list[float], gradients: list[float], max_points: int = 800 + ) -> tuple[list[float], list[float], list[float]]: """Downsample data if there are too many points. Args: @@ -96,7 +100,7 @@ class ElevationRenderer: return downsampled_distances, downsampled_elevations, downsampled_gradients - def create_elevation_profile(self, distances, elevations, gradients): + def create_elevation_profile(self, distances: list[float], elevations: list[float], gradients: list[float]) -> Optional[go.Figure]: """Create an interactive elevation profile chart. Args: @@ -184,7 +188,7 @@ class ElevationRenderer: return fig - def calculate_gradient_distribution(self, distances, gradients): + def calculate_gradient_distribution(self, distances: list[float], gradients: list[float]) -> Optional[dict[str, float]]: """Calculate distance distribution across different gradient categories. Args: @@ -224,7 +228,7 @@ class ElevationRenderer: 'gentle_descent': gentle_descent_km, } - def create_gradient_pie_chart(self, distances, gradients): + def create_gradient_pie_chart(self, distances: list[float], gradients: list[float]) -> Optional[go.Figure]: """Create a pie chart showing gradient distribution. Args: @@ -262,7 +266,7 @@ class ElevationRenderer: return fig - def add_gradient_legend(self, fig): + def add_gradient_legend(self, fig: go.Figure) -> go.Figure: """Add a custom legend explaining the color scheme. Args: diff --git a/hiking_assistant/gpx.py b/hiking_assistant/gpx.py index 9e44a7e..b8b1e7d 100644 --- a/hiking_assistant/gpx.py +++ b/hiking_assistant/gpx.py @@ -3,6 +3,8 @@ # author: deng # date: 20251127 +from typing import IO, Optional + import gpxpy import numpy as np from geopy.distance import geodesic @@ -11,7 +13,7 @@ from geopy.distance import geodesic class GPXProcessor: """Handle GPX file parsing and route analysis.""" - def __init__(self, gpx_file): + def __init__(self, gpx_file: IO) -> None: """Initialize GPX processor with uploaded file. Args: @@ -25,7 +27,7 @@ class GPXProcessor: self.distances = [] # unit: km self.waypoints = [] # list of (lat, lon, name, elevation) - def _get_sample_rate(self): + def _get_sample_rate(self) -> float: """Get the median time interval between points. Returns: @@ -37,7 +39,7 @@ class GPXProcessor: median_sample_time = np.median(diff).total_seconds() return median_sample_time - def validate_and_parse(self): + def validate_and_parse(self) -> bool: """Validate and parse the GPX file. Returns: @@ -77,7 +79,7 @@ class GPXProcessor: except Exception as e: raise Exception(f'GPX 檔案解析失敗: {str(e)}') - def calculate_distance(self): + def calculate_distance(self) -> float: """Calculate total distance of the route. Returns: @@ -96,7 +98,7 @@ class GPXProcessor: return total_distance - def calculate_elevation_gain_loss(self, threshold=1.0): + def calculate_elevation_gain_loss(self, threshold: float = 1.0) -> tuple[float, float]: """Calculate total elevation gain and loss with noise filtering. GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can @@ -127,7 +129,7 @@ class GPXProcessor: return gain, loss - def get_min_max_elevation(self): + def get_min_max_elevation(self) -> tuple[float, float]: """Get minimum and maximum elevation. Returns: @@ -138,7 +140,7 @@ class GPXProcessor: return min(self.elevations), max(self.elevations) - def get_start_end_points(self): + def get_start_end_points(self) -> tuple[Optional[tuple[float, float, float]], Optional[tuple[float, float, float]]]: """Get start and end point coordinates with elevation. Returns: @@ -149,7 +151,7 @@ class GPXProcessor: return (self.points[0][0], self.points[0][1], self.elevations[0]), (self.points[-1][0], self.points[-1][1], self.elevations[-1]) - def get_all_points(self): + def get_all_points(self) -> list[tuple[float, float]]: """Get all route points. Returns: @@ -157,7 +159,7 @@ class GPXProcessor: """ return self.points - def get_elevation_profile_data(self): + def get_elevation_profile_data(self) -> tuple[list[float], list[float]]: """Get data for elevation profile visualization. Returns: @@ -165,7 +167,7 @@ class GPXProcessor: """ return self.distances, self.elevations - def get_gradients(self): + def get_gradients(self) -> list[float]: """Calculate gradient (slope) for each segment. Returns: @@ -188,7 +190,7 @@ class GPXProcessor: return gradients - def calculate_naismith_time(self, horizontal_speed=5, vertical_speed=600): + def calculate_naismith_time(self, horizontal_speed: float = 5, vertical_speed: float = 600) -> int: """Calculate estimated hiking time using Naismith's Rule. Naismith's Rule: diff --git a/hiking_assistant/map.py b/hiking_assistant/map.py index 01b944a..19e51ba 100644 --- a/hiking_assistant/map.py +++ b/hiking_assistant/map.py @@ -3,17 +3,26 @@ # author: deng # date: 20251127 +from typing import Optional + import folium class MapRenderer: """Render hiking routes on interactive maps.""" - def __init__(self): + def __init__(self) -> None: """Initialize map renderer.""" pass - def create_route_map(self, points, start_point, end_point, waypoints=None, tile_layer='OpenStreetMap'): + def create_route_map( + self, + points: list[tuple[float, float]], + start_point: Optional[tuple[float, float, float]], + end_point: Optional[tuple[float, float, float]], + waypoints: Optional[list[dict]] = None, + tile_layer: str = 'OpenStreetMap', + ) -> Optional[folium.Map]: """Create an interactive map with the hiking route. Args: @@ -117,7 +126,7 @@ class MapRenderer: return route_map - def add_hover_marker(self, route_map, position, label='當前位置'): + def add_hover_marker(self, route_map: folium.Map, position: tuple[float, float], label: str = '當前位置') -> folium.Map: """Add a hover position marker to the map. Args: diff --git a/hiking_assistant/utils.py b/hiking_assistant/utils.py index c46b20c..3adceb6 100644 --- a/hiking_assistant/utils.py +++ b/hiking_assistant/utils.py @@ -9,6 +9,6 @@ import streamlit as st @st.cache_data -def convert_image_to_base64(image_path): +def convert_image_to_base64(image_path: str) -> str: with open(image_path, 'rb') as f: return base64.b64encode(f.read()).decode() diff --git a/hiking_assistant/weather.py b/hiking_assistant/weather.py index aa93b1e..45b0b40 100644 --- a/hiking_assistant/weather.py +++ b/hiking_assistant/weather.py @@ -3,19 +3,21 @@ # author: deng # date: 20251127 +from typing import Optional + import requests class WeatherFetcher: """Fetch weather data for hiking locations.""" - def __init__(self, api_url='https://api.open-meteo.com/v1/forecast', request_timeout=8, forecast_days=7): + def __init__(self, api_url: str = 'https://api.open-meteo.com/v1/forecast', request_timeout: int = 8, forecast_days: int = 7) -> None: """Initialize weather fetcher.""" self.api_url = api_url self.request_timeout = request_timeout self.forecast_days = max(forecast_days, 1) - def convert_wind_degrees_to_flow_direction(self, degrees): + def convert_wind_degrees_to_flow_direction(self, degrees: Optional[float]) -> str: """Convert wind direction from degrees to flow direction emoji. Args: @@ -30,7 +32,7 @@ class WeatherFetcher: index = round(degrees / 45) % 8 return directions[index] - def get_wind_speed_indicator(self, wind_speed): + def get_wind_speed_indicator(self, wind_speed: Optional[float]) -> str: """Get wind speed level indicator based on speed. Args: @@ -51,7 +53,7 @@ class WeatherFetcher: else: return '🔴' # Dangerous - def get_uv_index_indicator(self, uv_index): + def get_uv_index_indicator(self, uv_index: Optional[float]) -> str: """Get UV index level indicator based on index. Args: @@ -74,7 +76,7 @@ class WeatherFetcher: else: return '🟣' # Extreme - def get_weather(self, latitude, longitude): + def get_weather(self, latitude: float, longitude: float) -> Optional[dict]: """Fetch weather data for given coordinates. Args: diff --git a/pyproject.toml b/pyproject.toml index 2b3cec1..b0daf09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hiking-assistant" -version = "1.0.0" +version = "1.0.1" description = "This is a web app to analyze gpx for hiking planning" readme = "README.md" requires-python = ">=3.13" diff --git a/uv.lock b/uv.lock index 6ef12b5..a266e56 100644 --- a/uv.lock +++ b/uv.lock @@ -227,7 +227,7 @@ wheels = [ [[package]] name = "hiking-assistant" -version = "1.0.0" +version = "1.0.1" source = { virtual = "." } dependencies = [ { name = "folium" }, -- 2.49.0 From 302e105f09a8ab369cc0e82b62bc79cacad7a8bc Mon Sep 17 00:00:00 2001 From: deng Date: Mon, 25 May 2026 13:51:48 +0800 Subject: [PATCH 2/2] accelerate loading speed --- CHANGELOG.md | 9 ++++++ Dockerfile | 4 +-- hiking_assistant/app.py | 43 +++++++++++++++++++---------- hiking_assistant/assets/config.toml | 22 +++++++++++++++ hiking_assistant/utils.py | 15 ++++++++++ pyproject.toml | 3 +- uv.lock | 4 +-- 7 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 hiking_assistant/assets/config.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3241e..2d1d088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 更新紀錄 +## [1.0.2] - 2026-05-25 + +### ⚡ 效能優化 + +- 將設定檔格式從 YAML 改為 TOML,改用 Python 內建 `tomllib` 解析,減少第三方依賴並加快啟動速度 +- 在背景執行緒預熱重量級模組(`plotly`、`folium`、`gpxpy`、`numpy`、`geopy`),縮短首次載入頁面時間 + +--- + ## [1.0.0] - 2025-12-03 ### 🎉 首次正式發布 diff --git a/Dockerfile b/Dockerfile index 8d8d4fb..66fc266 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,5 +47,5 @@ EXPOSE 8800 CMD ["python", "-m", "streamlit", "run", "app.py"] # Build -# docker build --platform=linux/amd64 -t hiking_assistant:1.0.1 . -# docker save -o hiking_assistant.tar hiking_assistant:1.0.1 \ No newline at end of file +# docker build --platform=linux/amd64 -t hiking_assistant:1.0.2 . +# docker save -o hiking_assistant.tar hiking_assistant:1.0.2 \ No newline at end of file diff --git a/hiking_assistant/app.py b/hiking_assistant/app.py index b9152a8..ab9ab48 100644 --- a/hiking_assistant/app.py +++ b/hiking_assistant/app.py @@ -3,26 +3,24 @@ # author: deng # date: 20251127 +import threading from datetime import datetime import streamlit as st -import yaml -from elevation import ElevationRenderer -from gpx import GPXProcessor -from map import MapRenderer -from streamlit_folium import st_folium -from utils import convert_image_to_base64 -from weather import WeatherFetcher +from utils import convert_image_to_base64, parse_config + + +def _prewarm_imports() -> None: + """Pre-import heavy modules in background.""" + import elevation # noqa: F401 (imports plotly) + import gpx # noqa: F401 (imports gpxpy, numpy, geopy) + import map # noqa: F401 (imports folium) + import weather # noqa: F401 class HikingAssistant: - def __init__(self, config_path: str = 'assets/config.yaml') -> None: - self._config = self._load_config(config_path) - - @staticmethod - def _load_config(config_path: str) -> dict: - with open(config_path, 'r') as f: - return yaml.safe_load(f) + def __init__(self, config_path: str = 'assets/config.toml') -> None: + self._config = parse_config(config_path) def _set_page_background(self, image_path: str, opacity: float = 0.8) -> None: """Set background image for the application. @@ -94,6 +92,8 @@ class HikingAssistant: if 'gpx_file_key' not in st.session_state or st.session_state.gpx_file_key != file_key: # Process GPX file (only when it's a new file) with st.spinner('正在解析 GPX 檔案...'): + from gpx import GPXProcessor + gpx_processor = GPXProcessor(uploaded_file) if not gpx_processor.validate_and_parse(): @@ -177,6 +177,9 @@ class HikingAssistant: # Map section with st.spinner('正在渲染地圖...'): + from map import MapRenderer + from streamlit_folium import st_folium + map_renderer = MapRenderer() route_map = map_renderer.create_route_map(all_points, start_point, end_point, waypoints) @@ -190,6 +193,8 @@ class HikingAssistant: with profile_col: with st.spinner('正在繪製海拔剖面圖...'): + from elevation import ElevationRenderer + profile_renderer = ElevationRenderer() elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients) @@ -214,6 +219,8 @@ class HikingAssistant: st.header('☀️ 天氣安抓') if start_point: + from weather import WeatherFetcher + weather_fetcher = WeatherFetcher(forecast_days=self._config['app']['weather']['forecast_days']) weather_key = f'weather_{start_point[0]:.6f}_{start_point[1]:.6f}' @@ -344,6 +351,12 @@ class HikingAssistant: st.markdown(footer_text, unsafe_allow_html=True) +@st.cache_resource(show_spinner=False, max_entries=1) +def get_app_instance() -> HikingAssistant: + return HikingAssistant() + + if __name__ == '__main__': - app = HikingAssistant() + threading.Thread(target=_prewarm_imports, daemon=True).start() + app = get_app_instance() app.run() diff --git a/hiking_assistant/assets/config.toml b/hiking_assistant/assets/config.toml new file mode 100644 index 0000000..e45aac1 --- /dev/null +++ b/hiking_assistant/assets/config.toml @@ -0,0 +1,22 @@ +[app] +version = "1.0.2" +page_title = "山山登山小助手" +page_favicon_path = "./assets/new_favicon.webp" +page_footer_text = "⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估
Made with ❤️ by deng" +page_background_path = "./assets/background_compressed.jpg" +page_background_opacity = 0.8 + +[app.altitude_sickness] +elevation_threshold = 2100 +warning_text = "此路線海拔較高,請留意[高山症](https://www.ysnp.gov.tw/StaticPage/MountainSickness)發生風險" +emoji = "💊" + +[app.estimated_time] +horizontal_speed = 3 +vertical_speed = 400 + +[app.map] +height = 550 + +[app.weather] +forecast_days = 7 diff --git a/hiking_assistant/utils.py b/hiking_assistant/utils.py index 3adceb6..233c145 100644 --- a/hiking_assistant/utils.py +++ b/hiking_assistant/utils.py @@ -4,6 +4,7 @@ # date: 20251128 import base64 +import tomllib import streamlit as st @@ -12,3 +13,17 @@ import streamlit as st def convert_image_to_base64(image_path: str) -> str: with open(image_path, 'rb') as f: return base64.b64encode(f.read()).decode() + + +def parse_config(config_path: str) -> dict: + """Config parser + + Args: + config_path (str): path of config toml. + + Returns: + dict: configuration dictionary + """ + with open(config_path, 'rb') as file: + config = tomllib.load(file) + return config diff --git a/pyproject.toml b/pyproject.toml index b0daf09..6e3a983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hiking-assistant" -version = "1.0.1" +version = "1.0.2" description = "This is a web app to analyze gpx for hiking planning" readme = "README.md" requires-python = ">=3.13" @@ -13,7 +13,6 @@ dependencies = [ "requests>=2.32.3", "geopy>=2.4.1", "streamlit-plotly-events>=0.0.6", - "pyyaml>=6.0.3", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index a266e56..779cd6b 100644 --- a/uv.lock +++ b/uv.lock @@ -227,14 +227,13 @@ wheels = [ [[package]] name = "hiking-assistant" -version = "1.0.1" +version = "1.0.2" source = { virtual = "." } dependencies = [ { name = "folium" }, { name = "geopy" }, { name = "gpxpy" }, { name = "plotly" }, - { name = "pyyaml" }, { name = "requests" }, { name = "streamlit" }, { name = "streamlit-folium" }, @@ -254,7 +253,6 @@ requires-dist = [ { name = "geopy", specifier = ">=2.4.1" }, { name = "gpxpy", specifier = ">=1.6.2" }, { name = "plotly", specifier = ">=5.24.1" }, - { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", specifier = ">=2.32.3" }, { name = "streamlit", specifier = ">=1.51.0" }, { name = "streamlit-folium", specifier = ">=0.23.1" }, -- 2.49.0