trailframe 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trailframe/__init__.py +0 -0
- trailframe/__main__.py +3 -0
- trailframe/api/__init__.py +0 -0
- trailframe/api/about.py +84 -0
- trailframe/api/activities.py +205 -0
- trailframe/api/configuration.py +19 -0
- trailframe/api/events.py +45 -0
- trailframe/api/map_data.py +63 -0
- trailframe/api/models.py +52 -0
- trailframe/api/photos.py +338 -0
- trailframe/api/pipeline.py +37 -0
- trailframe/api/statistics.py +43 -0
- trailframe/api/tiles.py +16 -0
- trailframe/frontend/dist/assets/favicon-DuTzlsvV.png +0 -0
- trailframe/frontend/dist/assets/main-8Rix1cnZ.js +1 -0
- trailframe/frontend/dist/assets/main-vh-t_kPv.css +1 -0
- trailframe/frontend/dist/assets/slideshow-GzAXSY-n.js +1 -0
- trailframe/frontend/dist/assets/theme-CZT-v4sH.css +1 -0
- trailframe/frontend/dist/assets/theme-sVwLD9Y8.js +340 -0
- trailframe/frontend/dist/index.html +16 -0
- trailframe/frontend/dist/slideshow.html +15 -0
- trailframe/main.py +132 -0
- trailframe/models/__init__.py +0 -0
- trailframe/models/activity.py +411 -0
- trailframe/models/group.py +19 -0
- trailframe/models/photo.py +110 -0
- trailframe/models/scanner_stat.py +14 -0
- trailframe/services/__init__.py +0 -0
- trailframe/services/activity_service.py +224 -0
- trailframe/services/configuration_service.py +168 -0
- trailframe/services/database_service.py +137 -0
- trailframe/services/folder_service.py +208 -0
- trailframe/services/garmin_connect_service.py +201 -0
- trailframe/services/gpx_service.py +92 -0
- trailframe/services/location_service.py +435 -0
- trailframe/services/map_service.py +230 -0
- trailframe/services/pipeline_service.py +71 -0
- trailframe/services/pipelines/__init__.py +0 -0
- trailframe/services/pipelines/basic_pipeline.py +59 -0
- trailframe/services/pipelines/creation_pipeline.py +54 -0
- trailframe/services/pipelines/executor.py +15 -0
- trailframe/services/pipelines/pipeline.py +174 -0
- trailframe/services/scanners/__init__.py +0 -0
- trailframe/services/scanners/activity_scanner.py +76 -0
- trailframe/services/scanners/brisque_model_live.yml +9424 -0
- trailframe/services/scanners/brisque_range_live.yml +25 -0
- trailframe/services/scanners/brisque_scanner.py +28 -0
- trailframe/services/scanners/exif_scanner.py +96 -0
- trailframe/services/scanners/file_scanner.py +22 -0
- trailframe/services/scanners/location_scanner.py +36 -0
- trailframe/services/scanners/object_scanner.py +71 -0
- trailframe/services/scanners/perceptual_hash_scanner.py +19 -0
- trailframe/services/scanners/scanner.py +71 -0
- trailframe/services/scanners/thumbnail_scanner.py +14 -0
- trailframe/services/service.py +68 -0
- trailframe/services/statistics_service.py +96 -0
- trailframe/services/thumbnail_service.py +101 -0
- trailframe/services/tile_service.py +168 -0
- trailframe-0.1.0.dist-info/METADATA +130 -0
- trailframe-0.1.0.dist-info/RECORD +63 -0
- trailframe-0.1.0.dist-info/WHEEL +4 -0
- trailframe-0.1.0.dist-info/entry_points.txt +2 -0
- trailframe-0.1.0.dist-info/licenses/LICENSE +21 -0
trailframe/__init__.py
ADDED
|
File without changes
|
trailframe/__main__.py
ADDED
|
File without changes
|
trailframe/api/about.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from importlib.metadata import Distribution, distributions, packages_distributions
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/api/about", tags=["about"])
|
|
11
|
+
|
|
12
|
+
_APP_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PackageInfo(BaseModel):
|
|
16
|
+
name: str
|
|
17
|
+
version: str
|
|
18
|
+
license: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _imported_module_names() -> set[str]:
|
|
22
|
+
names: set[str] = set()
|
|
23
|
+
|
|
24
|
+
for path in _APP_ROOT.rglob("*.py"):
|
|
25
|
+
try:
|
|
26
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
27
|
+
except (OSError, SyntaxError):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
for node in ast.walk(tree):
|
|
31
|
+
if isinstance(node, ast.Import):
|
|
32
|
+
for alias in node.names:
|
|
33
|
+
names.add(alias.name.split(".", 1)[0])
|
|
34
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
35
|
+
names.add(node.module.split(".", 1)[0])
|
|
36
|
+
|
|
37
|
+
return names
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _package_license(dist: Distribution) -> str:
|
|
41
|
+
metadata = dist.metadata
|
|
42
|
+
|
|
43
|
+
for classifier in metadata.get_all("Classifier") or []:
|
|
44
|
+
if classifier.startswith("License :: OSI Approved ::"):
|
|
45
|
+
return classifier.split("::")[-1].strip()
|
|
46
|
+
|
|
47
|
+
for key in ("License-Expression", "License"):
|
|
48
|
+
value = metadata.get(key)
|
|
49
|
+
if value:
|
|
50
|
+
value = value.strip()
|
|
51
|
+
if len(value) <= 80:
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/packages", response_model=list[PackageInfo])
|
|
58
|
+
async def list_packages() -> list[PackageInfo]:
|
|
59
|
+
installed = {dist.metadata.get("Name", "").casefold(): dist for dist in distributions()}
|
|
60
|
+
module_distributions = packages_distributions()
|
|
61
|
+
|
|
62
|
+
distribution_names: set[str] = set()
|
|
63
|
+
|
|
64
|
+
for module in _imported_module_names():
|
|
65
|
+
distribution_names.update(
|
|
66
|
+
dist_name for dist_name in module_distributions.get(module, ()) if dist_name.casefold() in installed
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
packages: list[PackageInfo] = []
|
|
70
|
+
|
|
71
|
+
for key in sorted(distribution_names, key=str.casefold):
|
|
72
|
+
dist = installed.get(key.casefold())
|
|
73
|
+
if dist is None:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
packages.append(
|
|
77
|
+
PackageInfo(
|
|
78
|
+
name=dist.metadata.get("Name", ""),
|
|
79
|
+
version=dist.version,
|
|
80
|
+
license=_package_license(dist),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return packages
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import tempfile
|
|
3
|
+
import zipfile
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
|
9
|
+
from fastapi.responses import FileResponse, Response
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from trailframe.models.activity import Activity, ActivitySummary, GarminActivitySummary, GpxActivitySummary
|
|
13
|
+
from trailframe.models.photo import Photo, PhotoDetail
|
|
14
|
+
from trailframe.services.activity_service import ActivityService
|
|
15
|
+
from trailframe.services.folder_service import FolderService
|
|
16
|
+
from trailframe.services.garmin_connect_service import GarminConnectService
|
|
17
|
+
from trailframe.services.gpx_service import GpxService
|
|
18
|
+
from trailframe.services.map_service import MapService
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/api/activities", tags=["activities"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ImportResult(BaseModel):
|
|
24
|
+
success: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("", response_model=list[ActivitySummary])
|
|
28
|
+
async def list_activities() -> list[ActivitySummary]:
|
|
29
|
+
return await ActivityService.list_activities()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/garmin", response_model=list[GarminActivitySummary])
|
|
33
|
+
async def list_garmin_activities() -> list[GarminActivitySummary]:
|
|
34
|
+
return await GarminConnectService.list_summaries()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.post("/garmin/sync", response_model=ImportResult)
|
|
38
|
+
async def garmin_sync(credentials: dict[str, Any]) -> ImportResult:
|
|
39
|
+
success = await ActivityService.sync_garmin(credentials.get("email", ""), credentials.get("password", ""))
|
|
40
|
+
|
|
41
|
+
if not success:
|
|
42
|
+
raise HTTPException(status_code=401, detail="Garmin login failed")
|
|
43
|
+
|
|
44
|
+
return ImportResult(success=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/gpx", response_model=list[GpxActivitySummary])
|
|
48
|
+
async def list_gpx_activities() -> list[GpxActivitySummary]:
|
|
49
|
+
return await GpxService.list_summaries()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/gpx/upload", response_model=GpxActivitySummary)
|
|
53
|
+
async def upload_gpx(file: UploadFile = File(...)) -> GpxActivitySummary:
|
|
54
|
+
content = (await file.read()).decode("utf-8", errors="replace")
|
|
55
|
+
|
|
56
|
+
gpx_activity = await GpxService.save(content, file.filename or "activity.gpx")
|
|
57
|
+
|
|
58
|
+
if gpx_activity is None:
|
|
59
|
+
raise HTTPException(status_code=400, detail="Invalid GPX file")
|
|
60
|
+
|
|
61
|
+
return GpxActivitySummary(
|
|
62
|
+
id=gpx_activity.id,
|
|
63
|
+
filename=gpx_activity.filename,
|
|
64
|
+
name=gpx_activity.name,
|
|
65
|
+
activity_type=gpx_activity.activity_type,
|
|
66
|
+
start_time=gpx_activity.start_time,
|
|
67
|
+
distance=gpx_activity.distance,
|
|
68
|
+
duration=gpx_activity.duration,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/gpx/import/{gpx_id}", response_model=Activity)
|
|
73
|
+
async def import_gpx(gpx_id: int) -> Activity:
|
|
74
|
+
activity = await ActivityService.import_gpx(gpx_id)
|
|
75
|
+
|
|
76
|
+
if activity is None:
|
|
77
|
+
raise HTTPException(status_code=404, detail="GPX activity not found")
|
|
78
|
+
|
|
79
|
+
return activity
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.post("/import/{activity_id}", response_model=Activity)
|
|
83
|
+
async def import_activity(activity_id: int) -> Activity:
|
|
84
|
+
activity = await ActivityService.import_activity(activity_id)
|
|
85
|
+
|
|
86
|
+
if activity is None:
|
|
87
|
+
raise HTTPException(status_code=404, detail="Garmin activity not found")
|
|
88
|
+
|
|
89
|
+
return activity
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/{activity_id}/map")
|
|
93
|
+
async def activity_map(activity_id: int) -> FileResponse:
|
|
94
|
+
activity = await ActivityService.get_activity(activity_id)
|
|
95
|
+
|
|
96
|
+
if activity is None or not activity.activity_id:
|
|
97
|
+
raise HTTPException(status_code=404, detail="Activity not found")
|
|
98
|
+
|
|
99
|
+
path = MapService.get_map(activity.activity_id)
|
|
100
|
+
|
|
101
|
+
if not path.exists():
|
|
102
|
+
raise HTTPException(status_code=404, detail="Map not found")
|
|
103
|
+
|
|
104
|
+
return FileResponse(path, media_type="image/svg+xml")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/{activity_id}/traces")
|
|
108
|
+
async def activity_traces(activity_id: int) -> FileResponse:
|
|
109
|
+
activity = await ActivityService.get_activity(activity_id)
|
|
110
|
+
|
|
111
|
+
if activity is None or not activity.activity_id:
|
|
112
|
+
raise HTTPException(status_code=404, detail="Activity not found")
|
|
113
|
+
|
|
114
|
+
path = MapService.get_trace(activity.activity_id)
|
|
115
|
+
|
|
116
|
+
if not path.exists():
|
|
117
|
+
raise HTTPException(status_code=404, detail="Trace not found")
|
|
118
|
+
|
|
119
|
+
return FileResponse(path, media_type="image/svg+xml")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.get("/{activity_id}/photos", response_model=list[PhotoDetail])
|
|
123
|
+
async def activity_photos(activity_id: int) -> list[PhotoDetail]:
|
|
124
|
+
photos = await ActivityService.list_activity_photos(activity_id)
|
|
125
|
+
|
|
126
|
+
return [PhotoDetail.from_photo(photo) for photo in photos]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_zip_response(activity: Activity, photos: list[Photo], overlay_path: Path | None = None) -> Response:
|
|
130
|
+
buffer = BytesIO()
|
|
131
|
+
|
|
132
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
133
|
+
for photo in photos:
|
|
134
|
+
path = FolderService.resolve(photo.path)
|
|
135
|
+
|
|
136
|
+
if path.is_file():
|
|
137
|
+
archive.write(path, arcname=path.name)
|
|
138
|
+
|
|
139
|
+
if overlay_path is not None and overlay_path.is_file():
|
|
140
|
+
archive.write(overlay_path, arcname="map.png")
|
|
141
|
+
|
|
142
|
+
base = re.sub(r"[^A-Za-z0-9._-]+", "_", activity.name or f"activity-{activity.id}").strip("_")
|
|
143
|
+
filename = f"{base}.zip" if base else f"activity-{activity.id}.zip"
|
|
144
|
+
|
|
145
|
+
return Response(
|
|
146
|
+
content=buffer.getvalue(),
|
|
147
|
+
media_type="application/zip",
|
|
148
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.get("/{activity_id}/zip")
|
|
153
|
+
async def activity_zip(activity_id: int) -> Response:
|
|
154
|
+
activity = await ActivityService.get_activity(activity_id)
|
|
155
|
+
|
|
156
|
+
if activity is None:
|
|
157
|
+
raise HTTPException(status_code=404, detail="Activity not found")
|
|
158
|
+
|
|
159
|
+
photos = await ActivityService.list_activity_photos(activity_id)
|
|
160
|
+
|
|
161
|
+
return _build_zip_response(activity, photos)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@router.post("/{activity_id}/zip")
|
|
165
|
+
async def activity_zip_with_overlay(
|
|
166
|
+
activity_id: int,
|
|
167
|
+
overlay: Annotated[UploadFile | None, File()] = None,
|
|
168
|
+
) -> Response:
|
|
169
|
+
activity = await ActivityService.get_activity(activity_id)
|
|
170
|
+
|
|
171
|
+
if activity is None:
|
|
172
|
+
raise HTTPException(status_code=404, detail="Activity not found")
|
|
173
|
+
|
|
174
|
+
photos = await ActivityService.list_activity_photos(activity_id)
|
|
175
|
+
|
|
176
|
+
overlay_path = None
|
|
177
|
+
|
|
178
|
+
if overlay is not None:
|
|
179
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as file:
|
|
180
|
+
file.write(await overlay.read())
|
|
181
|
+
overlay_path = Path(file.name)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
return _build_zip_response(activity, photos, overlay_path)
|
|
185
|
+
finally:
|
|
186
|
+
if overlay_path is not None:
|
|
187
|
+
overlay_path.unlink(missing_ok=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@router.get("/{activity_id}", response_model=Activity)
|
|
191
|
+
async def get_activity(activity_id: int) -> Activity:
|
|
192
|
+
activity = await ActivityService.get_activity(activity_id)
|
|
193
|
+
|
|
194
|
+
if activity is None:
|
|
195
|
+
raise HTTPException(status_code=404, detail="Activity not found")
|
|
196
|
+
|
|
197
|
+
return activity
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@router.delete("/{activity_id}")
|
|
201
|
+
async def delete_activity(activity_id: int) -> None:
|
|
202
|
+
deleted = await ActivityService.delete_activity(activity_id)
|
|
203
|
+
|
|
204
|
+
if not deleted:
|
|
205
|
+
raise HTTPException(status_code=404, detail="Activity not found")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from trailframe.services.configuration_service import ConfigurationService
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/configuration", tags=["configuration"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("")
|
|
11
|
+
async def get_configuration() -> dict[str, Any]:
|
|
12
|
+
return ConfigurationService.to_json()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.post("")
|
|
16
|
+
async def update_configuration(values: dict[str, Any]) -> dict[str, Any]:
|
|
17
|
+
ConfigurationService.from_json(values)
|
|
18
|
+
ConfigurationService.save()
|
|
19
|
+
return ConfigurationService.to_json()
|
trailframe/api/events.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
from fastapi.responses import StreamingResponse
|
|
7
|
+
|
|
8
|
+
from trailframe.services.pipeline_service import PipelineService
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/api/events", tags=["events"])
|
|
11
|
+
|
|
12
|
+
_shutting_down = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def stop_streams(*args) -> None:
|
|
16
|
+
global _shutting_down
|
|
17
|
+
_shutting_down = True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _format_sse(event: str, data: dict[str, Any]) -> str:
|
|
21
|
+
payload = json.dumps(data)
|
|
22
|
+
return f"event: {event}\ndata: {payload}\n\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _event_stream():
|
|
26
|
+
last_message: dict[str, Any] | None = None
|
|
27
|
+
|
|
28
|
+
while not _shutting_down:
|
|
29
|
+
message = PipelineService.get_snapshot()
|
|
30
|
+
|
|
31
|
+
if message != last_message:
|
|
32
|
+
yield _format_sse("pipeline", message)
|
|
33
|
+
last_message = message
|
|
34
|
+
|
|
35
|
+
await asyncio.sleep(0.2)
|
|
36
|
+
yield _format_sse("pipeline", dict({"Text": "Server is shutting down"}))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("")
|
|
40
|
+
async def stream_events() -> StreamingResponse:
|
|
41
|
+
return StreamingResponse(
|
|
42
|
+
_event_stream(),
|
|
43
|
+
media_type="text/event-stream",
|
|
44
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
|
45
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from sqlmodel import select
|
|
7
|
+
|
|
8
|
+
from trailframe.models.activity import Activity
|
|
9
|
+
from trailframe.models.photo import Photo
|
|
10
|
+
from trailframe.services.database_service import DatabaseService
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api/map-data", tags=["map-data"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PhotoPoint(BaseModel):
|
|
16
|
+
id: int
|
|
17
|
+
lat: float
|
|
18
|
+
lon: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ActivityTrace(BaseModel):
|
|
22
|
+
id: int
|
|
23
|
+
name: str | None
|
|
24
|
+
trace: list[list[float]]
|
|
25
|
+
start_time: datetime | None = None
|
|
26
|
+
distance: float | None = None
|
|
27
|
+
duration: float | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MapData(BaseModel):
|
|
31
|
+
photos: list[PhotoPoint]
|
|
32
|
+
activities: list[ActivityTrace]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("", response_model=MapData)
|
|
36
|
+
async def get_map_data(session: AsyncSession = Depends(DatabaseService.get_session)) -> MapData:
|
|
37
|
+
photo_rows = (
|
|
38
|
+
await session.execute(
|
|
39
|
+
select(Photo.id, Photo.latitude, Photo.longitude).where(
|
|
40
|
+
Photo.latitude.is_not(None), Photo.longitude.is_not(None)
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
).all()
|
|
44
|
+
|
|
45
|
+
activity_rows = (
|
|
46
|
+
await session.execute(select(Activity.id, Activity.name, Activity.trace, Activity.start_time, Activity.distance, Activity.duration))
|
|
47
|
+
).all()
|
|
48
|
+
|
|
49
|
+
return MapData(
|
|
50
|
+
photos=[PhotoPoint(id=p[0], lat=p[1], lon=p[2]) for p in photo_rows],
|
|
51
|
+
activities=[
|
|
52
|
+
ActivityTrace(
|
|
53
|
+
id=a[0],
|
|
54
|
+
name=a[1],
|
|
55
|
+
trace=[[pt["lat"], pt["lon"]] for pt in (a[2] or []) if "lat" in pt and "lon" in pt],
|
|
56
|
+
start_time=a[3],
|
|
57
|
+
distance=a[4],
|
|
58
|
+
duration=a[5],
|
|
59
|
+
)
|
|
60
|
+
for a in activity_rows
|
|
61
|
+
if a[2]
|
|
62
|
+
],
|
|
63
|
+
)
|
trailframe/api/models.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
|
|
5
|
+
from trailframe.services.configuration_service import ConfigurationService
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/models", tags=["models"])
|
|
8
|
+
|
|
9
|
+
YOLO_MODELS = {
|
|
10
|
+
"yolo26n.pt": "Nano",
|
|
11
|
+
"yolo26s.pt": "Small",
|
|
12
|
+
"yolo26m.pt": "Medium",
|
|
13
|
+
"yolo26l.pt": "Large",
|
|
14
|
+
"yolo26x.pt": "Extra Large",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _models_folder() -> Path:
|
|
19
|
+
return Path(ConfigurationService.root().get_path_value("general.models_folder", default_value="models"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("")
|
|
23
|
+
async def list_models() -> list[str]:
|
|
24
|
+
folder = _models_folder()
|
|
25
|
+
|
|
26
|
+
if not folder.exists():
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
return [f.name for f in folder.iterdir() if f.suffix == ".pt"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/{name}/download")
|
|
33
|
+
async def download_model(name: str) -> str:
|
|
34
|
+
if name not in YOLO_MODELS:
|
|
35
|
+
raise HTTPException(status_code=400, detail=f"Unknown model: {name}")
|
|
36
|
+
|
|
37
|
+
folder = _models_folder()
|
|
38
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
target = folder / name
|
|
40
|
+
|
|
41
|
+
if target.exists():
|
|
42
|
+
return str(target)
|
|
43
|
+
|
|
44
|
+
from ultralytics.utils import checks
|
|
45
|
+
|
|
46
|
+
url = f"https://github.com/ultralytics/assets/releases/download/v8.4.0/{name}"
|
|
47
|
+
checks.check_file(url, download_dir=str(folder))
|
|
48
|
+
|
|
49
|
+
if not target.exists():
|
|
50
|
+
raise HTTPException(status_code=500, detail=f"Download failed for {name}")
|
|
51
|
+
|
|
52
|
+
return str(target)
|