updated app
This commit is contained in:
157
py_scripts/3D_VIDEO_DOCUMENTATION.md
Normal file
157
py_scripts/3D_VIDEO_DOCUMENTATION.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 3D Video Animation Feature
|
||||
|
||||
## Overview
|
||||
The 3D Video Animation feature generates Relive-style video animations from GPS route data. This creates engaging, cinematic videos that visualize your journey in a 3D perspective.
|
||||
|
||||
## Features
|
||||
|
||||
### Visual Elements
|
||||
- **3D Isometric View**: Perspective projection that simulates 3D depth
|
||||
- **Sky Gradient Background**: Blue gradient background that mimics sky
|
||||
- **Animated Route Trail**: Color-coded path from blue (start) to red (end)
|
||||
- **Pulsing Position Marker**: Animated current position indicator
|
||||
- **Grid Overlay**: 3D grid effect for depth perception
|
||||
- **Real-time Data Display**: Speed, timestamp, and progress information
|
||||
|
||||
### Technical Specifications
|
||||
- **Resolution**: 1920x1080 (Full HD)
|
||||
- **Frame Rate**: 30 FPS
|
||||
- **Format**: MP4 video
|
||||
- **Compression**: MP4V codec for broad compatibility
|
||||
|
||||
### Animation Effects
|
||||
- **Shadow Effects**: Route lines and markers have 3D shadows
|
||||
- **Elevation Simulation**: Simulated terrain elevation using sine waves
|
||||
- **Smooth Transitions**: Interpolated movement between GPS points
|
||||
- **Progress Indicators**: Visual progress through the route
|
||||
|
||||
## Required Libraries
|
||||
|
||||
### Core Dependencies
|
||||
- **OpenCV (cv2)**: Video generation and frame composition
|
||||
- **NumPy**: Mathematical operations and array handling
|
||||
- **PIL/Pillow**: Image processing and text rendering
|
||||
- **Requests**: API calls for elevation data (future enhancement)
|
||||
|
||||
### Optional Enhancements
|
||||
- **MoviePy**: Advanced video editing and effects
|
||||
- **Matplotlib**: Additional visualization options
|
||||
- **SciPy**: Mathematical transformations
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Navigate** to the Create Animation screen
|
||||
2. **Select** a project with GPS route data
|
||||
3. **Click** "Generate 3D Video" button
|
||||
4. **Wait** for processing (can take several minutes)
|
||||
5. **View** the generated video in the project folder
|
||||
|
||||
## Processing Steps
|
||||
|
||||
### 1. Data Loading (10%)
|
||||
- Loads GPS positions from `positions.json`
|
||||
- Validates minimum route length (10+ points)
|
||||
- Calculates route boundaries and center point
|
||||
|
||||
### 2. Route Analysis (20%)
|
||||
- Determines optimal viewport and scaling
|
||||
- Calculates center coordinates for camera position
|
||||
- Sets up coordinate transformation matrices
|
||||
|
||||
### 3. Frame Generation (30-70%)
|
||||
- Creates individual frames for each GPS point
|
||||
- Applies 3D perspective transformation
|
||||
- Renders route trail with color progression
|
||||
- Adds animated markers and text overlays
|
||||
|
||||
### 4. Video Compilation (75-90%)
|
||||
- Combines frames into MP4 video
|
||||
- Applies compression and optimization
|
||||
- Adds metadata and timing information
|
||||
|
||||
### 5. Finalization (90-100%)
|
||||
- Saves video to project folder
|
||||
- Cleans up temporary files
|
||||
- Shows completion notification
|
||||
|
||||
## File Output
|
||||
|
||||
### Naming Convention
|
||||
```
|
||||
{project_name}_3d_animation_{timestamp}.mp4
|
||||
```
|
||||
|
||||
### Example
|
||||
```
|
||||
MyTrip_3d_animation_20250702_143522.mp4
|
||||
```
|
||||
|
||||
### Location
|
||||
Videos are saved in the project folder:
|
||||
```
|
||||
resources/projects/{project_name}/
|
||||
```
|
||||
|
||||
## Customization Options
|
||||
|
||||
### Future Enhancements
|
||||
- **Real Elevation Data**: Integration with elevation APIs
|
||||
- **Custom Colors**: User-selectable color schemes
|
||||
- **Speed Control**: Variable playback speeds
|
||||
- **Camera Angles**: Multiple perspective options
|
||||
- **Terrain Textures**: Realistic ground textures
|
||||
- **Weather Effects**: Animated weather overlays
|
||||
|
||||
### Performance Optimization
|
||||
- **Multi-threading**: Parallel frame generation
|
||||
- **GPU Acceleration**: OpenGL rendering support
|
||||
- **Compression Options**: Quality vs. file size settings
|
||||
- **Preview Mode**: Lower quality for faster processing
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues
|
||||
- **Insufficient GPS Data**: Minimum 10 points required
|
||||
- **Memory Limitations**: Large routes may require optimization
|
||||
- **Storage Space**: Videos can be 50-200MB depending on route length
|
||||
- **Processing Time**: Can take 5-15 minutes for long routes
|
||||
|
||||
### Troubleshooting
|
||||
- **Reduce Route Size**: Use route optimization before generation
|
||||
- **Free Disk Space**: Ensure adequate storage available
|
||||
- **Close Other Apps**: Free memory for processing
|
||||
- **Check File Permissions**: Ensure write access to project folder
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Frame Generation Pipeline
|
||||
```
|
||||
GPS Point → Coordinate Transform → 3D Projection →
|
||||
Visual Effects → Text Overlay → Frame Export
|
||||
```
|
||||
|
||||
### Video Assembly Pipeline
|
||||
```
|
||||
Frame Sequence → Video Encoder → Compression →
|
||||
Metadata Addition → File Output
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
- **Temporary Files**: Frames stored in temp directory
|
||||
- **Batch Processing**: Processes frames in chunks
|
||||
- **Automatic Cleanup**: Removes temporary files after completion
|
||||
|
||||
## Integration
|
||||
|
||||
### UI Integration
|
||||
- **Progress Bar**: Real-time processing updates
|
||||
- **Status Messages**: Step-by-step progress information
|
||||
- **Error Dialogs**: User-friendly error messages
|
||||
- **Result Notification**: Success/failure feedback
|
||||
|
||||
### File System Integration
|
||||
- **Project Structure**: Maintains existing folder organization
|
||||
- **Automatic Naming**: Prevents file name conflicts
|
||||
- **Folder Opening**: Direct access to output location
|
||||
|
||||
This feature transforms GPS tracking data into professional-quality video animations suitable for sharing, presentations, or personal memories.
|
||||
105
py_scripts/PAUSE_EDIT_IMPROVEMENTS.md
Normal file
105
py_scripts/PAUSE_EDIT_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Pause Edit Screen Improvements
|
||||
|
||||
## Overview
|
||||
The pause edit screen has been completely redesigned for better mobile usability and enhanced user experience.
|
||||
|
||||
## New Features Implemented
|
||||
|
||||
### 1. Loading Popup
|
||||
- **Purpose**: Indicates that the app is loading pause data and location suggestions
|
||||
- **Implementation**: Shows a progress bar animation while loading data in background
|
||||
- **User Experience**: Prevents the app from appearing unresponsive during startup
|
||||
|
||||
### 2. Carousel Navigation
|
||||
- **When**: Automatically activated when there are more than 2 pauses
|
||||
- **Features**:
|
||||
- Swipe navigation between pauses
|
||||
- Loop mode for continuous navigation
|
||||
- Visual indicators showing current pause (e.g., "Pause 2 of 5")
|
||||
- **Fallback**: Simple scroll view for 1-2 pauses
|
||||
|
||||
### 3. Vertical Photo Scrolling
|
||||
- **Implementation**: Each pause has a vertical scroll area for photos
|
||||
- **Features**:
|
||||
- Thumbnail image previews (55px width)
|
||||
- Traditional vertical list layout for better mobile usability
|
||||
- Improved photo item styling with borders and file information
|
||||
- View and delete buttons for each photo
|
||||
- File size and format information display
|
||||
|
||||
### 4. Enhanced Location Suggestions
|
||||
- **Caching**: Location suggestions are pre-loaded and cached during startup
|
||||
- **Multi-strategy**: Uses multiple approaches to find meaningful location names
|
||||
- **Fallback**: Graceful degradation to coordinates if no location found
|
||||
|
||||
### 5. Mobile-Optimized UI
|
||||
- **Responsive Design**: Better layout for phone screens
|
||||
- **Touch-Friendly**: Larger buttons and touch targets
|
||||
- **Visual Feedback**: Better borders, colors, and spacing
|
||||
|
||||
### 6. Delete Pause Functionality
|
||||
- **Purpose**: Allow users to completely remove unwanted pauses
|
||||
- **Implementation**: Delete button next to save button for each pause
|
||||
- **Features**:
|
||||
- Confirmation dialog before deletion
|
||||
- Removes pause from locations list
|
||||
- Deletes all associated photos and folder
|
||||
- Automatically reorganizes remaining pause folders
|
||||
- Updates pause numbering sequence
|
||||
|
||||
## Updated Features (Latest Changes)
|
||||
|
||||
### Photo Scrolling Direction Changed
|
||||
- **From**: Horizontal scrolling with large previews
|
||||
- **To**: Vertical scrolling with compact thumbnail layout
|
||||
- **Benefit**: Better mobile usability and more familiar interface
|
||||
|
||||
### Delete Pause Button Added
|
||||
- **Location**: Next to "Save Pause Info" button
|
||||
- **Functionality**: Complete pause removal with confirmation
|
||||
- **Safety**: Confirmation dialog prevents accidental deletion
|
||||
- **Clean-up**: Automatic folder reorganization and numbering
|
||||
|
||||
## File Structure
|
||||
- `pause_edit_screen_improved.py`: New, clean implementation with all features
|
||||
- `pause_edit_screen_legacy.py`: Original file (renamed for backup)
|
||||
- `main.py`: Updated to use the improved version
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Loading Process
|
||||
1. Show loading popup immediately
|
||||
2. Load pause data in background thread
|
||||
3. Pre-process location suggestions
|
||||
4. Build UI on main thread
|
||||
5. Dismiss loading popup
|
||||
|
||||
### Carousel Logic
|
||||
```python
|
||||
if len(pauses) > 2:
|
||||
use_carousel_layout()
|
||||
else:
|
||||
use_simple_scroll_layout()
|
||||
```
|
||||
|
||||
### Photo Scrolling
|
||||
- Vertical ScrollView with `do_scroll_y=True, do_scroll_x=False`
|
||||
- Fixed-height photo items (60px)
|
||||
- Dynamic content height based on number of photos
|
||||
- Thumbnail layout with file information display
|
||||
|
||||
## Benefits
|
||||
1. **Improved Performance**: Background loading prevents UI freezing
|
||||
2. **Better Navigation**: Carousel makes it easy to navigate many pauses
|
||||
3. **Enhanced Photo Management**: Vertical scrolling provides familiar mobile interface
|
||||
4. **Professional Feel**: Loading indicators and smooth animations
|
||||
5. **Mobile-First**: Optimized for touch interaction
|
||||
6. **Complete Control**: Can delete unwanted pauses with safety confirmation
|
||||
7. **Better Organization**: Automatic reorganization maintains clean folder structure
|
||||
|
||||
## Usage
|
||||
The improved screen is now the default pause edit screen in the application. Users will automatically see:
|
||||
- Loading popup on screen entry
|
||||
- Carousel navigation for 3+ pauses
|
||||
- Horizontal photo scrolling in each pause
|
||||
- Cached location suggestions for faster loading
|
||||
2
py_scripts/__init__.py
Normal file
2
py_scripts/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# py_scripts package
|
||||
# Contains utility scripts for the Traccar Animation application
|
||||
BIN
py_scripts/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
py_scripts/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py_scripts/__pycache__/utils.cpython-311.pyc
Normal file
BIN
py_scripts/__pycache__/utils.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py_scripts/__pycache__/video_3d_generator.cpython-311.pyc
Normal file
BIN
py_scripts/__pycache__/video_3d_generator.cpython-311.pyc
Normal file
Binary file not shown.
492
py_scripts/utils.py
Normal file
492
py_scripts/utils.py
Normal file
@@ -0,0 +1,492 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from cryptography.fernet import Fernet
|
||||
import math
|
||||
import datetime
|
||||
RESOURCES_FOLDER = "resources"
|
||||
CREDENTIALS_FILE = os.path.join(RESOURCES_FOLDER, "credentials.enc")
|
||||
KEY_FILE = os.path.join(RESOURCES_FOLDER, "key.key")
|
||||
SERVER_SETTINGS_FILE = os.path.join(RESOURCES_FOLDER, "server_settings.enc")
|
||||
|
||||
# --- Encryption Utilities ---
|
||||
|
||||
def generate_key():
|
||||
"""Generate and save a key for encryption."""
|
||||
if not os.path.exists(KEY_FILE):
|
||||
key = Fernet.generate_key()
|
||||
with open(KEY_FILE, "wb") as key_file:
|
||||
key_file.write(key)
|
||||
|
||||
def load_key():
|
||||
"""Load the encryption key."""
|
||||
with open(KEY_FILE, "rb") as key_file:
|
||||
return key_file.read()
|
||||
|
||||
def encrypt_data(data):
|
||||
"""Encrypt data using the encryption key."""
|
||||
key = load_key()
|
||||
fernet = Fernet(key)
|
||||
return fernet.encrypt(data.encode())
|
||||
|
||||
def decrypt_data(data):
|
||||
"""Decrypt data using the encryption key."""
|
||||
key = load_key()
|
||||
fernet = Fernet(key)
|
||||
return fernet.decrypt(data).decode()
|
||||
|
||||
# --- Server Settings ---
|
||||
def check_server_settings():
|
||||
"""Load and decrypt server settings from file."""
|
||||
if not os.path.exists(SERVER_SETTINGS_FILE):
|
||||
return None
|
||||
try:
|
||||
with open(SERVER_SETTINGS_FILE, "rb") as file:
|
||||
encrypted_data = file.read()
|
||||
decrypted_data = decrypt_data(encrypted_data)
|
||||
settings = json.loads(decrypted_data)
|
||||
return settings
|
||||
except Exception as e:
|
||||
print(f"Failed to load server settings: {e}")
|
||||
return None
|
||||
|
||||
def save_server_settings(settings_data):
|
||||
"""Encrypt and save server settings."""
|
||||
encrypted_data = encrypt_data(json.dumps(settings_data))
|
||||
with open(SERVER_SETTINGS_FILE, "wb") as file:
|
||||
file.write(encrypted_data)
|
||||
|
||||
# --- Traccar Server Connection ---
|
||||
def test_connection(server_url, username=None, password=None, token=None):
|
||||
"""
|
||||
Test the connection with the Traccar server.
|
||||
Returns: dict with 'status' (bool) and 'message' (str)
|
||||
"""
|
||||
if not server_url:
|
||||
return {"status": False, "message": "Please provide the server URL."}
|
||||
if not token and (not username or not password):
|
||||
return {"status": False, "message": "Please provide either a token or username and password."}
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
auth = None if token else (username, password)
|
||||
response = requests.get(f"{server_url}/api/server", headers=headers, auth=auth, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return {"status": True, "message": "Connection successful! Server is reachable."}
|
||||
else:
|
||||
return {"status": False, "message": f"Error: {response.status_code} - {response.reason}"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"status": False, "message": "Connection timed out. Please try again."}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"status": False, "message": f"Connection failed: {str(e)}"}
|
||||
|
||||
# --- Device Fetching ---
|
||||
def get_devices_from_server():
|
||||
"""Retrieve a mapping of device names to IDs from the Traccar server."""
|
||||
settings = check_server_settings()
|
||||
if not settings:
|
||||
return None
|
||||
server_url = settings.get("server_url")
|
||||
token = settings.get("token")
|
||||
if not server_url or not token:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
try:
|
||||
response = requests.get(f"{server_url}/api/devices", headers=headers)
|
||||
if response.status_code == 200:
|
||||
devices = response.json()
|
||||
return {device.get("name", "Unnamed Device"): device.get("id", "Unknown ID") for device in devices}
|
||||
else:
|
||||
print(f"Error: {response.status_code} - {response.reason}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error retrieving devices: {str(e)}")
|
||||
return None
|
||||
|
||||
# --- Route Saving ---
|
||||
def save_route_to_file(route_name, positions, base_folder="resources/projects"):
|
||||
"""
|
||||
Save the given positions as a route in resources/projects/<route_name>/positions.json.
|
||||
Returns (success, message, file_path)
|
||||
"""
|
||||
if not route_name:
|
||||
return False, "Please enter a route name.", None
|
||||
if not positions:
|
||||
return False, "No positions to save.", None
|
||||
|
||||
folder_path = os.path.join(base_folder, route_name)
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
file_path = os.path.join(folder_path, "positions.json")
|
||||
try:
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(positions, f, indent=2)
|
||||
return True, f"Route '{route_name}' saved!", file_path
|
||||
except Exception as e:
|
||||
return False, f"Failed to save route: {str(e)}", None
|
||||
|
||||
def fetch_positions(server_url, token, device_id, from_time, to_time):
|
||||
"""
|
||||
Fetch positions from the Traccar API.
|
||||
Returns (positions, error_message)
|
||||
"""
|
||||
url = f"{server_url}/api/reports/route"
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
params = {
|
||||
"deviceId": device_id,
|
||||
"from": from_time,
|
||||
"to": to_time
|
||||
}
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=15)
|
||||
if response.status_code == 200:
|
||||
return response.json(), None
|
||||
elif response.status_code == 400:
|
||||
return None, "Bad Request: Please check the request payload and token."
|
||||
else:
|
||||
return None, f"Failed: {response.status_code} - {response.reason}"
|
||||
except requests.exceptions.RequestException as e:
|
||||
return None, f"Error fetching positions: {str(e)}"
|
||||
|
||||
def fetch_positions_for_selected_day(settings, device_mapping, device_name, start_date, end_date, start_hour, end_hour):
|
||||
"""
|
||||
Fetch positions for the selected day/device using Traccar API.
|
||||
Returns (positions, error_message)
|
||||
"""
|
||||
if not settings:
|
||||
return [], "Server settings not found."
|
||||
|
||||
server_url = settings.get("server_url")
|
||||
token = settings.get("token")
|
||||
device_id = device_mapping.get(device_name)
|
||||
if not device_id:
|
||||
return [], "Device ID not found."
|
||||
|
||||
from_time = f"{start_date}T{start_hour}:00:00Z"
|
||||
to_time = f"{end_date}T{end_hour}:59:59Z"
|
||||
|
||||
positions, error = fetch_positions(server_url, token, device_id, from_time, to_time)
|
||||
if error:
|
||||
return [], error
|
||||
return positions, None
|
||||
|
||||
def html_to_image(html_path, img_path, width=1280, height=720, delay=2, driver_path='/usr/bin/chromedriver'):
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from PIL import Image
|
||||
import time
|
||||
import os
|
||||
|
||||
selenium_height = int(height * 1.2) # 10% taller for compensation
|
||||
|
||||
chrome_options = Options()
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument(f"--window-size={width},{selenium_height}")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
service = Service(driver_path)
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
try:
|
||||
driver.set_window_size(width, selenium_height)
|
||||
driver.get("file://" + os.path.abspath(html_path))
|
||||
time.sleep(delay)
|
||||
tmp_img = img_path + ".tmp.png"
|
||||
driver.save_screenshot(tmp_img)
|
||||
driver.quit()
|
||||
|
||||
img = Image.open(tmp_img)
|
||||
img = img.crop((0, 0, width, height)) # Crop to original map size
|
||||
img.save(img_path)
|
||||
os.remove(tmp_img)
|
||||
print(f"Image saved to: {img_path} ({width}x{height})")
|
||||
except Exception as e:
|
||||
print(f"Error converting HTML to image: {e}")
|
||||
driver.quit()
|
||||
|
||||
def process_preview_util(
|
||||
project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
preview_image_widget,
|
||||
set_preview_image_path,
|
||||
Clock,
|
||||
width=800,
|
||||
height=600
|
||||
):
|
||||
import folium
|
||||
import os
|
||||
import json
|
||||
# Import html_to_image function from within the same module
|
||||
# (it's defined later in this file)
|
||||
|
||||
try:
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
html_path = os.path.join(project_folder, "preview.html")
|
||||
img_path = os.path.join(project_folder, "preview.png")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
label.text = "positions.json not found!"
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
with open(positions_path, "r") as f:
|
||||
positions = json.load(f)
|
||||
|
||||
if not positions:
|
||||
label.text = "No positions to preview."
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
coords = [(pos['latitude'], pos['longitude']) for pos in positions]
|
||||
width, height = 1280, 720 # 16:9 HD
|
||||
|
||||
m = folium.Map(
|
||||
location=coords[0],
|
||||
width=width,
|
||||
height=height,
|
||||
control_scale=True
|
||||
)
|
||||
folium.PolyLine(coords, color="blue", weight=4.5, opacity=1).add_to(m)
|
||||
folium.Marker(coords[0], tooltip="Start", icon=folium.Icon(color="green")).add_to(m)
|
||||
folium.Marker(coords[-1], tooltip="End", icon=folium.Icon(color="red")).add_to(m)
|
||||
|
||||
# --- Add pause markers if pauses.json exists ---
|
||||
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||
if os.path.exists(pauses_path):
|
||||
with open(pauses_path, "r") as pf:
|
||||
pauses = json.load(pf)
|
||||
for pause in pauses:
|
||||
lat = pause["location"]["latitude"]
|
||||
lon = pause["location"]["longitude"]
|
||||
duration = pause["duration_seconds"]
|
||||
start = pause["start_time"]
|
||||
end = pause["end_time"]
|
||||
folium.Marker(
|
||||
[lat, lon],
|
||||
tooltip=f"Pause: {duration//60} min {duration%60} sec",
|
||||
popup=f"Pause from {start} to {end} ({duration//60} min {duration%60} sec)",
|
||||
icon=folium.Icon(color="orange", icon="pause", prefix="fa")
|
||||
).add_to(m)
|
||||
|
||||
m.fit_bounds(coords, padding=(80, 80))
|
||||
m.get_root().html.add_child(folium.Element(f"""
|
||||
<style>
|
||||
html, body {{
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}}
|
||||
#{m.get_name()} {{
|
||||
position: absolute;
|
||||
top: 0; bottom: 0; left: 0; right: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}}
|
||||
</style>
|
||||
"""))
|
||||
m.save(html_path)
|
||||
|
||||
html_to_image(html_path, img_path, width=width, height=height)
|
||||
|
||||
set_preview_image_path(img_path)
|
||||
preview_image_widget.reload()
|
||||
label.text = "Preview ready!"
|
||||
progress.value = 100
|
||||
|
||||
def close_popup(dt):
|
||||
popup.dismiss()
|
||||
Clock.schedule_once(close_popup, 1)
|
||||
|
||||
except Exception as e:
|
||||
label.text = f"Error: {e}"
|
||||
progress.value = 100
|
||||
def close_popup(dt):
|
||||
popup.dismiss()
|
||||
Clock.schedule_once(close_popup, 2)
|
||||
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
# Returns distance in meters between two lat/lon points
|
||||
R = 6371000 # Earth radius in meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
def optimize_route_entries_util(
|
||||
project_name,
|
||||
RESOURCES_FOLDER,
|
||||
label,
|
||||
progress,
|
||||
popup,
|
||||
Clock,
|
||||
on_save=None
|
||||
):
|
||||
def process_entries(dt):
|
||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||
if not os.path.exists(positions_path):
|
||||
label.text = "positions.json not found!"
|
||||
progress.value = 100
|
||||
return
|
||||
|
||||
with open(positions_path, "r") as f:
|
||||
positions = json.load(f)
|
||||
|
||||
# Detect duplicate positions at the start
|
||||
start_remove = 0
|
||||
if positions:
|
||||
first = positions[0]
|
||||
for pos in positions:
|
||||
if pos['latitude'] == first['latitude'] and pos['longitude'] == first['longitude']:
|
||||
start_remove += 1
|
||||
else:
|
||||
break
|
||||
if start_remove > 0:
|
||||
start_remove -= 1
|
||||
|
||||
# Detect duplicate positions at the end
|
||||
end_remove = 0
|
||||
if positions:
|
||||
last = positions[-1]
|
||||
for pos in reversed(positions):
|
||||
if pos['latitude'] == last['latitude'] and pos['longitude'] == last['longitude']:
|
||||
end_remove += 1
|
||||
else:
|
||||
break
|
||||
if end_remove > 0:
|
||||
end_remove -= 1
|
||||
|
||||
# Shorten the positions list
|
||||
new_positions = positions[start_remove:len(positions)-end_remove if end_remove > 0 else None]
|
||||
|
||||
# --- PAUSE DETECTION ---
|
||||
pauses = []
|
||||
if new_positions:
|
||||
pause_start = None
|
||||
pause_end = None
|
||||
pause_location = None
|
||||
for i in range(1, len(new_positions)):
|
||||
prev = new_positions[i-1]
|
||||
curr = new_positions[i]
|
||||
# Check if stopped (same location)
|
||||
if curr['latitude'] == prev['latitude'] and curr['longitude'] == prev['longitude']:
|
||||
if pause_start is None:
|
||||
pause_start = prev['deviceTime']
|
||||
pause_location = (prev['latitude'], prev['longitude'])
|
||||
pause_end = curr['deviceTime']
|
||||
else:
|
||||
if pause_start and pause_end:
|
||||
# Calculate pause duration
|
||||
t1 = datetime.datetime.fromisoformat(pause_start.replace('Z', '+00:00'))
|
||||
t2 = datetime.datetime.fromisoformat(pause_end.replace('Z', '+00:00'))
|
||||
duration = (t2 - t1).total_seconds()
|
||||
if duration >= 120:
|
||||
pauses.append({
|
||||
"start_time": pause_start,
|
||||
"end_time": pause_end,
|
||||
"duration_seconds": int(duration),
|
||||
"location": {"latitude": pause_location[0], "longitude": pause_location[1]}
|
||||
})
|
||||
pause_start = None
|
||||
pause_end = None
|
||||
pause_location = None
|
||||
# Check for pause at the end
|
||||
if pause_start and pause_end:
|
||||
t1 = datetime.datetime.fromisoformat(pause_start.replace('Z', '+00:00'))
|
||||
t2 = datetime.datetime.fromisoformat(pause_end.replace('Z', '+00:00'))
|
||||
duration = (t2 - t1).total_seconds()
|
||||
if duration >= 120:
|
||||
pauses.append({
|
||||
"start_time": pause_start,
|
||||
"end_time": pause_end,
|
||||
"duration_seconds": int(duration),
|
||||
"location": {"latitude": pause_location[0], "longitude": pause_location[1]}
|
||||
})
|
||||
|
||||
# --- FILTER PAUSES ---
|
||||
# 1. Remove pauses near start/end
|
||||
filtered_pauses = []
|
||||
if new_positions and pauses:
|
||||
start_lat, start_lon = new_positions[0]['latitude'], new_positions[0]['longitude']
|
||||
end_lat, end_lon = new_positions[-1]['latitude'], new_positions[-1]['longitude']
|
||||
for pause in pauses:
|
||||
plat = pause["location"]["latitude"]
|
||||
plon = pause["location"]["longitude"]
|
||||
dist_start = haversine(start_lat, start_lon, plat, plon)
|
||||
dist_end = haversine(end_lat, end_lon, plat, plon)
|
||||
if dist_start < 50 or dist_end < 50:
|
||||
continue # Skip pauses near start or end
|
||||
filtered_pauses.append(pause)
|
||||
else:
|
||||
filtered_pauses = pauses
|
||||
|
||||
# 2. Merge pauses close in time and space
|
||||
merged_pauses = []
|
||||
filtered_pauses.sort(key=lambda p: p["start_time"])
|
||||
for pause in filtered_pauses:
|
||||
if not merged_pauses:
|
||||
merged_pauses.append(pause)
|
||||
else:
|
||||
last = merged_pauses[-1]
|
||||
# Time difference in seconds
|
||||
t1 = datetime.datetime.fromisoformat(last["end_time"].replace('Z', '+00:00'))
|
||||
t2 = datetime.datetime.fromisoformat(pause["start_time"].replace('Z', '+00:00'))
|
||||
time_diff = (t2 - t1).total_seconds()
|
||||
# Distance in meters
|
||||
last_lat = last["location"]["latitude"]
|
||||
last_lon = last["location"]["longitude"]
|
||||
plat = pause["location"]["latitude"]
|
||||
plon = pause["location"]["longitude"]
|
||||
dist = haversine(last_lat, last_lon, plat, plon)
|
||||
if time_diff < 300 and dist < 50:
|
||||
# Merge: extend last pause's end_time and duration
|
||||
last["end_time"] = pause["end_time"]
|
||||
last["duration_seconds"] += pause["duration_seconds"]
|
||||
else:
|
||||
merged_pauses.append(pause)
|
||||
pauses = merged_pauses
|
||||
|
||||
progress.value = 100
|
||||
label.text = (
|
||||
f"Entries removable at start: {start_remove}\n"
|
||||
f"Entries removable at end: {end_remove}\n"
|
||||
f"Detected pauses: {len(pauses)}"
|
||||
)
|
||||
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
|
||||
btn_save = Button(text="Save optimized file", background_color=(0.008, 0.525, 0.290, 1))
|
||||
btn_cancel = Button(text="Cancel")
|
||||
btn_box = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=44)
|
||||
btn_box.add_widget(btn_save)
|
||||
btn_box.add_widget(btn_cancel)
|
||||
popup.content.add_widget(btn_box)
|
||||
|
||||
def save_optimized(instance):
|
||||
with open(positions_path, "w") as f:
|
||||
json.dump(new_positions, f, indent=2)
|
||||
with open(pauses_path, "w") as f:
|
||||
json.dump(pauses, f, indent=2)
|
||||
label.text = "File optimized and pauses saved!"
|
||||
btn_save.disabled = True
|
||||
btn_cancel.disabled = True
|
||||
def close_and_refresh(dt):
|
||||
popup.dismiss()
|
||||
if on_save:
|
||||
on_save()
|
||||
Clock.schedule_once(close_and_refresh, 1)
|
||||
|
||||
btn_save.bind(on_press=save_optimized)
|
||||
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||
|
||||
Clock.schedule_once(process_entries, 0.5)
|
||||
296
py_scripts/video_3d_generator.py
Normal file
296
py_scripts/video_3d_generator.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
3D Video Animation Generator
|
||||
Creates Relive-style 3D video animations from GPS route data
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import math
|
||||
import requests
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
def generate_3d_video_animation(project_name, resources_folder, label_widget, progress_widget, popup_widget, clock_module):
|
||||
"""
|
||||
Generate a 3D video animation similar to Relive
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
resources_folder: Path to resources folder
|
||||
label_widget: Kivy label for status updates
|
||||
progress_widget: Kivy progress bar
|
||||
popup_widget: Kivy popup to dismiss when done
|
||||
clock_module: Kivy Clock module for scheduling
|
||||
"""
|
||||
|
||||
def update_progress(progress_val, status_text):
|
||||
"""Update UI from background thread"""
|
||||
def _update(dt):
|
||||
progress_widget.value = progress_val
|
||||
label_widget.text = status_text
|
||||
clock_module.schedule_once(_update, 0)
|
||||
|
||||
def finish_generation(success, message, output_path=None):
|
||||
"""Finish the generation process"""
|
||||
def _finish(dt):
|
||||
if popup_widget:
|
||||
popup_widget.dismiss()
|
||||
|
||||
# Show result popup
|
||||
from kivy.uix.popup import Popup
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.label import Label
|
||||
|
||||
result_layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||
|
||||
if success:
|
||||
result_label = Label(
|
||||
text=f"3D Video Generated Successfully!\n\nSaved to:\n{output_path}",
|
||||
color=(0, 1, 0, 1),
|
||||
halign="center"
|
||||
)
|
||||
open_btn = Button(
|
||||
text="Open Video Folder",
|
||||
size_hint_y=None,
|
||||
height=40,
|
||||
background_color=(0.2, 0.7, 0.2, 1)
|
||||
)
|
||||
open_btn.bind(on_press=lambda x: (os.system(f"xdg-open '{os.path.dirname(output_path)}'"), result_popup.dismiss()))
|
||||
result_layout.add_widget(result_label)
|
||||
result_layout.add_widget(open_btn)
|
||||
else:
|
||||
result_label = Label(
|
||||
text=f"Generation Failed:\n{message}",
|
||||
color=(1, 0, 0, 1),
|
||||
halign="center"
|
||||
)
|
||||
result_layout.add_widget(result_label)
|
||||
|
||||
close_btn = Button(
|
||||
text="Close",
|
||||
size_hint_y=None,
|
||||
height=40,
|
||||
background_color=(0.3, 0.3, 0.3, 1)
|
||||
)
|
||||
|
||||
result_layout.add_widget(close_btn)
|
||||
|
||||
result_popup = Popup(
|
||||
title="3D Video Generation Result",
|
||||
content=result_layout,
|
||||
size_hint=(0.9, 0.6),
|
||||
auto_dismiss=False
|
||||
)
|
||||
|
||||
close_btn.bind(on_press=lambda x: result_popup.dismiss())
|
||||
result_popup.open()
|
||||
|
||||
clock_module.schedule_once(_finish, 0)
|
||||
|
||||
def run_generation():
|
||||
"""Main generation function"""
|
||||
try:
|
||||
# Step 1: Load route data
|
||||
update_progress(10, "Loading route data...")
|
||||
project_folder = os.path.join(resources_folder, "projects", project_name)
|
||||
positions_path = os.path.join(project_folder, "positions.json")
|
||||
|
||||
if not os.path.exists(positions_path):
|
||||
finish_generation(False, "No route data found!")
|
||||
return
|
||||
|
||||
with open(positions_path, "r") as f:
|
||||
positions = json.load(f)
|
||||
|
||||
if len(positions) < 10:
|
||||
finish_generation(False, "Route too short for 3D animation (minimum 10 points)")
|
||||
return
|
||||
|
||||
# Step 2: Calculate route bounds and center
|
||||
update_progress(20, "Calculating route boundaries...")
|
||||
lats = [pos['latitude'] for pos in positions]
|
||||
lons = [pos['longitude'] for pos in positions]
|
||||
|
||||
center_lat = sum(lats) / len(lats)
|
||||
center_lon = sum(lons) / len(lons)
|
||||
|
||||
min_lat, max_lat = min(lats), max(lats)
|
||||
min_lon, max_lon = min(lons), max(lons)
|
||||
|
||||
# Step 3: Generate frames
|
||||
update_progress(30, "Generating 3D frames...")
|
||||
|
||||
# Create temporary directory for frames
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
frames_dir = os.path.join(temp_dir, "frames")
|
||||
os.makedirs(frames_dir)
|
||||
|
||||
# Video settings
|
||||
width, height = 1920, 1080
|
||||
fps = 30
|
||||
total_frames = len(positions) * 2 # 2 frames per position for smooth animation
|
||||
|
||||
# Generate frames
|
||||
for i, pos in enumerate(positions):
|
||||
progress = 30 + (i / len(positions)) * 40
|
||||
update_progress(progress, f"Generating frame {i+1}/{len(positions)}...")
|
||||
|
||||
frame = create_3d_frame(
|
||||
pos, positions, i, center_lat, center_lon,
|
||||
min_lat, max_lat, min_lon, max_lon,
|
||||
width, height
|
||||
)
|
||||
|
||||
# Save frame
|
||||
frame_path = os.path.join(frames_dir, f"frame_{i:06d}.png")
|
||||
cv2.imwrite(frame_path, frame)
|
||||
|
||||
# Step 4: Create video
|
||||
update_progress(75, "Compiling video...")
|
||||
|
||||
# Output path
|
||||
output_filename = f"{project_name}_3d_animation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4"
|
||||
output_path = os.path.join(project_folder, output_filename)
|
||||
|
||||
# Create video writer
|
||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||
video_writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
||||
|
||||
# Add frames to video
|
||||
frame_files = sorted([f for f in os.listdir(frames_dir) if f.endswith('.png')])
|
||||
for frame_file in frame_files:
|
||||
frame_path = os.path.join(frames_dir, frame_file)
|
||||
frame = cv2.imread(frame_path)
|
||||
video_writer.write(frame)
|
||||
|
||||
video_writer.release()
|
||||
|
||||
# Step 5: Add audio (optional)
|
||||
update_progress(90, "Adding finishing touches...")
|
||||
|
||||
# Clean up
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
update_progress(100, "3D Video generated successfully!")
|
||||
finish_generation(True, "Success!", output_path)
|
||||
|
||||
except Exception as e:
|
||||
finish_generation(False, str(e))
|
||||
|
||||
# Start generation in background
|
||||
import threading
|
||||
thread = threading.Thread(target=run_generation)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def create_3d_frame(current_pos, all_positions, frame_index, center_lat, center_lon,
|
||||
min_lat, max_lat, min_lon, max_lon, width, height):
|
||||
"""
|
||||
Create a single 3D-style frame
|
||||
"""
|
||||
# Create canvas
|
||||
frame = np.zeros((height, width, 3), dtype=np.uint8)
|
||||
|
||||
# Background gradient (sky effect)
|
||||
for y in range(height):
|
||||
color_intensity = int(255 * (1 - y / height))
|
||||
sky_color = (min(255, color_intensity + 50), min(255, color_intensity + 100), 255)
|
||||
frame[y, :] = sky_color
|
||||
|
||||
# Calculate perspective transformation
|
||||
# Simple isometric-style projection
|
||||
scale_x = width * 0.6 / (max_lon - min_lon) if max_lon != min_lon else 1000
|
||||
scale_y = height * 0.6 / (max_lat - min_lat) if max_lat != min_lat else 1000
|
||||
|
||||
# Draw route path with 3D effect
|
||||
route_points = []
|
||||
for i, pos in enumerate(all_positions[:frame_index + 1]):
|
||||
# Convert GPS to screen coordinates
|
||||
x = int((pos['longitude'] - min_lon) * scale_x + width * 0.2)
|
||||
y = int(height * 0.8 - (pos['latitude'] - min_lat) * scale_y)
|
||||
|
||||
# Add 3D effect (elevation simulation)
|
||||
elevation_offset = int(20 * math.sin(i * 0.1)) # Simulated elevation
|
||||
y -= elevation_offset
|
||||
|
||||
route_points.append((x, y))
|
||||
|
||||
# Draw route trail with gradient
|
||||
if len(route_points) > 1:
|
||||
for i in range(1, len(route_points)):
|
||||
# Color gradient from blue to red
|
||||
progress = i / len(route_points)
|
||||
color_r = int(255 * progress)
|
||||
color_b = int(255 * (1 - progress))
|
||||
color = (color_b, 100, color_r)
|
||||
|
||||
# Draw thick line with 3D shadow effect
|
||||
pt1, pt2 = route_points[i-1], route_points[i]
|
||||
|
||||
# Shadow
|
||||
cv2.line(frame, (pt1[0]+2, pt1[1]+2), (pt2[0]+2, pt2[1]+2), (50, 50, 50), 8)
|
||||
# Main line
|
||||
cv2.line(frame, pt1, pt2, color, 6)
|
||||
|
||||
# Draw current position marker
|
||||
if route_points:
|
||||
current_point = route_points[-1]
|
||||
# Pulsing effect
|
||||
pulse_size = int(15 + 10 * math.sin(frame_index * 0.3))
|
||||
|
||||
# Shadow
|
||||
cv2.circle(frame, (current_point[0]+3, current_point[1]+3), pulse_size, (0, 0, 0), -1)
|
||||
# Main marker
|
||||
cv2.circle(frame, current_point, pulse_size, (0, 255, 255), -1)
|
||||
cv2.circle(frame, current_point, pulse_size-3, (255, 255, 255), 2)
|
||||
|
||||
# Add grid effect for 3D feel
|
||||
grid_spacing = 50
|
||||
for x in range(0, width, grid_spacing):
|
||||
cv2.line(frame, (x, 0), (x, height), (100, 100, 100), 1)
|
||||
for y in range(0, height, grid_spacing):
|
||||
cv2.line(frame, (0, y), (width, y), (100, 100, 100), 1)
|
||||
|
||||
# Add text overlay
|
||||
try:
|
||||
# Position info
|
||||
speed = current_pos.get('speed', 0) if current_pos else 0
|
||||
timestamp = current_pos.get('deviceTime', '') if current_pos else ''
|
||||
|
||||
text_y = 50
|
||||
cv2.putText(frame, f"Speed: {speed:.1f} km/h", (50, text_y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||
|
||||
text_y += 40
|
||||
if timestamp:
|
||||
cv2.putText(frame, f"Time: {timestamp[:16]}", (50, text_y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
|
||||
|
||||
text_y += 40
|
||||
cv2.putText(frame, f"Point: {frame_index + 1}/{len(all_positions)}", (50, text_y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
|
||||
|
||||
except Exception:
|
||||
pass # Skip text if font issues
|
||||
|
||||
return frame
|
||||
|
||||
def get_elevation_data(lat, lon):
|
||||
"""
|
||||
Get elevation data for a coordinate (optional enhancement)
|
||||
"""
|
||||
try:
|
||||
# Using a free elevation API
|
||||
url = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}"
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data['results'][0]['elevation']
|
||||
except Exception:
|
||||
pass
|
||||
return 0 # Default elevation
|
||||
15
py_scripts/webview.py
Normal file
15
py_scripts/webview.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
chrome_options = Options()
|
||||
chrome_options.add_argument("--headless")
|
||||
chrome_options.add_argument("--window-size=800,600")
|
||||
chrome_options.add_argument("--no-sandbox")
|
||||
chrome_options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
service = Service('/usr/bin/chromedriver')
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
driver.get("https://www.google.com")
|
||||
driver.save_screenshot("/home/pi/Desktop/test.png")
|
||||
driver.quit()
|
||||
Reference in New Issue
Block a user