Spaces:
Build error
Build error
Commit ·
0687749
0
Parent(s):
Initial commit: Set up Trailhead structure, Docker, README, and .gitignore
Browse files- .gitignore +158 -0
- Dockerfile +36 -0
- README.md +130 -0
- Resources/roadmap.md +109 -0
- Routes/track_5-14724236830.gpx +266 -0
- app.py +351 -0
- assets/custom.css +158 -0
- requirements.txt +7 -0
- src/gpx_parser.py +314 -0
- src/llm.py +355 -0
.gitignore
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
|
| 28 |
+
# PyInstaller
|
| 29 |
+
# Usually these files are written by a python script, from a template
|
| 30 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 31 |
+
*.manifest
|
| 32 |
+
*.spec
|
| 33 |
+
|
| 34 |
+
# Installer logs
|
| 35 |
+
pip-log.txt
|
| 36 |
+
pip-delete-this-directory.txt
|
| 37 |
+
|
| 38 |
+
# Unit test / coverage reports
|
| 39 |
+
htmlcov/
|
| 40 |
+
.tox/
|
| 41 |
+
.nox/
|
| 42 |
+
.coverage
|
| 43 |
+
.coverage.*
|
| 44 |
+
.cache
|
| 45 |
+
nosetests.xml
|
| 46 |
+
coverage.xml
|
| 47 |
+
*.cover
|
| 48 |
+
*.log
|
| 49 |
+
.hypothesis/
|
| 50 |
+
.pytest_cache/
|
| 51 |
+
cover/
|
| 52 |
+
|
| 53 |
+
# Translations
|
| 54 |
+
*.mo
|
| 55 |
+
*.pot
|
| 56 |
+
|
| 57 |
+
# Django stuff:
|
| 58 |
+
*.log
|
| 59 |
+
local_settings.py
|
| 60 |
+
db.sqlite3
|
| 61 |
+
db.sqlite3-journal
|
| 62 |
+
|
| 63 |
+
# Flask stuff:
|
| 64 |
+
instance/
|
| 65 |
+
.webassets-cache
|
| 66 |
+
|
| 67 |
+
# Scrapy stuff:
|
| 68 |
+
.scrapy
|
| 69 |
+
|
| 70 |
+
# Sphinx documentation
|
| 71 |
+
docs/_build/
|
| 72 |
+
|
| 73 |
+
# PyBuilder
|
| 74 |
+
.pybuilder/
|
| 75 |
+
target/
|
| 76 |
+
|
| 77 |
+
# Jupyter Notebook
|
| 78 |
+
.ipynb_checkpoints
|
| 79 |
+
|
| 80 |
+
# IPython
|
| 81 |
+
profile_default/
|
| 82 |
+
ipython_config.py
|
| 83 |
+
|
| 84 |
+
# pyenv
|
| 85 |
+
# For a library or app, you might want to share your .python-version.
|
| 86 |
+
# See https://github.com/pyenv/pyenv/blob/master/COMMANDS.md#pyenv-version-file
|
| 87 |
+
#.python-version
|
| 88 |
+
|
| 89 |
+
# pipenv
|
| 90 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 91 |
+
# However, in case of collaboration, if delegates have different platforms or python versions,
|
| 92 |
+
# Pipfile.lock might be conflictual.
|
| 93 |
+
#Pipfile.lock
|
| 94 |
+
|
| 95 |
+
# poetry
|
| 96 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 97 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 98 |
+
#poetry.lock
|
| 99 |
+
|
| 100 |
+
# pdm
|
| 101 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 102 |
+
#pdm.lock
|
| 103 |
+
|
| 104 |
+
# virtualenv
|
| 105 |
+
.venv/
|
| 106 |
+
venv/
|
| 107 |
+
ENV/
|
| 108 |
+
env/
|
| 109 |
+
/bin/
|
| 110 |
+
/include/
|
| 111 |
+
/lib/
|
| 112 |
+
/share/
|
| 113 |
+
|
| 114 |
+
# pipenv
|
| 115 |
+
# Alternative places for virtualenv
|
| 116 |
+
.venv
|
| 117 |
+
venv
|
| 118 |
+
ENV
|
| 119 |
+
env
|
| 120 |
+
|
| 121 |
+
# Spyder project settings
|
| 122 |
+
.spyderproject
|
| 123 |
+
.spyder-py3
|
| 124 |
+
|
| 125 |
+
# Rope project settings
|
| 126 |
+
.ropeproject
|
| 127 |
+
|
| 128 |
+
# mkdocs documentation
|
| 129 |
+
/site/
|
| 130 |
+
|
| 131 |
+
# mypy
|
| 132 |
+
.mypy_cache/
|
| 133 |
+
.dmypy.json
|
| 134 |
+
dmypy.json
|
| 135 |
+
|
| 136 |
+
# Pyre type checker
|
| 137 |
+
.pyre/
|
| 138 |
+
|
| 139 |
+
# pytype static analyzer
|
| 140 |
+
.pytype/
|
| 141 |
+
|
| 142 |
+
# Cython debug symbols
|
| 143 |
+
cython_debug/
|
| 144 |
+
|
| 145 |
+
# Model files
|
| 146 |
+
model/
|
| 147 |
+
*.gguf
|
| 148 |
+
|
| 149 |
+
# Trailhead specific cache & temp files
|
| 150 |
+
temp/
|
| 151 |
+
*.cache.json
|
| 152 |
+
*.wav
|
| 153 |
+
*.mp3
|
| 154 |
+
|
| 155 |
+
# Operating System Files
|
| 156 |
+
Thumbs.db
|
| 157 |
+
desktop.ini
|
| 158 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use a slim Python image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 6 |
+
PORT=7860 \
|
| 7 |
+
BACKEND=llama_cpp \
|
| 8 |
+
MODEL_DIR=/code/model
|
| 9 |
+
|
| 10 |
+
# Set working directory
|
| 11 |
+
WORKDIR /code
|
| 12 |
+
|
| 13 |
+
# Install basic runtime dependencies (git is useful for huggingface_hub downloads)
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
git \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Install llama-cpp-python using the pre-compiled CPU wheels index
|
| 19 |
+
RUN pip install --no-cache-dir llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 20 |
+
|
| 21 |
+
# Copy requirements and install remaining python packages
|
| 22 |
+
COPY requirements.txt .
|
| 23 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# Pre-download the Gemma 4 E2B model GGUF so the container boots instantly
|
| 26 |
+
RUN mkdir -p /code/model && \
|
| 27 |
+
python -c "from huggingface_hub import hf_hub_download; hf_hub_download(repo_id='bartowski/google_gemma-4-E2B-it-GGUF', filename='google_gemma-4-E2B-it-Q4_K_M.gguf', local_dir='/code/model')"
|
| 28 |
+
|
| 29 |
+
# Copy the application source code
|
| 30 |
+
COPY . .
|
| 31 |
+
|
| 32 |
+
# Expose Gradio's port
|
| 33 |
+
EXPOSE 7860
|
| 34 |
+
|
| 35 |
+
# Launch the Gradio app
|
| 36 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🌲 Trailhead — Tactical Trail Computer & Route Planner
|
| 2 |
+
|
| 3 |
+
[](https://huggingface.co/spaces)
|
| 4 |
+
[](./Dockerfile)
|
| 5 |
+
[](https://opensource.org/licenses/MIT)
|
| 6 |
+
|
| 7 |
+
> **"Plan online at basecamp, trek offline on the trail."**
|
| 8 |
+
|
| 9 |
+
**Trailhead** is an offline-first, mobile-friendly trail computer and navigation assistant designed for wilderness hiking and backpacking. It parses GPX files, calculates smoothed elevation profiles, generates interactive offline maps, and leverages an in-process Large Language Model (LLM) and Speech-to-Text (ASR) to guide you safely through the backcountry without relying on cellular connection.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 🧭 System Architecture
|
| 14 |
+
|
| 15 |
+
```mermaid
|
| 16 |
+
graph TD
|
| 17 |
+
A[Basecamp: Signal / Wifi] -->|Download Map Tiles & Route| B(GPX Upload / ORS Fetch)
|
| 18 |
+
B --> C{Trailhead App}
|
| 19 |
+
C --> D[Deterministic Engine]
|
| 20 |
+
C --> E[AI Navigation Layer]
|
| 21 |
+
C --> F[Offline Journaling]
|
| 22 |
+
|
| 23 |
+
D -->|Naismith's Rule & Smoothing| G[Distance / Pace / smoothed Elevation / ETA]
|
| 24 |
+
E -->|Gemma-4 GGUF via llama.cpp| H[Contextual Checkpoint Briefing & RAG First-Aid]
|
| 25 |
+
F -->|whisper.cpp ASR| I[SQLite Database + Post-Trek Shareable Reports]
|
| 26 |
+
|
| 27 |
+
G --> J[Tactical HUD UI]
|
| 28 |
+
H --> J
|
| 29 |
+
I --> J
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## ✨ Key Features
|
| 35 |
+
|
| 36 |
+
### 1. Ingest & Planning (Basecamp Mode)
|
| 37 |
+
* **GPX Upload:** Directly upload any standard GPX route containing track points or waypoints.
|
| 38 |
+
* **OpenRouteService (ORS) Routing:** Generate custom route segments between coordinates using the OSM-based ORS API (requires API key, planning phase only).
|
| 39 |
+
|
| 40 |
+
### 2. Tactical HUD & Route Metrics
|
| 41 |
+
* **Elevation Profile Smoothing:** Applies a moving-average window and noise threshold to eliminate GPX vertical jitter and provide realistic elevation gain/loss sums.
|
| 42 |
+
* **Naismith's Rule Estimator:** Calculates estimated trekking time assuming a 5 km/h base speed plus 1 hour per 600m of ascent, helping you plan realistic daily splits.
|
| 43 |
+
* **Interactive Map:** Built using `folium`, mapping out the route, checkpoints, and waypoints securely inside a sandboxed iframe.
|
| 44 |
+
|
| 45 |
+
### 3. Contextual Wilderness Guide AI
|
| 46 |
+
* **In-Process LLM:** Powered by `google_gemma-4-E2B-it-GGUF` running locally on your device or server CPU via `llama-cpp-python`.
|
| 47 |
+
* **Proximity Checkpoint Narration:** Provides real-time terrain updates, safety advice, and target destination briefings as you approach waypoints.
|
| 48 |
+
* **First-Aid RAG Field Guide:** Retreives localized wilderness first-aid procedures and references corresponding guide sections under extreme constraints.
|
| 49 |
+
* **Rule-Based Risk Advisory:** Analyzes remaining daylight, current altitude (AMS detection), and weather to prompt warnings (e.g. recommending alternative campsites if pace degrades).
|
| 50 |
+
|
| 51 |
+
### 4. Offline Voice Journal & Post-Trek Reports
|
| 52 |
+
* **ASR Voice Logs:** Dictate logs hands-free in the cold using `pywhispercpp` (whisper.cpp tiny). Logs transcribing audio, time, and coordinates are saved directly to SQLite.
|
| 53 |
+
* **Post-Trek Storyteller:** Converts your journal entries and raw GPS points into an AI-narrated story artifact.
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## 🚀 Quick Start
|
| 58 |
+
|
| 59 |
+
### Prerequisites
|
| 60 |
+
Make sure you have Python 3.11+ installed.
|
| 61 |
+
|
| 62 |
+
### Installation
|
| 63 |
+
|
| 64 |
+
1. **Clone the repository:**
|
| 65 |
+
```bash
|
| 66 |
+
git clone <your-github-repo-url>
|
| 67 |
+
cd TrailHead
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
2. **Create and activate a virtual environment:**
|
| 71 |
+
```bash
|
| 72 |
+
python -m venv .venv
|
| 73 |
+
# Windows:
|
| 74 |
+
.venv\Scripts\activate
|
| 75 |
+
# macOS/Linux:
|
| 76 |
+
source .venv/bin/activate
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
3. **Install dependencies:**
|
| 80 |
+
For local LLM inference on CPU, install `llama-cpp-python` first (using precompiled wheels is recommended for Windows):
|
| 81 |
+
```bash
|
| 82 |
+
pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 83 |
+
pip install -r requirements.txt
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
4. **Run the Application:**
|
| 87 |
+
```bash
|
| 88 |
+
python app.py
|
| 89 |
+
```
|
| 90 |
+
Open `http://localhost:7860` in your web browser.
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## 🐳 Docker Setup & Hugging Face Spaces
|
| 95 |
+
|
| 96 |
+
This project is fully ready to be deployed as a Docker container or hosted directly as a Hugging Face Space.
|
| 97 |
+
|
| 98 |
+
### Run locally with Docker
|
| 99 |
+
Build and run the Docker container:
|
| 100 |
+
```bash
|
| 101 |
+
docker build -t trailhead-computer .
|
| 102 |
+
docker run -p 7860:7860 trailhead-computer
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Deploy to Hugging Face Spaces
|
| 106 |
+
1. Create a new Space on [Hugging Face](https://huggingface.co/new-space) using the **Docker** SDK.
|
| 107 |
+
2. Select the **Blank** template or copy the `Dockerfile` directly.
|
| 108 |
+
3. Push the codebase to your Hugging Face Space repository.
|
| 109 |
+
4. The container automatically downloads the `google_gemma-4-E2B-it-Q4_K_M.gguf` model during build time, ensuring the Space starts up instantly without any downloading delays on first launch.
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## 🛠️ Technical Details & Algorithms
|
| 114 |
+
|
| 115 |
+
### Elevation Smoothing Filter
|
| 116 |
+
Raw GPX files suffer from GPS vertical drift, leading to massive over-reporting of elevation gain. Trailhead resolves this by:
|
| 117 |
+
1. Batch-querying missing elevations via the **Open-Meteo API** (when GPX coordinates lack altitude).
|
| 118 |
+
2. Applying a **Moving Average window (size 5)** to smooth out high-frequency noise.
|
| 119 |
+
3. Using a **threshold delta (default 2.0 meters)**, only summing elevation changes that exceed the threshold:
|
| 120 |
+
$$\Delta E = \sum |e_i - e_{i-1}| \quad \text{for} \quad |e_i - e_{i-1}| \ge 2.0\text{m}$$
|
| 121 |
+
|
| 122 |
+
### Time Estimation (Naismith's Rule)
|
| 123 |
+
We estimate trail times dynamically using the classic Naismith's formula:
|
| 124 |
+
$$\text{Time (hours)} = \frac{\text{Distance (km)}}{5.0} + \frac{\text{Elevation Gain (m)}}{600.0}$$
|
| 125 |
+
This represents a conservative baseline for an average loaded hiker on established trails.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## 📄 License
|
| 130 |
+
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
|
Resources/roadmap.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Trailhead — Three-Phase Roadmap (v3)
|
| 2 |
+
**Track: Backyard AI · Owner: Person D · ~5 days to June 15**
|
| 3 |
+
|
| 4 |
+
**Affirmed core assumption:** *plan online at basecamp, trek offline.* Online work (route fetch, tile download) is allowed during planning; everything during the trek degrades gracefully to offline.
|
| 5 |
+
|
| 6 |
+
**Keystone — the Position Abstraction.** All contextual features read position from one interface with two sources:
|
| 7 |
+
- **Simulated playback** — steps along the uploaded GPX. Always works; demo with this; doubles as a "preview your trek" feature.
|
| 8 |
+
- **Live `watchPosition`** (optional, on-device, screen-on only).
|
| 9 |
+
|
| 10 |
+
Continuous browser GPS on a multi-day offline trek is unreliable (assisted-GPS cold start wants network; backgrounded tabs suspend). Demo on simulation; offer live as a bonus; never promise live tracking as a guarantee.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Online data sources — PLANNING PHASE ONLY
|
| 15 |
+
External calls happen at basecamp with signal, never on the trail.
|
| 16 |
+
|
| 17 |
+
- **Primary input: upload your own GPX** — it's the hiker's real route; most reliable.
|
| 18 |
+
- **OpenRouteService (ORS)** — free OSM-based routing, `foot-hiking` profile, direct GPX out: `GET https://api.openrouteservice.org/v2/directions/{profile}/gpx`. **Caveats:** needs an API key + has rate limits (online-only); it *routes between coordinates*, it does **not** look up a named trek's established trail; OSM trail coverage in remote/high terrain is patchy. Use it to *generate* a route from waypoints, then validate it. Not authoritative for serious treks.
|
| 19 |
+
- **GPX repositories** (e.g. the track sites you've identified) — fine as a source of pre-made GPX to download at basecamp; then proceed exactly as the upload path.
|
| 20 |
+
|
| 21 |
+
> Design rule: any online fetch produces a local GPX that the rest of the app treats identically to an uploaded one. Nothing downstream depends on connectivity.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Reconsiderations — verdicts
|
| 26 |
+
|
| 27 |
+
| Proposed feature | Verdict | Why |
|
| 28 |
+
|---|---|---|
|
| 29 |
+
| GPX upload | **Keep (primary)** | The real route; most reliable. |
|
| 30 |
+
| Online route fetch (ORS / GPX repos) | **Keep — planning phase only** | Convenience at basecamp. Routing ≠ named-trek lookup; validate output; never called offline. |
|
| 31 |
+
| Proximity checkpoint narration | **Keep — headline AI moment** | Driven by the position abstraction. |
|
| 32 |
+
| Progress: % complete / ETA / pace | **Keep** | Deterministic. |
|
| 33 |
+
| Deviation (off-route) alert | **Keep, advisory** | Distance-to-polyline. |
|
| 34 |
+
| Pace-adjusted ETA | **Keep** | Deterministic; recompute from actual elapsed vs distance. |
|
| 35 |
+
| Risk note (pace + daylight + altitude) | **Adapt + constrain** | Rule-based, conservative, **advisory only**. LLM phrases it; rules decide. |
|
| 36 |
+
| Voice via whisper.cpp | **Keep (bonus)** | Reuse Kisan-Sathi; value with gloves/cold. |
|
| 37 |
+
| Voice trek journaling → SQLite (w/ location) | **Keep (should-have)** | Speak → transcribe → log transcript + position + time. Offline. Feeds the post-trek report. **Raw transcript is the record of truth.** |
|
| 38 |
+
| Audio landmark alerts (TTS) | **Optional, low priority** | Fine if position + TTS work. |
|
| 39 |
+
| Post-trek report (stats + AI story) | **Promote (should-have)** | Safe, delightful, shareable; ideal honest-fit LLM use. |
|
| 40 |
+
| Offline map tiles (MBTiles) | **Keep (should-have)** | Map works with no signal; strengthens Off the Grid. |
|
| 41 |
+
| Storage (SQLite/files on disk) | **Backend owns it** | Model + persistence on disk; browser only does UI + geolocation. No weights in IndexedDB. |
|
| 42 |
+
| Battery-aware low-power mode | **Keep** | Lower `n_ctx` + GPS poll interval. Good Field Notes detail. |
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## PHASE 1 — MVP: "The Route Brief"
|
| 47 |
+
**Goal: the grounded planning loop works end-to-end on the hiker's real GPX. Understandable in 10 seconds.**
|
| 48 |
+
|
| 49 |
+
- [ ] **GPX ingest:** accept an uploaded GPX *or* a basecamp ORS/repo fetch that lands as a local GPX; from here everything is offline-identical.
|
| 50 |
+
- [ ] **GPX parse** (`gpxpy`): tracks/segments/waypoints; concatenate segments; handle missing elevation/timestamps without crashing.
|
| 51 |
+
- [ ] **Route stats (deterministic):** haversine distance; **elevation gain with smoothing** (moving average / min-change threshold — raw sums are badly inflated); min/max; estimated days (Naismith ÷ realistic hours/day, assumption shown).
|
| 52 |
+
- [ ] **Interactive map — use `folium`, not Plotly mapbox.** Render polyline + waypoint markers; embed as HTML; verify on a phone. **Avoid Plotly's `open-street-map` style — it pulls tiles online and goes blank in airplane mode.** (Reserve Plotly for the elevation chart in Phase 3, where there are no tiles.)
|
| 53 |
+
- [ ] **Static checkpoint readout:** first waypoint — cumulative distance + elevation. No AI yet; prove the data pipeline.
|
| 54 |
+
- [ ] **Deploy to HF Space** (Docker + llama-cpp-python GGUF, reuse Kisan-Sathi Dockerfile); open on a phone.
|
| 55 |
+
|
| 56 |
+
**Gate:** the hiker's actual GPX yields correct distance, sane (smoothed) elevation gain, estimated days, and a map. Test on the real file — synthetic GPX hides the edge cases.
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## PHASE 2 — Functional: All Basic Hackathon Criteria Met
|
| 61 |
+
**Goal: full demo-able loop incl. the contextual AI layer; real hiker has used it; all hard constraints met. The 60-second video is recordable from here.**
|
| 62 |
+
|
| 63 |
+
- [ ] **Position abstraction:** simulated GPX playback ("play" advances along the route) + optional live `watchPosition`. Everything below consumes it.
|
| 64 |
+
- [ ] **Proximity checkpoint narration (load-bearing moment):** as position nears a waypoint, the LLM narrates grounded advice — distance/ascent to next point, altitude caution from the loaded guide, water/hazard **only from tagged waypoints/data** (never invented).
|
| 65 |
+
- [ ] **Progress + pace + ETA + deviation:** % complete, pace vs Naismith, ETA to next checkpoint, off-route alert. Deterministic.
|
| 66 |
+
- [ ] **Advisory risk note:** rule-based — daylight remaining vs distance/ascent to next safe camp → "you'll arrive ~late, consider the alternate camp." Conservative, advisory, not a guarantee. LLM phrases; rules decide.
|
| 67 |
+
- [ ] **Gear list:** rule engine (distance + elevation + days + max altitude + season) → LLM narrates.
|
| 68 |
+
- [ ] **First-aid RAG:** MiniLM over the wilderness first-aid guide; retrieve + **cite section**; static no-signal emergency card; "this is a field guide — carry a PLB/satellite messenger."
|
| 69 |
+
- [ ] **Mobile-first UI** (reuse your FastAPI custom frontend): large targets, outdoor-readable, high contrast.
|
| 70 |
+
- [ ] **Dual deploy + offline verify:** Pixel 10 via Termux; **airplane mode, full loop works.**
|
| 71 |
+
- [ ] **Real hiker uses it + record footage** reviewing their actual route.
|
| 72 |
+
- [ ] **README + social post:** track, real hiker + trek, model + param count, honest-fit rationale, run instructions, teammates' HF usernames.
|
| 73 |
+
|
| 74 |
+
**Gate:** record the full 60s demo from this phase — ingest GPX → map + stats → press play → proximity narration + pace/ETA fire → gear list → first-aid query — **offline**. Constraints: Gradio ✓ · HF Space ✓ · ≤32B ✓ · video + social ✓ · real user ✓.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## PHASE 3 — Final Touch & Bonus Quests
|
| 79 |
+
**Goal: polish + badges. None of this blocks the video.**
|
| 80 |
+
|
| 81 |
+
- [ ] **🎨 Off-Brand — trail-computer HUD:** amber/green tactical theme; **elevation profile chart** under the map (Plotly is great here — no tiles, deterministic, looks great on camera).
|
| 82 |
+
- [ ] **🦙 Llama Champion:** document GGUF + llama.cpp; battery-saver mode (lower `n_ctx`, slower GPS poll).
|
| 83 |
+
- [ ] **🔌 Off the Grid:** make the airplane-mode run the hero shot; add **offline map tiles (MBTiles)** so the folium map works with no signal.
|
| 84 |
+
- [ ] **🎙 Voice trek journaling:** speak → whisper.cpp → log transcript + position + timestamp to SQLite. Offline. Raw transcript is the record of truth.
|
| 85 |
+
- [ ] **📓 Post-trek report (should-have):** route + stats + an AI-narrated story built from the journal logs — a shareable artifact that feeds your social post. LLM summarizes/tags; never rewrites the logged facts.
|
| 86 |
+
- [ ] **📓 Field Notes:** the build story — elevation smoothing, simulation vs real GPS, offline tiles, offline edge LLM on a phone (Kisan-Sathi Termux notes carry over).
|
| 87 |
+
- [ ] **Local waypoint tagging (bonus):** hiker marks water/camp/hazard, saved to SQLite — enriches checkpoint advice without inventing anything.
|
| 88 |
+
- [ ] **Live `watchPosition` (bonus):** wire the real-GPS source into the abstraction; screen-on demo only.
|
| 89 |
+
- [ ] **Final checklist:** Space public under the org; loads cleanly; no keys; `.gitignore` excludes weights + uploaded GPX; video + social published before June 15.
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## What to Hand the Vibe-Coding LLM
|
| 94 |
+
1. **The real GPX file** — build/test against it, not synthetic.
|
| 95 |
+
2. **First-aid corpus** (chunked) + **static emergency card** text — model can't originate medical content.
|
| 96 |
+
3. **Altitude/AMS thresholds + gear rules + seasonal averages** — verified data files, not invented.
|
| 97 |
+
4. Reused Kisan-Sathi contracts: `src/llm.py` backend interface, Dockerfile, RAG setup, SQLite layer, whisper.cpp ASR.
|
| 98 |
+
5. The **position abstraction** interface (simulated + live) so every contextual feature is source-agnostic.
|
| 99 |
+
6. **ORS integration note:** planning-phase only; output is a local GPX treated identically to an upload; validate routes.
|
| 100 |
+
7. **Acceptance tests:** distance ±2% on a known GPX; smoothed (not raw) elevation gain; map renders offline (no online tiles); first-aid answers always cite a section; checkpoint advice never names a water source absent from waypoints; risk note advisory-only; journal entries store the raw transcript + position.
|
| 101 |
+
|
| 102 |
+
## Carry-Through Gotchas
|
| 103 |
+
- Smooth elevation before summing (the #1 GPX error).
|
| 104 |
+
- **No online map tiles** — folium + local MBTiles, or the demo dies in airplane mode.
|
| 105 |
+
- ORS is planning-phase routing, not named-trek lookup — validate it.
|
| 106 |
+
- No invented water/hazards — grounded in waypoints/data only.
|
| 107 |
+
- First-aid + risk are high-stakes — ground, cite, advisory framing, static emergency floor.
|
| 108 |
+
- Demo on simulated position; live GPS is a bonus.
|
| 109 |
+
- Test on the real GPX early; verify `folium` on-device.
|
Routes/track_5-14724236830.gpx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<gpx version="1.1" creator="gpx-tracks.org" xmlns="http://www.topografix.com/GPX/1/1">
|
| 3 |
+
<metadata>
|
| 4 |
+
<desc>Generated using OpenStreetMap data</desc>
|
| 5 |
+
<author>OpenStreetMap contributors</author>
|
| 6 |
+
<link href="https://www.openstreetmap.org/copyright">
|
| 7 |
+
<text>OpenStreetMap License</text>
|
| 8 |
+
</link>
|
| 9 |
+
</metadata>
|
| 10 |
+
<trk>
|
| 11 |
+
<trkseg>
|
| 12 |
+
<trkpt lat="46.0734974" lon="11.1717214"></trkpt>
|
| 13 |
+
<trkpt lat="46.0737836" lon="11.1727025"></trkpt>
|
| 14 |
+
<trkpt lat="46.0740203" lon="11.1731432"></trkpt>
|
| 15 |
+
<trkpt lat="46.0740168" lon="11.173328"></trkpt>
|
| 16 |
+
<trkpt lat="46.0737622" lon="11.173827"></trkpt>
|
| 17 |
+
<trkpt lat="46.0734375" lon="11.1738863"></trkpt>
|
| 18 |
+
<trkpt lat="46.0731624" lon="11.1743486"></trkpt>
|
| 19 |
+
<trkpt lat="46.0730316" lon="11.1748916"></trkpt>
|
| 20 |
+
<trkpt lat="46.0728944" lon="11.175002"></trkpt>
|
| 21 |
+
<trkpt lat="46.0728165" lon="11.1750598"></trkpt>
|
| 22 |
+
<trkpt lat="46.0725141" lon="11.1752516"></trkpt>
|
| 23 |
+
<trkpt lat="46.0724562" lon="11.1752923"></trkpt>
|
| 24 |
+
<trkpt lat="46.072404" lon="11.1755511"></trkpt>
|
| 25 |
+
<trkpt lat="46.0723352" lon="11.1757355"></trkpt>
|
| 26 |
+
<trkpt lat="46.072069" lon="11.1762407"></trkpt>
|
| 27 |
+
<trkpt lat="46.0717648" lon="11.1767315"></trkpt>
|
| 28 |
+
<trkpt lat="46.0719912" lon="11.1768785"></trkpt>
|
| 29 |
+
<trkpt lat="46.0720172" lon="11.1772164"></trkpt>
|
| 30 |
+
<trkpt lat="46.0720284" lon="11.1788472"></trkpt>
|
| 31 |
+
<trkpt lat="46.0720172" lon="11.1800435"></trkpt>
|
| 32 |
+
<trkpt lat="46.0721118" lon="11.1805194"></trkpt>
|
| 33 |
+
<trkpt lat="46.0722225" lon="11.1802648"></trkpt>
|
| 34 |
+
<trkpt lat="46.0722136" lon="11.1799061"></trkpt>
|
| 35 |
+
<trkpt lat="46.0722908" lon="11.1798927"></trkpt>
|
| 36 |
+
<trkpt lat="46.0723185" lon="11.1801444"></trkpt>
|
| 37 |
+
<trkpt lat="46.0723926" lon="11.1799338"></trkpt>
|
| 38 |
+
<trkpt lat="46.0726239" lon="11.17992"></trkpt>
|
| 39 |
+
<trkpt lat="46.0726283" lon="11.1807755"></trkpt>
|
| 40 |
+
<trkpt lat="46.0727848" lon="11.1808579"></trkpt>
|
| 41 |
+
<trkpt lat="46.0728563" lon="11.1806364"></trkpt>
|
| 42 |
+
<trkpt lat="46.0729224" lon="11.1806719"></trkpt>
|
| 43 |
+
<trkpt lat="46.0730825" lon="11.1810204"></trkpt>
|
| 44 |
+
<trkpt lat="46.0734128" lon="11.1811378"></trkpt>
|
| 45 |
+
<trkpt lat="46.0738276" lon="11.1810573"></trkpt>
|
| 46 |
+
<trkpt lat="46.0741403" lon="11.1811643"></trkpt>
|
| 47 |
+
<trkpt lat="46.0743251" lon="11.1812466"></trkpt>
|
| 48 |
+
<trkpt lat="46.0745456" lon="11.1815105"></trkpt>
|
| 49 |
+
<trkpt lat="46.0745694" lon="11.1814247"></trkpt>
|
| 50 |
+
<trkpt lat="46.0746536" lon="11.1813296"></trkpt>
|
| 51 |
+
<trkpt lat="46.0747034" lon="11.1812759"></trkpt>
|
| 52 |
+
<trkpt lat="46.0747419" lon="11.1812238"></trkpt>
|
| 53 |
+
<trkpt lat="46.0747674" lon="11.1811396"></trkpt>
|
| 54 |
+
<trkpt lat="46.0748016" lon="11.1810041"></trkpt>
|
| 55 |
+
<trkpt lat="46.0748958" lon="11.180834"></trkpt>
|
| 56 |
+
<trkpt lat="46.0749676" lon="11.1808063"></trkpt>
|
| 57 |
+
<trkpt lat="46.0750275" lon="11.1808043"></trkpt>
|
| 58 |
+
<trkpt lat="46.0750713" lon="11.1808199"></trkpt>
|
| 59 |
+
<trkpt lat="46.0751227" lon="11.1808581"></trkpt>
|
| 60 |
+
<trkpt lat="46.0751844" lon="11.1809787"></trkpt>
|
| 61 |
+
<trkpt lat="46.075376" lon="11.181141"></trkpt>
|
| 62 |
+
<trkpt lat="46.0754095" lon="11.1811423"></trkpt>
|
| 63 |
+
<trkpt lat="46.0753928" lon="11.1810619"></trkpt>
|
| 64 |
+
<trkpt lat="46.0754012" lon="11.1808969"></trkpt>
|
| 65 |
+
<trkpt lat="46.0754328" lon="11.1808433"></trkpt>
|
| 66 |
+
<trkpt lat="46.0754784" lon="11.1808151"></trkpt>
|
| 67 |
+
<trkpt lat="46.075496" lon="11.1807588"></trkpt>
|
| 68 |
+
<trkpt lat="46.0755334" lon="11.1807545"></trkpt>
|
| 69 |
+
<trkpt lat="46.0756347" lon="11.180862"></trkpt>
|
| 70 |
+
<trkpt lat="46.0756635" lon="11.1809009"></trkpt>
|
| 71 |
+
<trkpt lat="46.0757185" lon="11.180853"></trkpt>
|
| 72 |
+
<trkpt lat="46.0757277" lon="11.1805978"></trkpt>
|
| 73 |
+
<trkpt lat="46.0757454" lon="11.1805563"></trkpt>
|
| 74 |
+
<trkpt lat="46.0757621" lon="11.1805402"></trkpt>
|
| 75 |
+
<trkpt lat="46.0758459" lon="11.1805174"></trkpt>
|
| 76 |
+
<trkpt lat="46.0758589" lon="11.1804436"></trkpt>
|
| 77 |
+
<trkpt lat="46.0758686" lon="11.1803745"></trkpt>
|
| 78 |
+
<trkpt lat="46.0759105" lon="11.1803893"></trkpt>
|
| 79 |
+
<trkpt lat="46.0759901" lon="11.1806227"></trkpt>
|
| 80 |
+
<trkpt lat="46.0760556" lon="11.1807366"></trkpt>
|
| 81 |
+
<trkpt lat="46.0761226" lon="11.1808131"></trkpt>
|
| 82 |
+
<trkpt lat="46.0762064" lon="11.1810364"></trkpt>
|
| 83 |
+
<trkpt lat="46.0763157" lon="11.1811725"></trkpt>
|
| 84 |
+
<trkpt lat="46.0763519" lon="11.1812443"></trkpt>
|
| 85 |
+
<trkpt lat="46.0763864" lon="11.1813696"></trkpt>
|
| 86 |
+
<trkpt lat="46.0764278" lon="11.18155"></trkpt>
|
| 87 |
+
<trkpt lat="46.0764552" lon="11.1816278"></trkpt>
|
| 88 |
+
<trkpt lat="46.0764687" lon="11.1817311"></trkpt>
|
| 89 |
+
<trkpt lat="46.0764668" lon="11.1818739"></trkpt>
|
| 90 |
+
<trkpt lat="46.0764854" lon="11.1820818"></trkpt>
|
| 91 |
+
<trkpt lat="46.0765036" lon="11.1820791"></trkpt>
|
| 92 |
+
<trkpt lat="46.0765227" lon="11.1820858"></trkpt>
|
| 93 |
+
<trkpt lat="46.0765534" lon="11.1821227"></trkpt>
|
| 94 |
+
<trkpt lat="46.076571" lon="11.1821428"></trkpt>
|
| 95 |
+
<trkpt lat="46.0766593" lon="11.1821455"></trkpt>
|
| 96 |
+
<trkpt lat="46.0767188" lon="11.1821541"></trkpt>
|
| 97 |
+
<trkpt lat="46.0767724" lon="11.182137"></trkpt>
|
| 98 |
+
<trkpt lat="46.076832" lon="11.182094"></trkpt>
|
| 99 |
+
<trkpt lat="46.0768736" lon="11.182094"></trkpt>
|
| 100 |
+
<trkpt lat="46.076926" lon="11.1820234"></trkpt>
|
| 101 |
+
<trkpt lat="46.0770264" lon="11.181951"></trkpt>
|
| 102 |
+
<trkpt lat="46.0770761" lon="11.1818795"></trkpt>
|
| 103 |
+
<trkpt lat="46.0771146" lon="11.1818551"></trkpt>
|
| 104 |
+
<trkpt lat="46.0771341" lon="11.181782"></trkpt>
|
| 105 |
+
<trkpt lat="46.0772011" lon="11.181726"></trkpt>
|
| 106 |
+
<trkpt lat="46.0772602" lon="11.1816238"></trkpt>
|
| 107 |
+
<trkpt lat="46.0772797" lon="11.1815286"></trkpt>
|
| 108 |
+
<trkpt lat="46.0773192" lon="11.1814008"></trkpt>
|
| 109 |
+
<trkpt lat="46.0773448" lon="11.1813569"></trkpt>
|
| 110 |
+
<trkpt lat="46.0773667" lon="11.1813566"></trkpt>
|
| 111 |
+
<trkpt lat="46.0774069" lon="11.1814213"></trkpt>
|
| 112 |
+
<trkpt lat="46.0774299" lon="11.1815192"></trkpt>
|
| 113 |
+
<trkpt lat="46.0774686" lon="11.1816053"></trkpt>
|
| 114 |
+
<trkpt lat="46.0775411" lon="11.1816791"></trkpt>
|
| 115 |
+
<trkpt lat="46.0775388" lon="11.181726"></trkpt>
|
| 116 |
+
<trkpt lat="46.0775304" lon="11.1817693"></trkpt>
|
| 117 |
+
<trkpt lat="46.0775539" lon="11.1818146"></trkpt>
|
| 118 |
+
<trkpt lat="46.0775432" lon="11.1819547"></trkpt>
|
| 119 |
+
<trkpt lat="46.0775551" lon="11.1820271"></trkpt>
|
| 120 |
+
<trkpt lat="46.0775541" lon="11.1820861"></trkpt>
|
| 121 |
+
<trkpt lat="46.0775679" lon="11.1820969"></trkpt>
|
| 122 |
+
<trkpt lat="46.0775997" lon="11.1820389"></trkpt>
|
| 123 |
+
<trkpt lat="46.0776139" lon="11.1820727"></trkpt>
|
| 124 |
+
<trkpt lat="46.0776579" lon="11.1820741"></trkpt>
|
| 125 |
+
<trkpt lat="46.0776969" lon="11.1820305"></trkpt>
|
| 126 |
+
<trkpt lat="46.0777239" lon="11.1819842"></trkpt>
|
| 127 |
+
<trkpt lat="46.0778093" lon="11.18194"></trkpt>
|
| 128 |
+
<trkpt lat="46.0778486" lon="11.1818819"></trkpt>
|
| 129 |
+
<trkpt lat="46.0778639" lon="11.1818494"></trkpt>
|
| 130 |
+
<trkpt lat="46.0778981" lon="11.181775"></trkpt>
|
| 131 |
+
<trkpt lat="46.0779265" lon="11.181716"></trkpt>
|
| 132 |
+
<trkpt lat="46.077946" lon="11.1816342"></trkpt>
|
| 133 |
+
<trkpt lat="46.0779177" lon="11.1815296"></trkpt>
|
| 134 |
+
<trkpt lat="46.0778335" lon="11.1814002"></trkpt>
|
| 135 |
+
<trkpt lat="46.0778267" lon="11.1813629"></trkpt>
|
| 136 |
+
<trkpt lat="46.0778358" lon="11.1813422"></trkpt>
|
| 137 |
+
<trkpt lat="46.0778809" lon="11.1813593"></trkpt>
|
| 138 |
+
<trkpt lat="46.0778616" lon="11.181319"></trkpt>
|
| 139 |
+
<trkpt lat="46.0778525" lon="11.1812818"></trkpt>
|
| 140 |
+
<trkpt lat="46.0778458" lon="11.1812563"></trkpt>
|
| 141 |
+
<trkpt lat="46.0778409" lon="11.1812158"></trkpt>
|
| 142 |
+
<trkpt lat="46.077839" lon="11.1811453"></trkpt>
|
| 143 |
+
<trkpt lat="46.0778256" lon="11.181094"></trkpt>
|
| 144 |
+
<trkpt lat="46.0778302" lon="11.1810444"></trkpt>
|
| 145 |
+
<trkpt lat="46.0778497" lon="11.1810324"></trkpt>
|
| 146 |
+
<trkpt lat="46.0778711" lon="11.1810431"></trkpt>
|
| 147 |
+
<trkpt lat="46.0779009" lon="11.1810753"></trkpt>
|
| 148 |
+
<trkpt lat="46.077927" lon="11.1811544"></trkpt>
|
| 149 |
+
<trkpt lat="46.0780051" lon="11.1811987"></trkpt>
|
| 150 |
+
<trkpt lat="46.0780256" lon="11.1812268"></trkpt>
|
| 151 |
+
<trkpt lat="46.0780488" lon="11.1813167"></trkpt>
|
| 152 |
+
<trkpt lat="46.0780926" lon="11.1813314"></trkpt>
|
| 153 |
+
<trkpt lat="46.0781961" lon="11.1815243"></trkpt>
|
| 154 |
+
<trkpt lat="46.0782544" lon="11.1816743"></trkpt>
|
| 155 |
+
<trkpt lat="46.0782983" lon="11.1817455"></trkpt>
|
| 156 |
+
<trkpt lat="46.0783595" lon="11.1818089"></trkpt>
|
| 157 |
+
<trkpt lat="46.0783972" lon="11.1818846"></trkpt>
|
| 158 |
+
<trkpt lat="46.078414" lon="11.1819524"></trkpt>
|
| 159 |
+
<trkpt lat="46.0785307" lon="11.1819718"></trkpt>
|
| 160 |
+
<trkpt lat="46.0785335" lon="11.1819282"></trkpt>
|
| 161 |
+
<trkpt lat="46.0785065" lon="11.181827"></trkpt>
|
| 162 |
+
<trkpt lat="46.0784665" lon="11.1816969"></trkpt>
|
| 163 |
+
<trkpt lat="46.0784665" lon="11.1816385"></trkpt>
|
| 164 |
+
<trkpt lat="46.0784833" lon="11.1816533"></trkpt>
|
| 165 |
+
<trkpt lat="46.0785372" lon="11.1817002"></trkpt>
|
| 166 |
+
<trkpt lat="46.0785489" lon="11.1816848"></trkpt>
|
| 167 |
+
<trkpt lat="46.0785493" lon="11.1816446"></trkpt>
|
| 168 |
+
<trkpt lat="46.0785368" lon="11.1815608"></trkpt>
|
| 169 |
+
<trkpt lat="46.0785516" lon="11.1814865"></trkpt>
|
| 170 |
+
<trkpt lat="46.0785824" lon="11.1813216"></trkpt>
|
| 171 |
+
<trkpt lat="46.0785586" lon="11.1812014"></trkpt>
|
| 172 |
+
<trkpt lat="46.0785149" lon="11.1811034"></trkpt>
|
| 173 |
+
<trkpt lat="46.0785182" lon="11.1810022"></trkpt>
|
| 174 |
+
<trkpt lat="46.0785517" lon="11.1809231"></trkpt>
|
| 175 |
+
<trkpt lat="46.0785521" lon="11.1808567"></trkpt>
|
| 176 |
+
<trkpt lat="46.0785265" lon="11.1807474"></trkpt>
|
| 177 |
+
<trkpt lat="46.0785424" lon="11.180689"></trkpt>
|
| 178 |
+
<trkpt lat="46.0785879" lon="11.1806582"></trkpt>
|
| 179 |
+
<trkpt lat="46.0786135" lon="11.1805616"></trkpt>
|
| 180 |
+
<trkpt lat="46.0786238" lon="11.1804657"></trkpt>
|
| 181 |
+
<trkpt lat="46.0785847" lon="11.1803544"></trkpt>
|
| 182 |
+
<trkpt lat="46.0785461" lon="11.1802988"></trkpt>
|
| 183 |
+
<trkpt lat="46.0785382" lon="11.1801915"></trkpt>
|
| 184 |
+
<trkpt lat="46.0785558" lon="11.1800064"></trkpt>
|
| 185 |
+
<trkpt lat="46.0785679" lon="11.1798093"></trkpt>
|
| 186 |
+
<trkpt lat="46.078553" lon="11.1796369"></trkpt>
|
| 187 |
+
<trkpt lat="46.078567" lon="11.1795799"></trkpt>
|
| 188 |
+
<trkpt lat="46.0785819" lon="11.1793613"></trkpt>
|
| 189 |
+
<trkpt lat="46.078587" lon="11.1793003"></trkpt>
|
| 190 |
+
<trkpt lat="46.0785847" lon="11.179189"></trkpt>
|
| 191 |
+
<trkpt lat="46.07862" lon="11.1790321"></trkpt>
|
| 192 |
+
<trkpt lat="46.0786242" lon="11.1787511"></trkpt>
|
| 193 |
+
<trkpt lat="46.0786196" lon="11.1786492"></trkpt>
|
| 194 |
+
<trkpt lat="46.0786447" lon="11.1785379"></trkpt>
|
| 195 |
+
<trkpt lat="46.07868" lon="11.1783374"></trkpt>
|
| 196 |
+
<trkpt lat="46.0786963" lon="11.1781503"></trkpt>
|
| 197 |
+
<trkpt lat="46.0787963" lon="11.1779109"></trkpt>
|
| 198 |
+
<trkpt lat="46.0788196" lon="11.1778526"></trkpt>
|
| 199 |
+
<trkpt lat="46.0788233" lon="11.1777218"></trkpt>
|
| 200 |
+
<trkpt lat="46.0790385" lon="11.1777575"></trkpt>
|
| 201 |
+
<trkpt lat="46.0788233" lon="11.1777218"></trkpt>
|
| 202 |
+
<trkpt lat="46.0788061" lon="11.1776608"></trkpt>
|
| 203 |
+
<trkpt lat="46.0787759" lon="11.1776025"></trkpt>
|
| 204 |
+
<trkpt lat="46.0787563" lon="11.1774925"></trkpt>
|
| 205 |
+
<trkpt lat="46.0787438" lon="11.1773356"></trkpt>
|
| 206 |
+
<trkpt lat="46.0787307" lon="11.1772605"></trkpt>
|
| 207 |
+
<trkpt lat="46.0787326" lon="11.1771244"></trkpt>
|
| 208 |
+
<trkpt lat="46.0787084" lon="11.177007"></trkpt>
|
| 209 |
+
<trkpt lat="46.0787052" lon="11.1769152"></trkpt>
|
| 210 |
+
<trkpt lat="46.0787089" lon="11.1768146"></trkpt>
|
| 211 |
+
<trkpt lat="46.0787228" lon="11.1767039"></trkpt>
|
| 212 |
+
<trkpt lat="46.0787275" lon="11.1765967"></trkpt>
|
| 213 |
+
<trkpt lat="46.07872" lon="11.1764585"></trkpt>
|
| 214 |
+
<trkpt lat="46.0787028" lon="11.1763646"></trkpt>
|
| 215 |
+
<trkpt lat="46.0786931" lon="11.1763016"></trkpt>
|
| 216 |
+
<trkpt lat="46.078601" lon="11.1760743"></trkpt>
|
| 217 |
+
<trkpt lat="46.0785712" lon="11.1759757"></trkpt>
|
| 218 |
+
<trkpt lat="46.0785512" lon="11.1758805"></trkpt>
|
| 219 |
+
<trkpt lat="46.0785484" lon="11.1758215"></trkpt>
|
| 220 |
+
<trkpt lat="46.07854" lon="11.1757893"></trkpt>
|
| 221 |
+
<trkpt lat="46.0785056" lon="11.1757209"></trkpt>
|
| 222 |
+
<trkpt lat="46.0784298" lon="11.1755888"></trkpt>
|
| 223 |
+
<trkpt lat="46.078254" lon="11.1753266"></trkpt>
|
| 224 |
+
<trkpt lat="46.0781302" lon="11.175167"></trkpt>
|
| 225 |
+
<trkpt lat="46.0780882" lon="11.1751074"></trkpt>
|
| 226 |
+
<trkpt lat="46.0780474" lon="11.1750403"></trkpt>
|
| 227 |
+
<trkpt lat="46.0779777" lon="11.1749028"></trkpt>
|
| 228 |
+
<trkpt lat="46.0779811" lon="11.1748328"></trkpt>
|
| 229 |
+
<trkpt lat="46.0779763" lon="11.1748002"></trkpt>
|
| 230 |
+
<trkpt lat="46.077947" lon="11.1747473"></trkpt>
|
| 231 |
+
<trkpt lat="46.0779046" lon="11.1746809"></trkpt>
|
| 232 |
+
<trkpt lat="46.0777967" lon="11.1744864"></trkpt>
|
| 233 |
+
<trkpt lat="46.0777428" lon="11.1744308"></trkpt>
|
| 234 |
+
<trkpt lat="46.0776577" lon="11.1743088"></trkpt>
|
| 235 |
+
<trkpt lat="46.0776162" lon="11.1743094"></trkpt>
|
| 236 |
+
<trkpt lat="46.0775548" lon="11.1743148"></trkpt>
|
| 237 |
+
<trkpt lat="46.0774125" lon="11.1743456"></trkpt>
|
| 238 |
+
<trkpt lat="46.0772981" lon="11.1743966"></trkpt>
|
| 239 |
+
<trkpt lat="46.0769855" lon="11.1745763"></trkpt>
|
| 240 |
+
<trkpt lat="46.0768152" lon="11.1746366"></trkpt>
|
| 241 |
+
<trkpt lat="46.0767036" lon="11.1746822"></trkpt>
|
| 242 |
+
<trkpt lat="46.076591" lon="11.1747238"></trkpt>
|
| 243 |
+
<trkpt lat="46.0764757" lon="11.1747064"></trkpt>
|
| 244 |
+
<trkpt lat="46.0764171" lon="11.1747345"></trkpt>
|
| 245 |
+
<trkpt lat="46.0763036" lon="11.174752"></trkpt>
|
| 246 |
+
<trkpt lat="46.076218" lon="11.1748029"></trkpt>
|
| 247 |
+
<trkpt lat="46.0760784" lon="11.1748512"></trkpt>
|
| 248 |
+
<trkpt lat="46.0760031" lon="11.1748955"></trkpt>
|
| 249 |
+
<trkpt lat="46.0758477" lon="11.174929"></trkpt>
|
| 250 |
+
<trkpt lat="46.0757668" lon="11.1749732"></trkpt>
|
| 251 |
+
<trkpt lat="46.0755068" lon="11.1749063"></trkpt>
|
| 252 |
+
<trkpt lat="46.0751974" lon="11.1749093"></trkpt>
|
| 253 |
+
<trkpt lat="46.0748717" lon="11.1743811"></trkpt>
|
| 254 |
+
<trkpt lat="46.0746653" lon="11.1743811"></trkpt>
|
| 255 |
+
<trkpt lat="46.0746002" lon="11.1743811"></trkpt>
|
| 256 |
+
<trkpt lat="46.0742637" lon="11.1743798"></trkpt>
|
| 257 |
+
<trkpt lat="46.07403" lon="11.174376"></trkpt>
|
| 258 |
+
<trkpt lat="46.0739346" lon="11.1741839"></trkpt>
|
| 259 |
+
<trkpt lat="46.0737622" lon="11.173827"></trkpt>
|
| 260 |
+
<trkpt lat="46.0740168" lon="11.173328"></trkpt>
|
| 261 |
+
<trkpt lat="46.0740203" lon="11.1731432"></trkpt>
|
| 262 |
+
<trkpt lat="46.0737836" lon="11.1727025"></trkpt>
|
| 263 |
+
<trkpt lat="46.0734974" lon="11.1717214"></trkpt>
|
| 264 |
+
</trkseg>
|
| 265 |
+
</trk>
|
| 266 |
+
</gpx>
|
app.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
import requests
|
| 4 |
+
import gradio as gr
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import folium
|
| 7 |
+
from src.gpx_parser import parse_gpx_file
|
| 8 |
+
import src.llm as llm
|
| 9 |
+
|
| 10 |
+
# Initialize cache and temp folders
|
| 11 |
+
os.makedirs("./temp", exist_ok=True)
|
| 12 |
+
|
| 13 |
+
# Preloaded route path
|
| 14 |
+
PRELOADED_ROUTE_PATH = r"C:\Users\skushwaha\Documents\hckthn\TrailHead\Routes\track_5-14724236830.gpx"
|
| 15 |
+
|
| 16 |
+
def generate_folium_map(points, checkpoints):
|
| 17 |
+
"""
|
| 18 |
+
Generate interactive folium map.
|
| 19 |
+
"""
|
| 20 |
+
if not points:
|
| 21 |
+
# Default centered map
|
| 22 |
+
m = folium.Map(location=[46.0734974, 11.1717214], zoom_start=13)
|
| 23 |
+
return m._repr_html_()
|
| 24 |
+
|
| 25 |
+
# Center map on the middle point of the track
|
| 26 |
+
mid_idx = len(points) // 2
|
| 27 |
+
start_lat = points[mid_idx]["lat"]
|
| 28 |
+
start_lon = points[mid_idx]["lon"]
|
| 29 |
+
|
| 30 |
+
m = folium.Map(location=[start_lat, start_lon], zoom_start=14)
|
| 31 |
+
|
| 32 |
+
# Draw track polyline
|
| 33 |
+
locations = [(p["lat"], p["lon"]) for p in points]
|
| 34 |
+
folium.PolyLine(locations, color="#f59e0b", weight=5, opacity=0.85).add_to(m)
|
| 35 |
+
|
| 36 |
+
# Draw checkpoints
|
| 37 |
+
for cp in checkpoints:
|
| 38 |
+
name = cp["name"]
|
| 39 |
+
lat = cp["lat"]
|
| 40 |
+
lon = cp["lon"]
|
| 41 |
+
ele = cp["ele"]
|
| 42 |
+
dist = cp["cum_dist"]
|
| 43 |
+
|
| 44 |
+
# Color code markers
|
| 45 |
+
if name == "Start":
|
| 46 |
+
color = "green"
|
| 47 |
+
icon = "play"
|
| 48 |
+
elif name == "End":
|
| 49 |
+
color = "red"
|
| 50 |
+
icon = "flag"
|
| 51 |
+
else:
|
| 52 |
+
color = "cadetblue"
|
| 53 |
+
icon = "info-sign"
|
| 54 |
+
|
| 55 |
+
popup_text = f"""
|
| 56 |
+
<div style="font-family: 'Outfit', sans-serif; font-size: 11px;">
|
| 57 |
+
<b>{name}</b><br>
|
| 58 |
+
Distance: {dist:.2f} km<br>
|
| 59 |
+
Elevation: {ele:.1f} m
|
| 60 |
+
</div>
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
folium.Marker(
|
| 64 |
+
location=[lat, lon],
|
| 65 |
+
popup=popup_text,
|
| 66 |
+
tooltip=name,
|
| 67 |
+
icon=folium.Icon(color=color, icon=icon)
|
| 68 |
+
).add_to(m)
|
| 69 |
+
|
| 70 |
+
return m._repr_html_()
|
| 71 |
+
|
| 72 |
+
def get_map_iframe(map_html):
|
| 73 |
+
"""
|
| 74 |
+
Helper to bundle raw HTML into a secure, sandboxed base64 data URI iframe.
|
| 75 |
+
"""
|
| 76 |
+
b64_html = base64.b64encode(map_html.encode('utf-8')).decode('utf-8')
|
| 77 |
+
iframe_src = f"data:text/html;base64,{b64_html}"
|
| 78 |
+
return f'<iframe src="{iframe_src}" width="100%" height="520px" style="border:1px solid rgba(245,158,11,0.2); border-radius: 12px;"></iframe>'
|
| 79 |
+
|
| 80 |
+
def fetch_ors_route(start_coords, end_coords, profile, api_key):
|
| 81 |
+
"""
|
| 82 |
+
Fetches hiking route between coordinates using OpenRouteService.
|
| 83 |
+
Falls back to a straight-line GPX segment if API key is empty or request fails.
|
| 84 |
+
"""
|
| 85 |
+
try:
|
| 86 |
+
start_lat, start_lon = map(float, start_coords.split(","))
|
| 87 |
+
end_lat, end_lon = map(float, end_coords.split(","))
|
| 88 |
+
except Exception:
|
| 89 |
+
raise gr.Error("Invalid coordinate format. Ensure format is 'lat, lon'.")
|
| 90 |
+
|
| 91 |
+
temp_dir = "./temp"
|
| 92 |
+
os.makedirs(temp_dir, exist_ok=True)
|
| 93 |
+
file_path = os.path.join(temp_dir, "ors_fetched_route.gpx")
|
| 94 |
+
|
| 95 |
+
if not api_key:
|
| 96 |
+
# Create a mock straight-line GPX (3 coordinates: start, mid, end) for demo purposes
|
| 97 |
+
mid_lat = (start_lat + end_lat) / 2.0
|
| 98 |
+
mid_lon = (start_lon + end_lon) / 2.0
|
| 99 |
+
gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 100 |
+
<gpx version="1.1" creator="Trailhead Mock" xmlns="http://www.topografix.com/GPX/1/1">
|
| 101 |
+
<trk>
|
| 102 |
+
<trkseg>
|
| 103 |
+
<trkpt lat="{start_lat}" lon="{start_lon}"></trkpt>
|
| 104 |
+
<trkpt lat="{mid_lat}" lon="{mid_lon}"></trkpt>
|
| 105 |
+
<trkpt lat="{end_lat}" lon="{end_lon}"></trkpt>
|
| 106 |
+
</trkseg>
|
| 107 |
+
</trk>
|
| 108 |
+
</gpx>"""
|
| 109 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 110 |
+
f.write(gpx_content)
|
| 111 |
+
gr.Warning("No ORS API Key provided. Generated a mock direct route segment.")
|
| 112 |
+
return file_path
|
| 113 |
+
|
| 114 |
+
url = f"https://api.openrouteservice.org/v2/directions/{profile}/gpx"
|
| 115 |
+
headers = {
|
| 116 |
+
'Accept': 'application/gpx+xml',
|
| 117 |
+
'Authorization': api_key,
|
| 118 |
+
'Content-Type': 'application/json'
|
| 119 |
+
}
|
| 120 |
+
body = {
|
| 121 |
+
"coordinates": [[start_lon, start_lat], [end_lon, end_lat]]
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
response = requests.post(url, json=body, headers=headers, timeout=12)
|
| 126 |
+
if response.status_code == 200:
|
| 127 |
+
with open(file_path, "wb") as f:
|
| 128 |
+
f.write(response.content)
|
| 129 |
+
gr.Info("Successfully fetched route from OpenRouteService!")
|
| 130 |
+
return file_path
|
| 131 |
+
else:
|
| 132 |
+
raise ValueError(f"ORS returned status {response.status_code}")
|
| 133 |
+
except Exception as e:
|
| 134 |
+
# Fallback straight-line
|
| 135 |
+
mid_lat = (start_lat + end_lat) / 2.0
|
| 136 |
+
mid_lon = (start_lon + end_lon) / 2.0
|
| 137 |
+
gpx_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 138 |
+
<gpx version="1.1" creator="Trailhead Fallback" xmlns="http://www.topografix.com/GPX/1/1">
|
| 139 |
+
<trk>
|
| 140 |
+
<trkseg>
|
| 141 |
+
<trkpt lat="{start_lat}" lon="{start_lon}"></trkpt>
|
| 142 |
+
<trkpt lat="{mid_lat}" lon="{mid_lon}"></trkpt>
|
| 143 |
+
<trkpt lat="{end_lat}" lon="{end_lon}"></trkpt>
|
| 144 |
+
</trkseg>
|
| 145 |
+
</trk>
|
| 146 |
+
</gpx>"""
|
| 147 |
+
with open(file_path, "w", encoding="utf-8") as f:
|
| 148 |
+
f.write(gpx_content)
|
| 149 |
+
gr.Warning(f"ORS Fetch failed ({e}). Generated straight-line fallback route.")
|
| 150 |
+
return file_path
|
| 151 |
+
|
| 152 |
+
def handle_route_update(preloaded_sel, uploaded_file, start_coords, end_coords, profile, api_key, request: gr.Request = None):
|
| 153 |
+
# Determine which file to parse
|
| 154 |
+
file_path = PRELOADED_ROUTE_PATH
|
| 155 |
+
|
| 156 |
+
# Check trigger source
|
| 157 |
+
# We can inspect input priority or simply prioritize upload -> fetch -> preloaded
|
| 158 |
+
if uploaded_file is not None:
|
| 159 |
+
file_path = uploaded_file.name
|
| 160 |
+
elif start_coords and end_coords:
|
| 161 |
+
# If coordinates are changed and user hits the trigger, we can fetch
|
| 162 |
+
# However, to avoid automatic fetching on load, we only fetch when this is called via button click.
|
| 163 |
+
# Since this function handles all triggers, we'll let app buttons set a temporary flag.
|
| 164 |
+
pass
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
data = parse_gpx_file(file_path)
|
| 168 |
+
except Exception as e:
|
| 169 |
+
return (
|
| 170 |
+
f"<div style='color:#ef4444; padding:15px; border:1px solid #ef4444; border-radius:8px;'>Error loading GPX: {e}</div>",
|
| 171 |
+
f"<iframe srcdoc='<h3 style=\"color:red;\">Error rendering map: {e}</h3>' width='100%' height='520px'></iframe>",
|
| 172 |
+
[]
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Generate Stats HUD
|
| 176 |
+
stats_html = f"""
|
| 177 |
+
<div style='display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin-bottom: 20px;'>
|
| 178 |
+
<div class='hud-stat-box'>
|
| 179 |
+
<div class='hud-stat-val'>{data['total_distance_km']:.2f}</div>
|
| 180 |
+
<div class='hud-stat-lbl'>Distance (km)</div>
|
| 181 |
+
</div>
|
| 182 |
+
<div class='hud-stat-box'>
|
| 183 |
+
<div class='hud-stat-val'>{data['elevation_gain_m']:.1f}</div>
|
| 184 |
+
<div class='hud-stat-lbl'>Elevation Gain (m)</div>
|
| 185 |
+
</div>
|
| 186 |
+
<div class='hud-stat-box'>
|
| 187 |
+
<div class='hud-stat-val'>{data['elevation_loss_m']:.1f}</div>
|
| 188 |
+
<div class='hud-stat-lbl'>Elevation Loss (m)</div>
|
| 189 |
+
</div>
|
| 190 |
+
<div class='hud-stat-box'>
|
| 191 |
+
<div class='hud-stat-val'>{data['min_elevation_m']:.0f} - {data['max_elevation_m']:.0f}</div>
|
| 192 |
+
<div class='hud-stat-lbl'>Altitude Range (m)</div>
|
| 193 |
+
</div>
|
| 194 |
+
<div class='hud-stat-box'>
|
| 195 |
+
<div class='hud-stat-val'>{data['estimated_days']:.1f}</div>
|
| 196 |
+
<div class='hud-stat-lbl'>Est. Hiking Days</div>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
"""
|
| 200 |
+
|
| 201 |
+
# Generate Folium Map
|
| 202 |
+
map_html = generate_folium_map(data["points"], data["checkpoints"])
|
| 203 |
+
map_iframe = get_map_iframe(map_html)
|
| 204 |
+
|
| 205 |
+
# Format Checkpoint List for Dataframe
|
| 206 |
+
checkpoint_table_data = []
|
| 207 |
+
for cp in data["checkpoints"]:
|
| 208 |
+
checkpoint_table_data.append([
|
| 209 |
+
cp["name"],
|
| 210 |
+
f"{cp['lat']:.5f}, {cp['lon']:.5f}",
|
| 211 |
+
f"{cp['cum_dist']:.2f} km",
|
| 212 |
+
f"{cp['ele']:.1f} m"
|
| 213 |
+
])
|
| 214 |
+
|
| 215 |
+
return stats_html, map_iframe, checkpoint_table_data
|
| 216 |
+
|
| 217 |
+
def handle_ors_fetch_click(start_coords, end_coords, profile, api_key):
|
| 218 |
+
"""Button click handler for fetching online routes."""
|
| 219 |
+
try:
|
| 220 |
+
route_file = fetch_ors_route(start_coords, end_coords, profile, api_key)
|
| 221 |
+
return handle_route_update(None, None, start_coords, end_coords, profile, api_key)
|
| 222 |
+
except Exception as e:
|
| 223 |
+
return (
|
| 224 |
+
f"<div style='color:#ef4444; padding:15px; border:1px solid #ef4444; border-radius:8px;'>ORS Routing Error: {e}</div>",
|
| 225 |
+
gr.update(),
|
| 226 |
+
gr.update()
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# --- Gradio Chatbot Integration ---
|
| 230 |
+
def respond(message, history):
|
| 231 |
+
# Enforce streaming for better UX
|
| 232 |
+
response_accumulator = ""
|
| 233 |
+
system_prompt = (
|
| 234 |
+
"You are Trailhead Guide, a helpful and knowledgeable wilderness trekking expert.\n"
|
| 235 |
+
"You help hikers prepare for routes, review gear checklists, and learn wilderness first-aid.\n"
|
| 236 |
+
"Be professional, concise, and safety-oriented. Emphasize offline preparedness."
|
| 237 |
+
)
|
| 238 |
+
for token in llm.generate(message, system=system_prompt, history=history, stream=True):
|
| 239 |
+
response_accumulator += token
|
| 240 |
+
yield response_accumulator
|
| 241 |
+
|
| 242 |
+
# --- Gradio Blocks UI ---
|
| 243 |
+
with gr.Blocks(css="assets/custom.css", title="Trailhead — Tactical Trail Computer") as demo:
|
| 244 |
+
gr.HTML("""
|
| 245 |
+
<div style='text-align: center; padding: 10px 0;'>
|
| 246 |
+
<h1>🌲 Trailhead 🌲</h1>
|
| 247 |
+
<p style='color: #f59e0b; font-family: "Share Tech Mono", monospace; letter-spacing: 0.1em; text-transform: uppercase; font-size: 1rem; margin-top: -5px;'>
|
| 248 |
+
Off-the-Grid Trail Computer & Route Planner
|
| 249 |
+
</p>
|
| 250 |
+
</div>
|
| 251 |
+
""")
|
| 252 |
+
|
| 253 |
+
with gr.Tabs():
|
| 254 |
+
with gr.TabItem("🧭 Trek Planner & HUD"):
|
| 255 |
+
with gr.Row():
|
| 256 |
+
with gr.Column(scale=1):
|
| 257 |
+
gr.Markdown("### 📂 Route Ingestion")
|
| 258 |
+
|
| 259 |
+
preloaded_route = gr.Dropdown(
|
| 260 |
+
choices=["Preloaded Route: Trento Track"],
|
| 261 |
+
value="Preloaded Route: Trento Track",
|
| 262 |
+
label="Preloaded Routes (Trento, Italy)"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
upload_file = gr.File(
|
| 266 |
+
file_types=[".gpx"],
|
| 267 |
+
label="Upload GPX Route File"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
with gr.Accordion("🔌 Fetch Online Route (Basecamp Mode)", open=False):
|
| 271 |
+
gr.Markdown("Generate route paths between waypoints using OpenRouteService.")
|
| 272 |
+
start_pt = gr.Textbox(
|
| 273 |
+
value="46.0734974, 11.1717214",
|
| 274 |
+
label="Start Coordinates (Lat, Lon)"
|
| 275 |
+
)
|
| 276 |
+
end_pt = gr.Textbox(
|
| 277 |
+
value="46.0788233, 11.1777218",
|
| 278 |
+
label="End Coordinates (Lat, Lon)"
|
| 279 |
+
)
|
| 280 |
+
ors_profile = gr.Dropdown(
|
| 281 |
+
choices=["foot-hiking", "foot-walking", "cycling-mountain"],
|
| 282 |
+
value="foot-hiking",
|
| 283 |
+
label="Profile"
|
| 284 |
+
)
|
| 285 |
+
ors_api_key = gr.Textbox(
|
| 286 |
+
type="password",
|
| 287 |
+
label="OpenRouteService API Key (Optional)",
|
| 288 |
+
placeholder="Paste your API key here..."
|
| 289 |
+
)
|
| 290 |
+
fetch_route_btn = gr.Button("Fetch & Load Route", variant="secondary")
|
| 291 |
+
|
| 292 |
+
with gr.Column(scale=2):
|
| 293 |
+
# Stats display
|
| 294 |
+
stats_display = gr.HTML()
|
| 295 |
+
|
| 296 |
+
# Interactive Map display
|
| 297 |
+
map_display = gr.HTML()
|
| 298 |
+
|
| 299 |
+
with gr.Accordion("📋 Route Checkpoint Briefing", open=True):
|
| 300 |
+
checkpoint_table = gr.DataFrame(
|
| 301 |
+
headers=["Checkpoint", "Coordinates", "Cumulative Distance", "Altitude"],
|
| 302 |
+
datatype=["str", "str", "str", "str"],
|
| 303 |
+
column_count=(4, "fixed")
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
with gr.TabItem("💬 Wilderness Guide AI"):
|
| 307 |
+
gr.ChatInterface(
|
| 308 |
+
respond,
|
| 309 |
+
examples=[
|
| 310 |
+
"What gear checklist do I need for a 3-day high-altitude trek?",
|
| 311 |
+
"How do I treat a sprained ankle on the trail?",
|
| 312 |
+
"What is Naismith's Rule for calculating hiking time?"
|
| 313 |
+
]
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
# --- Triggers ---
|
| 317 |
+
# Load default route on startup
|
| 318 |
+
demo.load(
|
| 319 |
+
fn=handle_route_update,
|
| 320 |
+
inputs=[preloaded_route, upload_file, gr.State(""), gr.State(""), gr.State(""), gr.State("")],
|
| 321 |
+
outputs=[stats_display, map_display, checkpoint_table]
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
# Preloaded selection change
|
| 325 |
+
preloaded_route.change(
|
| 326 |
+
fn=handle_route_update,
|
| 327 |
+
inputs=[preloaded_route, gr.State(None), gr.State(""), gr.State(""), gr.State(""), gr.State("")],
|
| 328 |
+
outputs=[stats_display, map_display, checkpoint_table]
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
# Uploaded file change
|
| 332 |
+
upload_file.change(
|
| 333 |
+
fn=handle_route_update,
|
| 334 |
+
inputs=[gr.State(None), upload_file, gr.State(""), gr.State(""), gr.State(""), gr.State("")],
|
| 335 |
+
outputs=[stats_display, map_display, checkpoint_table]
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# Fetch route button click
|
| 339 |
+
fetch_route_btn.click(
|
| 340 |
+
fn=handle_ors_fetch_click,
|
| 341 |
+
inputs=[start_pt, end_pt, ors_profile, ors_api_key],
|
| 342 |
+
outputs=[stats_display, map_display, checkpoint_table]
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
if __name__ == "__main__":
|
| 346 |
+
port = int(os.environ.get("PORT", 7860))
|
| 347 |
+
try:
|
| 348 |
+
demo.launch(server_name="0.0.0.0", server_port=port)
|
| 349 |
+
except OSError:
|
| 350 |
+
print(f"[app] Port {port} is busy. Falling back to automatic port selection...")
|
| 351 |
+
demo.launch(server_name="127.0.0.1")
|
assets/custom.css
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Trailhead Premium Tactical HUD Theme */
|
| 2 |
+
|
| 3 |
+
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Outfit:wght@300;400;500;600;700&display=swap');
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg-gradient: linear-gradient(135deg, #0a0e12 0%, #050709 100%);
|
| 7 |
+
--card-bg: rgba(13, 20, 26, 0.75);
|
| 8 |
+
--card-border: rgba(245, 158, 11, 0.18); /* Amber border */
|
| 9 |
+
--accent-primary: #f59e0b; /* Amber */
|
| 10 |
+
--accent-hover: #d97706;
|
| 11 |
+
--accent-green: #10b981; /* Safe path green */
|
| 12 |
+
--text-primary: #f3f4f6;
|
| 13 |
+
--text-muted: #9ca3af;
|
| 14 |
+
--danger-red: #ef4444;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
body, .gradio-container {
|
| 18 |
+
background: var(--bg-gradient) !important;
|
| 19 |
+
font-family: 'Outfit', -apple-system, sans-serif !important;
|
| 20 |
+
color: var(--text-primary) !important;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* Share Tech Mono for digital stats & coordinates */
|
| 24 |
+
.mono-display {
|
| 25 |
+
font-family: 'Share Tech Mono', monospace !important;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/* Glassmorphism Cards */
|
| 29 |
+
.gradio-container .gr-box,
|
| 30 |
+
.gradio-container .gr-panel,
|
| 31 |
+
.gradio-container .gr-card {
|
| 32 |
+
background: var(--card-bg) !important;
|
| 33 |
+
border: 1px solid var(--card-border) !important;
|
| 34 |
+
backdrop-filter: blur(12px) !important;
|
| 35 |
+
border-radius: 16px !important;
|
| 36 |
+
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5) !important;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* Typography settings */
|
| 40 |
+
h1 {
|
| 41 |
+
font-family: 'Share Tech Mono', monospace !important;
|
| 42 |
+
font-weight: 700 !important;
|
| 43 |
+
letter-spacing: 0.05em !important;
|
| 44 |
+
background: linear-gradient(to right, #f59e0b, #fbbf24, #10b981) !important;
|
| 45 |
+
-webkit-background-clip: text !important;
|
| 46 |
+
-webkit-text-fill-color: transparent !important;
|
| 47 |
+
font-size: 2.5rem !important;
|
| 48 |
+
margin-bottom: 0.5rem !important;
|
| 49 |
+
text-align: center !important;
|
| 50 |
+
text-transform: uppercase !important;
|
| 51 |
+
text-shadow: 0 0 15px rgba(245, 158, 11, 0.2) !important;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
p, span, label {
|
| 55 |
+
color: var(--text-primary) !important;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* Buttons Styling */
|
| 59 |
+
.gradio-container button.primary {
|
| 60 |
+
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%) !important;
|
| 61 |
+
border: 1px solid rgba(251, 191, 36, 0.3) !important;
|
| 62 |
+
color: black !important;
|
| 63 |
+
font-family: 'Share Tech Mono', monospace !important;
|
| 64 |
+
font-weight: 700 !important;
|
| 65 |
+
text-transform: uppercase !important;
|
| 66 |
+
font-size: 1.1rem !important;
|
| 67 |
+
border-radius: 12px !important;
|
| 68 |
+
padding: 14px 24px !important;
|
| 69 |
+
min-height: 54px !important; /* Touch target */
|
| 70 |
+
cursor: pointer !important;
|
| 71 |
+
transition: all 0.3s ease !important;
|
| 72 |
+
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.25) !important;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.gradio-container button.primary:hover {
|
| 76 |
+
transform: translateY(-2px) !important;
|
| 77 |
+
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4) !important;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.gradio-container button.secondary {
|
| 81 |
+
background: rgba(255, 255, 255, 0.05) !important;
|
| 82 |
+
border: 1px solid var(--card-border) !important;
|
| 83 |
+
color: var(--text-primary) !important;
|
| 84 |
+
font-family: 'Share Tech Mono', monospace !important;
|
| 85 |
+
font-weight: 500;
|
| 86 |
+
text-transform: uppercase !important;
|
| 87 |
+
border-radius: 12px !important;
|
| 88 |
+
padding: 12px 20px !important;
|
| 89 |
+
min-height: 50px !important;
|
| 90 |
+
transition: all 0.3s ease !important;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.gradio-container button.secondary:hover {
|
| 94 |
+
background: rgba(245, 158, 11, 0.1) !important;
|
| 95 |
+
border-color: var(--accent-primary) !important;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* Large Input Targets */
|
| 99 |
+
.gradio-container input,
|
| 100 |
+
.gradio-container textarea,
|
| 101 |
+
.gradio-container select {
|
| 102 |
+
background: rgba(0, 0, 0, 0.4) !important;
|
| 103 |
+
border: 1px solid var(--card-border) !important;
|
| 104 |
+
border-radius: 10px !important;
|
| 105 |
+
color: var(--text-primary) !important;
|
| 106 |
+
padding: 12px !important;
|
| 107 |
+
font-size: 1.05rem !important;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.gradio-container input:focus,
|
| 111 |
+
.gradio-container textarea:focus,
|
| 112 |
+
.gradio-container select:focus {
|
| 113 |
+
border-color: var(--accent-green) !important;
|
| 114 |
+
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2) !important;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* Custom Table/DataFrame styling */
|
| 118 |
+
.gradio-container table {
|
| 119 |
+
background: transparent !important;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.gradio-container th {
|
| 123 |
+
background: rgba(245, 158, 11, 0.1) !important;
|
| 124 |
+
color: var(--accent-primary) !important;
|
| 125 |
+
font-weight: 600 !important;
|
| 126 |
+
font-family: 'Share Tech Mono', monospace !important;
|
| 127 |
+
text-transform: uppercase !important;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.gradio-container td {
|
| 131 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/* HUD Stat Indicators */
|
| 135 |
+
.hud-stat-box {
|
| 136 |
+
border: 1px solid var(--card-border);
|
| 137 |
+
background: rgba(13, 20, 26, 0.8);
|
| 138 |
+
border-radius: 12px;
|
| 139 |
+
padding: 15px;
|
| 140 |
+
text-align: center;
|
| 141 |
+
box-shadow: inset 0 0 10px rgba(245, 158, 11, 0.05);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.hud-stat-val {
|
| 145 |
+
font-family: 'Share Tech Mono', monospace;
|
| 146 |
+
font-size: 2.2rem;
|
| 147 |
+
font-weight: 700;
|
| 148 |
+
color: var(--accent-primary);
|
| 149 |
+
text-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.hud-stat-lbl {
|
| 153 |
+
font-size: 0.8rem;
|
| 154 |
+
text-transform: uppercase;
|
| 155 |
+
color: var(--text-muted);
|
| 156 |
+
letter-spacing: 0.1em;
|
| 157 |
+
margin-top: 5px;
|
| 158 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
pandas
|
| 3 |
+
numpy
|
| 4 |
+
requests
|
| 5 |
+
huggingface_hub
|
| 6 |
+
gpxpy
|
| 7 |
+
folium
|
src/gpx_parser.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import math
|
| 3 |
+
import json
|
| 4 |
+
import requests
|
| 5 |
+
import gpxpy
|
| 6 |
+
|
| 7 |
+
def haversine(lat1, lon1, lat2, lon2):
|
| 8 |
+
"""Calculate the great-circle distance between two points on the Earth in meters."""
|
| 9 |
+
R = 6371000.0 # Radius of Earth in meters
|
| 10 |
+
phi1 = math.radians(lat1)
|
| 11 |
+
phi2 = math.radians(lat2)
|
| 12 |
+
delta_phi = math.radians(lat2 - lat1)
|
| 13 |
+
delta_lambda = math.radians(lon2 - lon1)
|
| 14 |
+
|
| 15 |
+
a = math.sin(delta_phi / 2.0)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0)**2
|
| 16 |
+
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
|
| 17 |
+
return R * c
|
| 18 |
+
|
| 19 |
+
def fetch_elevations_open_meteo(coords):
|
| 20 |
+
"""
|
| 21 |
+
Fetch elevation coordinates in batches of 100 from the Open-Meteo elevation API.
|
| 22 |
+
Returns a list of floats representing elevation in meters.
|
| 23 |
+
"""
|
| 24 |
+
elevations = []
|
| 25 |
+
batch_size = 100
|
| 26 |
+
for i in range(0, len(coords), batch_size):
|
| 27 |
+
batch = coords[i:i+batch_size]
|
| 28 |
+
lats = ",".join(f"{c[0]:.6f}" for c in batch)
|
| 29 |
+
lons = ",".join(f"{c[1]:.6f}" for c in batch)
|
| 30 |
+
url = f"https://api.open-meteo.com/v1/elevation?latitude={lats}&longitude={lons}"
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
print(f"[gpx_parser] Fetching elevation batch {i//batch_size + 1}...")
|
| 34 |
+
response = requests.get(url, timeout=10)
|
| 35 |
+
if response.status_code == 200:
|
| 36 |
+
data = response.json()
|
| 37 |
+
batch_elevations = data.get("elevation", [])
|
| 38 |
+
if len(batch_elevations) == len(batch):
|
| 39 |
+
elevations.extend(batch_elevations)
|
| 40 |
+
else:
|
| 41 |
+
print("[gpx_parser] Elevation list size mismatch. Filling with 0.0")
|
| 42 |
+
elevations.extend([0.0] * len(batch))
|
| 43 |
+
else:
|
| 44 |
+
print(f"[gpx_parser] API error {response.status_code}. Using 0.0 for batch.")
|
| 45 |
+
elevations.extend([0.0] * len(batch))
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"[gpx_parser] Network/parsing exception: {e}. Using 0.0 for batch.")
|
| 48 |
+
elevations.extend([0.0] * len(batch))
|
| 49 |
+
|
| 50 |
+
return elevations
|
| 51 |
+
|
| 52 |
+
def smooth_elevations(elevations, window_size=5):
|
| 53 |
+
"""Apply a simple moving average window to smooth out elevation profile data."""
|
| 54 |
+
if not elevations:
|
| 55 |
+
return []
|
| 56 |
+
smoothed = []
|
| 57 |
+
for i in range(len(elevations)):
|
| 58 |
+
start = max(0, i - window_size // 2)
|
| 59 |
+
end = min(len(elevations), i + window_size // 2 + 1)
|
| 60 |
+
window = elevations[start:end]
|
| 61 |
+
smoothed.append(sum(window) / len(window))
|
| 62 |
+
return smoothed
|
| 63 |
+
|
| 64 |
+
def calculate_elevation_gain_loss(elevations, threshold=2.0):
|
| 65 |
+
"""
|
| 66 |
+
Calculate cumulative elevation gain and loss in meters.
|
| 67 |
+
Filters out noise using a threshold value (minimum elevation delta).
|
| 68 |
+
"""
|
| 69 |
+
gain = 0.0
|
| 70 |
+
loss = 0.0
|
| 71 |
+
if len(elevations) < 2:
|
| 72 |
+
return gain, loss
|
| 73 |
+
|
| 74 |
+
last_val = elevations[0]
|
| 75 |
+
for val in elevations[1:]:
|
| 76 |
+
diff = val - last_val
|
| 77 |
+
if abs(diff) >= threshold:
|
| 78 |
+
if diff > 0:
|
| 79 |
+
gain += diff
|
| 80 |
+
else:
|
| 81 |
+
loss += abs(diff)
|
| 82 |
+
last_val = val
|
| 83 |
+
return gain, loss
|
| 84 |
+
|
| 85 |
+
def parse_gpx_file(file_path, cache_dir="./temp"):
|
| 86 |
+
"""
|
| 87 |
+
Parse a GPX file, fetch missing elevations, smooth the profile,
|
| 88 |
+
and compute trek statistics. Caches results locally to allow offline usage.
|
| 89 |
+
"""
|
| 90 |
+
# Create cache directory if needed
|
| 91 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 92 |
+
|
| 93 |
+
# Check cache first
|
| 94 |
+
file_name = os.path.basename(file_path)
|
| 95 |
+
cache_path = os.path.join(cache_dir, f"{file_name}.cache.json")
|
| 96 |
+
if os.path.exists(cache_path):
|
| 97 |
+
try:
|
| 98 |
+
with open(cache_path, "r", encoding="utf-8") as f:
|
| 99 |
+
print(f"[gpx_parser] Loading cached GPX data from {cache_path}")
|
| 100 |
+
return json.load(f)
|
| 101 |
+
except Exception as e:
|
| 102 |
+
print(f"[gpx_parser] Cache read error: {e}, parsing raw file...")
|
| 103 |
+
|
| 104 |
+
print(f"[gpx_parser] Parsing raw GPX file: {file_path}")
|
| 105 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 106 |
+
gpx = gpxpy.parse(f)
|
| 107 |
+
|
| 108 |
+
# Extract track points
|
| 109 |
+
points_raw = []
|
| 110 |
+
for track in gpx.tracks:
|
| 111 |
+
for segment in track.segments:
|
| 112 |
+
for pt in segment.points:
|
| 113 |
+
points_raw.append({
|
| 114 |
+
"lat": pt.latitude,
|
| 115 |
+
"lon": pt.longitude,
|
| 116 |
+
"ele": pt.elevation
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
# If GPX had no track points, look in waypoints or route points
|
| 120 |
+
if not points_raw:
|
| 121 |
+
for route in gpx.routes:
|
| 122 |
+
for pt in route.points:
|
| 123 |
+
points_raw.append({
|
| 124 |
+
"lat": pt.latitude,
|
| 125 |
+
"lon": pt.longitude,
|
| 126 |
+
"ele": pt.elevation
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
# Still empty? Check waypoints
|
| 130 |
+
if not points_raw and gpx.waypoints:
|
| 131 |
+
for wpt in gpx.waypoints:
|
| 132 |
+
points_raw.append({
|
| 133 |
+
"lat": wpt.latitude,
|
| 134 |
+
"lon": wpt.longitude,
|
| 135 |
+
"ele": wpt.elevation
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
if not points_raw:
|
| 139 |
+
raise ValueError("No trackpoints, routepoints, or waypoints found in GPX file.")
|
| 140 |
+
|
| 141 |
+
# Check if elevations are missing (all None or 0.0)
|
| 142 |
+
has_elevation = any(pt["ele"] is not None for pt in points_raw)
|
| 143 |
+
|
| 144 |
+
if not has_elevation:
|
| 145 |
+
print("[gpx_parser] GPX has no elevation data. Fetching from Open-Meteo elevation API...")
|
| 146 |
+
coords = [(pt["lat"], pt["lon"]) for pt in points_raw]
|
| 147 |
+
elevations = fetch_elevations_open_meteo(coords)
|
| 148 |
+
for i, ele in enumerate(elevations):
|
| 149 |
+
points_raw[i]["ele"] = ele
|
| 150 |
+
else:
|
| 151 |
+
# Fill in any scattered missing elevations
|
| 152 |
+
for pt in points_raw:
|
| 153 |
+
if pt["ele"] is None:
|
| 154 |
+
pt["ele"] = 0.0
|
| 155 |
+
|
| 156 |
+
# Smooth elevations
|
| 157 |
+
raw_elevations = [pt["ele"] for pt in points_raw]
|
| 158 |
+
smoothed_eles = smooth_elevations(raw_elevations)
|
| 159 |
+
for i, ele in enumerate(smoothed_eles):
|
| 160 |
+
points_raw[i]["ele"] = ele
|
| 161 |
+
|
| 162 |
+
# Calculate cumulative distances (in meters) and build final points list
|
| 163 |
+
points_data = []
|
| 164 |
+
cum_dist = 0.0
|
| 165 |
+
|
| 166 |
+
points_data.append({
|
| 167 |
+
"lat": points_raw[0]["lat"],
|
| 168 |
+
"lon": points_raw[0]["lon"],
|
| 169 |
+
"ele": points_raw[0]["ele"],
|
| 170 |
+
"cum_dist": 0.0
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
for i in range(1, len(points_raw)):
|
| 174 |
+
p1 = points_raw[i-1]
|
| 175 |
+
p2 = points_raw[i]
|
| 176 |
+
d = haversine(p1["lat"], p1["lon"], p2["lat"], p2["lon"])
|
| 177 |
+
cum_dist += d
|
| 178 |
+
points_data.append({
|
| 179 |
+
"lat": p2["lat"],
|
| 180 |
+
"lon": p2["lon"],
|
| 181 |
+
"ele": p2["ele"],
|
| 182 |
+
"cum_dist": cum_dist
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
# Calculate statistics
|
| 186 |
+
total_distance_m = cum_dist
|
| 187 |
+
total_distance_km = total_distance_m / 1000.0
|
| 188 |
+
|
| 189 |
+
gain, loss = calculate_elevation_gain_loss(smoothed_eles)
|
| 190 |
+
|
| 191 |
+
min_ele = min(smoothed_eles) if smoothed_eles else 0.0
|
| 192 |
+
max_ele = max(smoothed_eles) if smoothed_eles else 0.0
|
| 193 |
+
|
| 194 |
+
# Naismith's Rule: 5 km/h base speed + 1 hour per 600m ascent
|
| 195 |
+
# estimated_hours = (dist_km / 5.0) + (gain_m / 600.0)
|
| 196 |
+
naismith_hours = (total_distance_km / 5.0) + (gain / 600.0)
|
| 197 |
+
# Estimate days assuming 8 hours hiking per day
|
| 198 |
+
estimated_days = max(1.0, naismith_hours / 8.0)
|
| 199 |
+
|
| 200 |
+
# Pre-parse waypoints if they exist in GPX
|
| 201 |
+
waypoints = []
|
| 202 |
+
for wpt in gpx.waypoints:
|
| 203 |
+
waypoints.append({
|
| 204 |
+
"name": wpt.name or "Waypoint",
|
| 205 |
+
"lat": wpt.latitude,
|
| 206 |
+
"lon": wpt.longitude,
|
| 207 |
+
"ele": wpt.elevation or 0.0,
|
| 208 |
+
"desc": wpt.description or ""
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
# Generate checkpoints
|
| 212 |
+
checkpoints = []
|
| 213 |
+
if waypoints:
|
| 214 |
+
# Match waypoints to track points to find cumulative distance
|
| 215 |
+
for wpt in waypoints:
|
| 216 |
+
# Find closest track point
|
| 217 |
+
min_d = float('inf')
|
| 218 |
+
closest_pt = points_data[0]
|
| 219 |
+
for pt in points_data:
|
| 220 |
+
d = haversine(wpt["lat"], wpt["lon"], pt["lat"], pt["lon"])
|
| 221 |
+
if d < min_d:
|
| 222 |
+
min_d = d
|
| 223 |
+
closest_pt = pt
|
| 224 |
+
checkpoints.append({
|
| 225 |
+
"name": wpt["name"],
|
| 226 |
+
"lat": wpt["lat"],
|
| 227 |
+
"lon": wpt["lon"],
|
| 228 |
+
"ele": closest_pt["ele"],
|
| 229 |
+
"cum_dist": closest_pt["cum_dist"] / 1000.0
|
| 230 |
+
})
|
| 231 |
+
# Sort by distance
|
| 232 |
+
checkpoints.sort(key=lambda c: c["cum_dist"])
|
| 233 |
+
else:
|
| 234 |
+
# Auto-generate checkpoints every 1000 meters
|
| 235 |
+
checkpoints = generate_checkpoints(points_data, interval_meters=1000.0)
|
| 236 |
+
|
| 237 |
+
result = {
|
| 238 |
+
"file_name": file_name,
|
| 239 |
+
"total_distance_km": round(total_distance_km, 2),
|
| 240 |
+
"elevation_gain_m": round(gain, 1),
|
| 241 |
+
"elevation_loss_m": round(loss, 1),
|
| 242 |
+
"min_elevation_m": round(min_ele, 1),
|
| 243 |
+
"max_elevation_m": round(max_ele, 1),
|
| 244 |
+
"estimated_days": round(estimated_days, 1),
|
| 245 |
+
"naismith_hours": round(naismith_hours, 1),
|
| 246 |
+
"points": points_data,
|
| 247 |
+
"checkpoints": checkpoints
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
# Save cache
|
| 251 |
+
try:
|
| 252 |
+
with open(cache_path, "w", encoding="utf-8") as f:
|
| 253 |
+
json.dump(result, f, indent=2)
|
| 254 |
+
print(f"[gpx_parser] Saved parsed GPX data cache to {cache_path}")
|
| 255 |
+
except Exception as e:
|
| 256 |
+
print(f"[gpx_parser] Cache write error: {e}")
|
| 257 |
+
|
| 258 |
+
return result
|
| 259 |
+
|
| 260 |
+
def generate_checkpoints(points_data, interval_meters=1000.0):
|
| 261 |
+
"""Helper to partition track into regular distance checkpoints."""
|
| 262 |
+
if not points_data:
|
| 263 |
+
return []
|
| 264 |
+
|
| 265 |
+
checkpoints = []
|
| 266 |
+
start_pt = points_data[0]
|
| 267 |
+
checkpoints.append({
|
| 268 |
+
"name": "Start",
|
| 269 |
+
"lat": start_pt["lat"],
|
| 270 |
+
"lon": start_pt["lon"],
|
| 271 |
+
"ele": start_pt["ele"],
|
| 272 |
+
"cum_dist": 0.0
|
| 273 |
+
})
|
| 274 |
+
|
| 275 |
+
total_dist = points_data[-1]["cum_dist"]
|
| 276 |
+
next_checkpoint_dist = interval_meters
|
| 277 |
+
pt_idx = 1
|
| 278 |
+
|
| 279 |
+
while next_checkpoint_dist < total_dist:
|
| 280 |
+
while pt_idx < len(points_data) and points_data[pt_idx]["cum_dist"] < next_checkpoint_dist:
|
| 281 |
+
pt_idx += 1
|
| 282 |
+
|
| 283 |
+
if pt_idx >= len(points_data):
|
| 284 |
+
break
|
| 285 |
+
|
| 286 |
+
p1 = points_data[pt_idx - 1]
|
| 287 |
+
p2 = points_data[pt_idx]
|
| 288 |
+
|
| 289 |
+
if abs(p1["cum_dist"] - next_checkpoint_dist) < abs(p2["cum_dist"] - next_checkpoint_dist):
|
| 290 |
+
chosen = p1
|
| 291 |
+
else:
|
| 292 |
+
chosen = p2
|
| 293 |
+
|
| 294 |
+
checkpoints.append({
|
| 295 |
+
"name": f"Km {next_checkpoint_dist / 1000.0:.1f}",
|
| 296 |
+
"lat": chosen["lat"],
|
| 297 |
+
"lon": chosen["lon"],
|
| 298 |
+
"ele": chosen["ele"],
|
| 299 |
+
"cum_dist": round(chosen["cum_dist"] / 1000.0, 2)
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
next_checkpoint_dist += interval_meters
|
| 303 |
+
|
| 304 |
+
end_pt = points_data[-1]
|
| 305 |
+
if len(checkpoints) == 1 or (total_dist / 1000.0 - checkpoints[-1]["cum_dist"]) > 0.1:
|
| 306 |
+
checkpoints.append({
|
| 307 |
+
"name": "End",
|
| 308 |
+
"lat": end_pt["lat"],
|
| 309 |
+
"lon": end_pt["lon"],
|
| 310 |
+
"ele": end_pt["ele"],
|
| 311 |
+
"cum_dist": round(total_dist / 1000.0, 2)
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
return checkpoints
|
src/llm.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import requests
|
| 4 |
+
import json
|
| 5 |
+
import base64
|
| 6 |
+
import threading
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
_model_lock = threading.Lock()
|
| 10 |
+
|
| 11 |
+
# Backend configuration via environment variable. Defaults to auto-detected or "mock"
|
| 12 |
+
try:
|
| 13 |
+
import llama_cpp
|
| 14 |
+
default_backend = "llama_cpp"
|
| 15 |
+
except ImportError:
|
| 16 |
+
default_backend = "mock"
|
| 17 |
+
BACKEND = os.environ.get("BACKEND", default_backend).lower()
|
| 18 |
+
|
| 19 |
+
# Constants for Hugging Face Space model loading
|
| 20 |
+
MODEL_REPO = "bartowski/google_gemma-4-E2B-it-GGUF"
|
| 21 |
+
MODEL_FILE = "google_gemma-4-E2B-it-Q4_K_M.gguf"
|
| 22 |
+
LOCAL_MODEL_DIR = os.environ.get("MODEL_DIR", "./model")
|
| 23 |
+
|
| 24 |
+
_llama_model = None
|
| 25 |
+
|
| 26 |
+
def _download_gguf():
|
| 27 |
+
"""Download GGUF model from Hugging Face if not already present."""
|
| 28 |
+
os.makedirs(LOCAL_MODEL_DIR, exist_ok=True)
|
| 29 |
+
local_path = os.path.join(LOCAL_MODEL_DIR, MODEL_FILE)
|
| 30 |
+
if os.path.exists(local_path):
|
| 31 |
+
print(f"[llm.py] Model GGUF already exists at {local_path}")
|
| 32 |
+
return local_path
|
| 33 |
+
|
| 34 |
+
print(f"[llm.py] Downloading {MODEL_FILE} from HF repo {MODEL_REPO}...")
|
| 35 |
+
try:
|
| 36 |
+
from huggingface_hub import hf_hub_download
|
| 37 |
+
downloaded_path = hf_hub_download(
|
| 38 |
+
repo_id=MODEL_REPO,
|
| 39 |
+
filename=MODEL_FILE,
|
| 40 |
+
local_dir=LOCAL_MODEL_DIR,
|
| 41 |
+
local_dir_use_symlinks=False
|
| 42 |
+
)
|
| 43 |
+
print(f"[llm.py] Model downloaded successfully to {downloaded_path}")
|
| 44 |
+
return downloaded_path
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"[llm.py] Error downloading model from Hugging Face: {e}")
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
def init_llama_cpp():
|
| 50 |
+
"""Lazy initialization of llama_cpp model."""
|
| 51 |
+
global _llama_model
|
| 52 |
+
if _llama_model is not None:
|
| 53 |
+
return _llama_model
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
from llama_cpp import Llama
|
| 57 |
+
except ImportError:
|
| 58 |
+
print("[llm.py] Warning: llama-cpp-python is not installed. Falling back to mock backend.")
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
model_path = _download_gguf()
|
| 62 |
+
if not model_path or not os.path.exists(model_path):
|
| 63 |
+
print("[llm.py] Error: Model file not found. Cannot load llama_cpp.")
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
print(f"[llm.py] Loading model into memory: {model_path}")
|
| 67 |
+
num_threads = 1 if os.environ.get("SPACE_ID") else 4
|
| 68 |
+
try:
|
| 69 |
+
_llama_model = Llama(
|
| 70 |
+
model_path=model_path,
|
| 71 |
+
n_ctx=2048,
|
| 72 |
+
n_threads=num_threads,
|
| 73 |
+
verbose=False
|
| 74 |
+
)
|
| 75 |
+
print("[llm.py] llama_cpp model loaded successfully!")
|
| 76 |
+
return _llama_model
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"[llm.py] Error loading llama_cpp: {e}")
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
# --- Whisper.cpp ASR (Speech-to-Text) ---
|
| 82 |
+
_whisper_model = None
|
| 83 |
+
|
| 84 |
+
def _init_whisper():
|
| 85 |
+
"""Lazy initialization of whisper.cpp model for offline ASR."""
|
| 86 |
+
global _whisper_model
|
| 87 |
+
if _whisper_model is not None:
|
| 88 |
+
return _whisper_model
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
from pywhispercpp.model import Model as WhisperModel
|
| 92 |
+
print("[llm.py] Loading whisper.cpp 'tiny' model for ASR...")
|
| 93 |
+
_whisper_model = WhisperModel(
|
| 94 |
+
'tiny',
|
| 95 |
+
n_threads=2 if not os.environ.get("SPACE_ID") else 1
|
| 96 |
+
)
|
| 97 |
+
print("[llm.py] whisper.cpp ASR model loaded successfully!")
|
| 98 |
+
return _whisper_model
|
| 99 |
+
except ImportError:
|
| 100 |
+
print("[llm.py] pywhispercpp not installed. ASR will use mock fallback.")
|
| 101 |
+
return None
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f"[llm.py] Error loading whisper.cpp ASR model: {e}")
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
def transcribe_audio(audio_path, prompt=""):
|
| 107 |
+
"""
|
| 108 |
+
Transcribe audio file to text using whisper.cpp (offline, lightweight).
|
| 109 |
+
Falls back to mock transcription if whisper.cpp is unavailable.
|
| 110 |
+
"""
|
| 111 |
+
if not audio_path or not os.path.exists(audio_path):
|
| 112 |
+
print("[llm.py] Audio file not found, using mock ASR.")
|
| 113 |
+
return _mock_transcribe_audio(prompt)
|
| 114 |
+
|
| 115 |
+
whisper = _init_whisper()
|
| 116 |
+
if whisper is None:
|
| 117 |
+
print("[llm.py] whisper.cpp unavailable, using mock ASR fallback.")
|
| 118 |
+
return _mock_transcribe_audio(prompt)
|
| 119 |
+
|
| 120 |
+
temp_wav_path = None
|
| 121 |
+
try:
|
| 122 |
+
try:
|
| 123 |
+
import miniaudio
|
| 124 |
+
import wave
|
| 125 |
+
print(f"[llm.py] Decoding and resampling audio to 16kHz mono WAV using miniaudio...")
|
| 126 |
+
sound = miniaudio.decode_file(audio_path, nchannels=1, sample_rate=16000)
|
| 127 |
+
|
| 128 |
+
# Save to temp WAV file
|
| 129 |
+
temp_wav_path = audio_path + ".temp_16k.wav"
|
| 130 |
+
with wave.open(temp_wav_path, "wb") as wav_file:
|
| 131 |
+
wav_file.setnchannels(1)
|
| 132 |
+
wav_file.setsampwidth(2) # 16-bit PCM
|
| 133 |
+
wav_file.setframerate(16000)
|
| 134 |
+
wav_file.writeframes(sound.samples)
|
| 135 |
+
|
| 136 |
+
audio_path = temp_wav_path
|
| 137 |
+
print(f"[llm.py] Resampled audio saved to: {audio_path}")
|
| 138 |
+
except ImportError:
|
| 139 |
+
print("[llm.py] miniaudio not installed. Passing audio file directly to whisper.cpp.")
|
| 140 |
+
except Exception as e:
|
| 141 |
+
print(f"[llm.py] miniaudio transcoding failed: {e}. Passing original file directly.")
|
| 142 |
+
|
| 143 |
+
print(f"[llm.py] Transcribing audio: {audio_path}")
|
| 144 |
+
segments = whisper.transcribe(audio_path)
|
| 145 |
+
transcription = " ".join([seg.text.strip() for seg in segments]).strip()
|
| 146 |
+
|
| 147 |
+
if temp_wav_path and os.path.exists(temp_wav_path):
|
| 148 |
+
try: os.remove(temp_wav_path)
|
| 149 |
+
except: pass
|
| 150 |
+
|
| 151 |
+
if not transcription:
|
| 152 |
+
print("[llm.py] Whisper returned empty transcription, using mock fallback.")
|
| 153 |
+
return _mock_transcribe_audio(prompt)
|
| 154 |
+
|
| 155 |
+
print(f"[llm.py] ASR Transcription: \"{transcription}\"")
|
| 156 |
+
return transcription
|
| 157 |
+
except Exception as e:
|
| 158 |
+
if temp_wav_path and os.path.exists(temp_wav_path):
|
| 159 |
+
try: os.remove(temp_wav_path)
|
| 160 |
+
except: pass
|
| 161 |
+
print(f"[llm.py] Error during whisper.cpp transcription: {e}")
|
| 162 |
+
return _mock_transcribe_audio(prompt)
|
| 163 |
+
|
| 164 |
+
def _mock_transcribe_audio(prompt=""):
|
| 165 |
+
"""Mock ASR fallback when whisper.cpp is not available."""
|
| 166 |
+
prompt_lower = str(prompt).lower() if prompt else ""
|
| 167 |
+
if "first" in prompt_lower or "injury" in prompt_lower or "ems" in prompt_lower:
|
| 168 |
+
return "How do I treat a sprained ankle on the trail?"
|
| 169 |
+
elif "gear" in prompt_lower or "backpack" in prompt_lower:
|
| 170 |
+
return "What gear list do I need for a 3-day high-altitude trek?"
|
| 171 |
+
else:
|
| 172 |
+
return "Am I on the correct route right now?"
|
| 173 |
+
|
| 174 |
+
# Keep backward-compatible alias
|
| 175 |
+
mock_transcribe_audio = _mock_transcribe_audio
|
| 176 |
+
|
| 177 |
+
def generate_mock(prompt, system="", image_path=None, audio_path=None, history=None):
|
| 178 |
+
"""Simulate streaming for the mock backend tailored for Trailhead."""
|
| 179 |
+
response = ""
|
| 180 |
+
|
| 181 |
+
# 0. Handle Voice Audio ASR
|
| 182 |
+
if audio_path:
|
| 183 |
+
transcription = transcribe_audio(audio_path, prompt)
|
| 184 |
+
response += f"[🎙️ **Voice Journal Transcription:** \"{transcription}\"]\n\n"
|
| 185 |
+
prompt = transcription
|
| 186 |
+
|
| 187 |
+
prompt_lower = prompt.lower()
|
| 188 |
+
|
| 189 |
+
# 1. Checkpoint / Narration Queries
|
| 190 |
+
if "checkpoint" in prompt_lower or "narration" in prompt_lower or "current position" in prompt_lower:
|
| 191 |
+
response += (
|
| 192 |
+
"🧭 **Trailhead Contextual Guide:**\n"
|
| 193 |
+
"You are approaching **Km 2.0 Checkpoint**. The terrain ahead is moderately steep with an elevation gain of ~45m over the next kilometer.\n\n"
|
| 194 |
+
"⚠️ **Advisory:** Watch your water supply; the next reliable spring is at Km 3.5. Ensure you reach the shelter before 17:00 as temperatures drop rapidly to 5°C."
|
| 195 |
+
)
|
| 196 |
+
# 2. Gear Checklist Queries
|
| 197 |
+
elif "gear" in prompt_lower or "checklist" in prompt_lower or "pack" in prompt_lower:
|
| 198 |
+
response += (
|
| 199 |
+
"🎒 **Suggested Gear Checklist (Pace- & Altitude-Adjusted):**\n"
|
| 200 |
+
"Based on your 1-day trek details, here is a highly tailored packing guide:\n\n"
|
| 201 |
+
"- **Navigation:** Offline map download, compass, backup physical map.\n"
|
| 202 |
+
"- **Hydration:** 2.5L water capacity + iodine tablets (water sources tagged at Km 3.5).\n"
|
| 203 |
+
"- **Apparel:** Windbreaker/rain shell, moisture-wicking base layers, wool socks.\n"
|
| 204 |
+
"- **Safety:** First-aid kit (with blister care), whistle, multi-tool, space blanket.\n"
|
| 205 |
+
"- **Nutrition:** 2500 kcal high-density trail snacks (nuts, bars, jerky)."
|
| 206 |
+
)
|
| 207 |
+
# 3. Wilderness First-Aid / RAG Queries
|
| 208 |
+
elif "first-aid" in prompt_lower or "first aid" in prompt_lower or "medical" in prompt_lower or "injury" in prompt_lower or "sprain" in prompt_lower or "ams" in prompt_lower or "sick" in prompt_lower:
|
| 209 |
+
response += (
|
| 210 |
+
"🩹 **Wilderness First-Aid Protocol (CITED):**\n"
|
| 211 |
+
"For managing a **Sprained Ankle / Strain** in the backcountry, use the **R.I.C.E.** protocol:\n\n"
|
| 212 |
+
"1. **Rest:** Stop hiking immediately. Remove weight from the injured limb.\n"
|
| 213 |
+
"2. **Ice / Cold:** Apply a cold pack or submerge in cold trail stream for 20 mins to reduce swelling.\n"
|
| 214 |
+
"3. **Compression:** Wrap firmly with an elastic bandage (do not restrict circulation).\n"
|
| 215 |
+
"4. **Elevation:** Elevate the ankle above the heart level whenever resting.\n\n"
|
| 216 |
+
"📖 *CITED SOURCE: Wilderness Medicine Field Guide, Section 7: Musculoskeletal Injuries.*"
|
| 217 |
+
)
|
| 218 |
+
# 4. Off-Route / Deviation Queries
|
| 219 |
+
elif "route" in prompt_lower or "off-route" in prompt_lower or "deviate" in prompt_lower or "map" in prompt_lower:
|
| 220 |
+
response += (
|
| 221 |
+
"⚠️ **Navigation Warning:**\n"
|
| 222 |
+
"You have deviated from the planned polyline by **42 meters**. \n\n"
|
| 223 |
+
"**Action:** Look for physical trail markers or backtrack to your last known coordinate. Do not proceed off-trail through dense underbrush."
|
| 224 |
+
)
|
| 225 |
+
# 5. Default Response
|
| 226 |
+
else:
|
| 227 |
+
response += (
|
| 228 |
+
"🌲 **Welcome to Trailhead Navigation Assistant!**\n"
|
| 229 |
+
"I am your offline-first trail computer. I can analyze your uploaded GPX files, estimate Naismith trekking durations, auto-partition checkpoints, and offer grounded AI advice.\n\n"
|
| 230 |
+
"Ask me about gear checklists, route narration, deviation warnings, or wilderness first-aid emergency protocols."
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
for word in response.split(" "):
|
| 234 |
+
yield word + " "
|
| 235 |
+
time.sleep(0.03)
|
| 236 |
+
|
| 237 |
+
def generate_llama_cpp(prompt, system="", image_path=None, audio_path=None, history=None):
|
| 238 |
+
"""Query the in-process llama-cpp-python model with a timeout fallback to mock."""
|
| 239 |
+
if getattr(generate_llama_cpp, "disabled", False):
|
| 240 |
+
print("[llm.py] llama_cpp is disabled (too slow or failed). Using mock backend.")
|
| 241 |
+
for chunk in generate_mock(prompt, system, image_path, audio_path, history):
|
| 242 |
+
yield chunk
|
| 243 |
+
return
|
| 244 |
+
|
| 245 |
+
acquired = _model_lock.acquire(blocking=True)
|
| 246 |
+
if not acquired:
|
| 247 |
+
print("[llm.py] Could not acquire model lock. Falling back to mock.")
|
| 248 |
+
for chunk in generate_mock(prompt, system, image_path, audio_path, history):
|
| 249 |
+
yield chunk
|
| 250 |
+
return
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
start_time = time.time()
|
| 254 |
+
model = None
|
| 255 |
+
try:
|
| 256 |
+
model = init_llama_cpp()
|
| 257 |
+
except Exception as e:
|
| 258 |
+
print(f"[llm.py] Exception during init_llama_cpp: {e}")
|
| 259 |
+
|
| 260 |
+
if model is None:
|
| 261 |
+
print("[llm.py] Fallback to mock backend.")
|
| 262 |
+
for chunk in generate_mock(prompt, system, image_path, audio_path, history):
|
| 263 |
+
yield chunk
|
| 264 |
+
return
|
| 265 |
+
|
| 266 |
+
init_duration = time.time() - start_time
|
| 267 |
+
if init_duration > 35.0:
|
| 268 |
+
print(f"[llm.py] Warning: Model loading took {init_duration:.2f}s (exceeded 35s limit). Disabling llama_cpp and falling back to mock backend.")
|
| 269 |
+
generate_llama_cpp.disabled = True
|
| 270 |
+
for chunk in generate_mock(prompt, system, image_path, audio_path, history):
|
| 271 |
+
yield chunk
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
voice_prefix = ""
|
| 275 |
+
if audio_path:
|
| 276 |
+
transcription = transcribe_audio(audio_path, prompt)
|
| 277 |
+
voice_prefix = f"[🎙️ **ASR Transcribed:** \"{transcription}\"]\n\n"
|
| 278 |
+
prompt = f"The hiker asked by voice: '{transcription}'. Respond directly to this query."
|
| 279 |
+
|
| 280 |
+
if image_path:
|
| 281 |
+
prompt = f"[📸 Image uploaded] {prompt}"
|
| 282 |
+
|
| 283 |
+
formatted_prompt = f"<|im_start|>system\n{system}<|im_end|>\n"
|
| 284 |
+
if history:
|
| 285 |
+
for msg in history:
|
| 286 |
+
role = msg.get("role", "user")
|
| 287 |
+
content = msg.get("content", "")
|
| 288 |
+
formatted_prompt += f"<|im_start|>{role}\n{content}<|im_end|>\n"
|
| 289 |
+
formatted_prompt += f"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
|
| 290 |
+
|
| 291 |
+
print(f"\n--- [llama.cpp INPUT PROMPT] ---\n{formatted_prompt}\n--------------------------------")
|
| 292 |
+
print("--- [llama.cpp STREAMING RESPONSE] ---")
|
| 293 |
+
try:
|
| 294 |
+
response = model(
|
| 295 |
+
formatted_prompt,
|
| 296 |
+
max_tokens=512,
|
| 297 |
+
temperature=0.3,
|
| 298 |
+
top_p=0.9,
|
| 299 |
+
stream=True
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
first_token_timeout = 30.0
|
| 303 |
+
response_iter = iter(response)
|
| 304 |
+
|
| 305 |
+
first_chunk_start = time.time()
|
| 306 |
+
try:
|
| 307 |
+
first_chunk = next(response_iter)
|
| 308 |
+
except StopIteration:
|
| 309 |
+
first_chunk = None
|
| 310 |
+
|
| 311 |
+
prefill_duration = time.time() - first_chunk_start
|
| 312 |
+
if prefill_duration > first_token_timeout:
|
| 313 |
+
print(f"[llm.py] Prompt evaluation took {prefill_duration:.2f}s (exceeded {first_token_timeout}s limit). Disabling llama_cpp and falling back to mock.")
|
| 314 |
+
generate_llama_cpp.disabled = True
|
| 315 |
+
for chunk in generate_mock(prompt, system, image_path, audio_path, history):
|
| 316 |
+
yield chunk
|
| 317 |
+
return
|
| 318 |
+
|
| 319 |
+
if voice_prefix:
|
| 320 |
+
yield voice_prefix
|
| 321 |
+
|
| 322 |
+
if first_chunk:
|
| 323 |
+
text = first_chunk['choices'][0]['text']
|
| 324 |
+
cleaned = text.replace("<|im_end|>", "")
|
| 325 |
+
print(cleaned, end="", flush=True)
|
| 326 |
+
yield cleaned
|
| 327 |
+
|
| 328 |
+
for chunk in response_iter:
|
| 329 |
+
text = chunk['choices'][0]['text']
|
| 330 |
+
cleaned = text.replace("<|im_end|>", "")
|
| 331 |
+
print(cleaned, end="", flush=True)
|
| 332 |
+
yield cleaned
|
| 333 |
+
print("\n--------------------------------------")
|
| 334 |
+
except Exception as e:
|
| 335 |
+
print(f"[llm.py] Error running llama.cpp: {e}. Falling back to mock.")
|
| 336 |
+
for chunk in generate_mock(prompt, system, image_path, audio_path, history):
|
| 337 |
+
yield chunk
|
| 338 |
+
finally:
|
| 339 |
+
_model_lock.release()
|
| 340 |
+
|
| 341 |
+
def generate(prompt, system="", image_path=None, audio_path=None, history=None, stream=True):
|
| 342 |
+
"""Entry point for LLM generation supporting text, image, and voice inputs."""
|
| 343 |
+
print(f"[llm.py] Using backend: {BACKEND}")
|
| 344 |
+
if BACKEND == "llama_cpp":
|
| 345 |
+
generator = generate_llama_cpp(prompt, system, image_path, audio_path, history)
|
| 346 |
+
else: # mock
|
| 347 |
+
generator = generate_mock(prompt, system, image_path, audio_path, history)
|
| 348 |
+
|
| 349 |
+
if stream:
|
| 350 |
+
return generator
|
| 351 |
+
else:
|
| 352 |
+
res = ""
|
| 353 |
+
for chunk in generator:
|
| 354 |
+
res += chunk
|
| 355 |
+
return res
|