Compare commits
21 Commits
5627c790f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7df9de12ce | |||
| 9f8c1c27dc | |||
| 1d0dc05a7b | |||
| 911143dfc5 | |||
| 29fd68f732 | |||
| 4fa7ed2a48 | |||
| 35d3bb8442 | |||
| 507f526433 | |||
| a565cd67e1 | |||
| 2532bf6219 | |||
| a38e2b1fe9 | |||
| c28be4e083 | |||
| 291e5bab44 | |||
| 3ccbf72599 | |||
| cb632752a3 | |||
| 0cc77fd89a | |||
| 069227abf9 | |||
| 6cac2381cd | |||
| fa3a11ee4b | |||
| 0ebdbc6b74 | |||
| 6240042901 |
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,2 +1,39 @@
|
|||||||
# Ignore the virtual environment folder
|
# Ignore the virtual environment folder
|
||||||
track/
|
track/
|
||||||
|
|
||||||
|
# Ignore Python cache files
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Ignore project data and generated files
|
||||||
|
resources/projects/
|
||||||
|
resources/trip_archive/
|
||||||
|
resources/credentials.enc
|
||||||
|
resources/key.key
|
||||||
|
resources/server_settings.enc
|
||||||
|
|
||||||
|
# Ignore generated videos and frames
|
||||||
|
*.mp4
|
||||||
|
*.avi
|
||||||
|
*.mov
|
||||||
|
*.webm
|
||||||
|
cinema_frames/
|
||||||
|
progressive_frames/
|
||||||
|
|
||||||
|
# Ignore test files and temporary files
|
||||||
|
test_*.py
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Ignore IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Ignore OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
8
main.py
8
main.py
@@ -5,7 +5,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
from kivy.properties import StringProperty, ListProperty
|
from kivy.properties import StringProperty, ListProperty
|
||||||
from utils import (
|
from py_scripts.utils import (
|
||||||
generate_key, load_key, encrypt_data, decrypt_data,
|
generate_key, load_key, encrypt_data, decrypt_data,
|
||||||
check_server_settings, save_server_settings,
|
check_server_settings, save_server_settings,
|
||||||
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
||||||
@@ -32,7 +32,8 @@ from screens.get_trip_from_server import GetTripFromServer
|
|||||||
from screens.create_animation_screen import CreateAnimationScreen
|
from screens.create_animation_screen import CreateAnimationScreen
|
||||||
from screens.settings_screen import SettingsScreen
|
from screens.settings_screen import SettingsScreen
|
||||||
from screens.settings_screen import RegisterScreen
|
from screens.settings_screen import RegisterScreen
|
||||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
from screens.pause_edit_screen_improved import PauseEditScreen
|
||||||
|
from config import RESOURCES_FOLDER
|
||||||
kivy.require("2.0.0")
|
kivy.require("2.0.0")
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
Window.size = (360, 780)
|
Window.size = (360, 780)
|
||||||
@@ -49,8 +50,9 @@ class TraccarApp(App):
|
|||||||
sm.add_widget(SettingsScreen(name="settings"))
|
sm.add_widget(SettingsScreen(name="settings"))
|
||||||
sm.add_widget(RegisterScreen(name="register"))
|
sm.add_widget(RegisterScreen(name="register"))
|
||||||
sm.add_widget(CreateAnimationScreen(name="create_animation"))
|
sm.add_widget(CreateAnimationScreen(name="create_animation"))
|
||||||
|
sm.add_widget(PauseEditScreen(name="pause_edit"))
|
||||||
print("Screens added to ScreenManager:", [screen.name for screen in sm.screens])
|
print("Screens added to ScreenManager:", [screen.name for screen in sm.screens])
|
||||||
return sm
|
return sm
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
TraccarApp().run()
|
TraccarApp().run()
|
||||||
46
proba
46
proba
@@ -1,46 +0,0 @@
|
|||||||
|
|
||||||
class CreateAnimationScreen(Screen):
|
|
||||||
project_name = StringProperty("")
|
|
||||||
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
|
||||||
preview_image_path = StringProperty("") # Add this line
|
|
||||||
|
|
||||||
def on_pre_enter(self):
|
|
||||||
# Update the route entries label with the actual number of entries
|
|
||||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
|
||||||
positions_path = os.path.join(project_folder, "positions.json")
|
|
||||||
count = 0
|
|
||||||
if os.path.exists(positions_path):
|
|
||||||
with open(positions_path, "r") as f:
|
|
||||||
try:
|
|
||||||
positions = json.load(f)
|
|
||||||
count = len(positions)
|
|
||||||
except Exception:
|
|
||||||
count = 0
|
|
||||||
self.ids.route_entries_label.text = f"Your route has {count} entries,"
|
|
||||||
|
|
||||||
def open_rename_popup(self):
|
|
||||||
from kivy.uix.popup import Popup
|
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
|
||||||
from kivy.uix.button import Button
|
|
||||||
from kivy.uix.textinput import TextInput
|
|
||||||
from kivy.uix.label import Label
|
|
||||||
|
|
||||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
|
||||||
label = Label(text="Enter new project name:")
|
|
||||||
input_field = TextInput(text=self.project_name, multiline=False)
|
|
||||||
btn_save = Button(text="Save", background_color=(0.008, 0.525, 0.290, 1))
|
|
||||||
btn_cancel = Button(text="Cancel")
|
|
||||||
|
|
||||||
layout.add_widget(label)
|
|
||||||
layout.add_widget(input_field)
|
|
||||||
layout.add_widget(btn_save)
|
|
||||||
layout.add_widget(btn_cancel)
|
|
||||||
|
|
||||||
popup = Popup(title="Rename Project", content=layout, size_hint=(None, None), size=(350, 260), auto_dismiss=False)
|
|
||||||
|
|
||||||
def do_rename(instance):
|
|
||||||
new_name = input_field.text.strip()
|
|
||||||
if new_name and new_name != self.project_name:
|
|
||||||
if self.rename_project_folder(self.project_name, new_name):
|
|
||||||
self.project_name = new_name
|
|
||||||
popup.dismiss()
|
|
||||||
242
py_scripts/3D_VIDEO_DOCUMENTATION.md
Normal file
242
py_scripts/3D_VIDEO_DOCUMENTATION.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Professional Google Earth-Style 3D Video Animation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Professional Google Earth-Style 3D Video Animation feature generates cinematic, high-quality video animations from GPS route data with realistic space entry sequences. This system creates authentic Google Earth-style visuals with professional terrain rendering, atmospheric effects, and spectacular space-to-Earth transitions.
|
||||||
|
|
||||||
|
## Major Visual Enhancements
|
||||||
|
|
||||||
|
### Realistic Google Earth Visuals
|
||||||
|
- **Authentic Earth Sphere Rendering**: Realistic planetary view from space with proper curvature
|
||||||
|
- **Professional Terrain Textures**: Multi-layer terrain with forests, mountains, plains, deserts, and water bodies
|
||||||
|
- **Geographic Feature Simulation**: Coastlines, rivers, and landmasses with fractal-like detail
|
||||||
|
- **Atmospheric Scattering**: Realistic atmospheric effects and color gradients
|
||||||
|
- **Cloud Layer Rendering**: Dynamic cloud formations with proper shadows
|
||||||
|
|
||||||
|
### Enhanced Space Entry Sequence
|
||||||
|
- **Spectacular Space View**: Authentic space background with star fields and Earth sphere
|
||||||
|
- **Realistic Atmospheric Entry**: Progressive transition through atmospheric layers
|
||||||
|
- **Earth's Terminator Line**: Day/night boundary visible at high altitudes
|
||||||
|
- **Professional UI**: Google Earth-style information panels and progress indicators
|
||||||
|
- **Cinematic Descent**: Smooth altitude progression from 50km to route level
|
||||||
|
|
||||||
|
### Advanced Terrain System
|
||||||
|
- **Multi-Octave Terrain Generation**: Realistic landscape using multiple noise layers
|
||||||
|
- **Geographic Coordinate Influence**: Terrain varies based on actual GPS coordinates
|
||||||
|
- **Atmospheric Perspective**: Distance-based color shifts and haze effects
|
||||||
|
- **Cloud Shadow Mapping**: Realistic shadow patterns on terrain
|
||||||
|
- **Enhanced Color Palette**: Professional color schemes for different terrain types
|
||||||
|
|
||||||
|
### Professional UI Elements
|
||||||
|
- **Information Panel**: Speed, bearing, altitude, time, and progress with gradients
|
||||||
|
- **360° Compass**: Full compass with cardinal directions and dynamic needle
|
||||||
|
- **Gradient Progress Bar**: Color-transitioning progress indicator
|
||||||
|
- **Enhanced Typography**: Better text rendering with shadows and effects
|
||||||
|
- **Atmospheric Vignette**: Subtle edge darkening for cinematic feel
|
||||||
|
|
||||||
|
## Technical Specifications
|
||||||
|
- **Resolution**: 1920x1080 (Full HD)
|
||||||
|
- **Frame Rate**: 30 FPS (smooth motion)
|
||||||
|
- **Format**: MP4 video (universal compatibility)
|
||||||
|
- **Compression**: MP4V codec optimized for quality
|
||||||
|
- **Visual Quality**: Professional Google Earth-style rendering
|
||||||
|
- **Space Entry**: 3-second descent from 50km altitude with realistic visuals
|
||||||
|
- **Camera Height**: 1000-3000m (dynamic aerial perspective)
|
||||||
|
- **View Distance**: 3000m ahead (enhanced for aerial views)
|
||||||
|
- **Field of View**: 75° (optimized for aerial perspective)
|
||||||
|
- **Tilt Angle**: 65-73° (dynamic for terrain following)
|
||||||
|
- **Terrain Detail**: Multi-layer realistic terrain with 6+ terrain types
|
||||||
|
- **Color Depth**: Professional color palette with atmospheric effects
|
||||||
|
- **Entry Altitude Range**: 50km → 2km (space to aerial transition)
|
||||||
|
|
||||||
|
## Advanced Animation Features
|
||||||
|
- **Space Entry Sequence**: Spectacular 3-second descent from space to route
|
||||||
|
- **Earth Curvature Rendering**: Realistic planetary curvature at high altitudes
|
||||||
|
- **Atmospheric Transition**: Smooth space-to-atmosphere visual effects
|
||||||
|
- **Enhanced Aerial Perspective**: Optimized 1000-3000m camera height range
|
||||||
|
- **3D Shadow Effects**: Multi-layer shadows for depth
|
||||||
|
- **Elevation Dynamics**: Real-time terrain elevation calculation
|
||||||
|
- **Smooth Interpolation**: Advanced movement interpolation
|
||||||
|
- **Depth Culling**: Performance optimization through view frustum culling
|
||||||
|
- **Route Highlighting**: Progressive route visibility during space descent
|
||||||
|
- **Progressive Rendering**: Back-to-front rendering for proper transparency
|
||||||
|
- **Atmospheric Effects**: Distance-based fog and atmospheric perspective
|
||||||
|
- **Dynamic Lighting**: Simulated lighting based on elevation and distance
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Enhanced Processing Pipeline
|
||||||
|
|
||||||
|
### 1. Route Analysis & Camera Planning (10-20%)
|
||||||
|
- Advanced GPS data analysis and validation
|
||||||
|
- Dynamic camera path calculation
|
||||||
|
- Elevation profile generation
|
||||||
|
- Viewport optimization for route coverage
|
||||||
|
|
||||||
|
### 2. 3D Scene Setup (20-30%)
|
||||||
|
- Camera position and target calculation
|
||||||
|
- 3D coordinate system establishment
|
||||||
|
- Terrain mesh generation
|
||||||
|
- Lighting and atmosphere setup
|
||||||
|
|
||||||
|
### 3. Enhanced Frame Generation (30-75%)
|
||||||
|
- Dynamic camera positioning for each frame
|
||||||
|
- 3D-to-2D perspective projection
|
||||||
|
- Depth-sorted object rendering
|
||||||
|
- Advanced route visualization with gradients
|
||||||
|
- Multi-layer UI element composition
|
||||||
|
- Atmospheric effect application
|
||||||
|
|
||||||
|
### 4. Video Assembly & Optimization (75-90%)
|
||||||
|
- Frame sequence compilation
|
||||||
|
- Advanced compression with quality optimization
|
||||||
|
- Metadata embedding
|
||||||
|
- Audio track preparation (future enhancement)
|
||||||
|
|
||||||
|
### 5. Post-Processing & Output (90-100%)
|
||||||
|
- Final quality optimization
|
||||||
|
- File system integration
|
||||||
|
- Temporary file cleanup
|
||||||
|
- User notification and result display
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Enhanced Rendering Pipeline
|
||||||
|
```
|
||||||
|
GPS Data → Camera Path Planning → 3D Scene Setup →
|
||||||
|
Dynamic Projection → Depth Sorting → Visual Effects →
|
||||||
|
UI Overlay → Atmospheric Effects → Frame Export
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced 3D Mathematics
|
||||||
|
- **Haversine Distance Calculation**: Precise GPS distance computation
|
||||||
|
- **Bearing Calculation**: Accurate directional vectors
|
||||||
|
- **3D Perspective Projection**: Field-of-view based projection
|
||||||
|
- **Matrix Transformations**: Rotation and translation matrices
|
||||||
|
- **Depth Buffer Simulation**: Z-order sorting for realistic rendering
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## Space Entry Sequence Details
|
||||||
|
|
||||||
|
### Visual Journey
|
||||||
|
1. **Space View (0-1 seconds)**: Starts from 50km altitude with black space background and Earth curvature
|
||||||
|
2. **Atmospheric Entry (1-2 seconds)**: Gradual transition showing atmospheric layers and blue sky emergence
|
||||||
|
3. **Route Approach (2-3 seconds)**: Descent to 2km altitude with route becoming visible and highlighted
|
||||||
|
4. **Transition Bridge (3-3.5 seconds)**: Smooth bridge frame announcing route start
|
||||||
|
5. **Aerial Following (3.5+ seconds)**: Seamless transition to dynamic camera following at optimal aerial height
|
||||||
|
|
||||||
|
### Technical Implementation
|
||||||
|
- **Altitude Range**: 50,000m → 2,000m → 1,000-3,000m (dynamic)
|
||||||
|
- **Descent Curve**: Cubic ease-out for natural deceleration
|
||||||
|
- **Camera Transition**: Smooth movement from center overview to route start
|
||||||
|
- **Transition Bridge**: Dedicated frame for smooth space-to-route handoff
|
||||||
|
- **Visual Effects**: Earth curvature, atmospheric glow, space-to-sky gradient
|
||||||
|
- **Route Visibility**: Progressive highlighting during descent approach
|
||||||
|
- **Error Handling**: Robust fallback frames ensure generation continues
|
||||||
|
- **Variable Safety**: Protected against undefined position markers
|
||||||
|
|
||||||
|
### Enhanced Aerial Perspective
|
||||||
|
- **Optimal Height Range**: 1000-3000 meters for perfect aerial views
|
||||||
|
- **Dynamic Variation**: Camera height varies smoothly for cinematic effect
|
||||||
|
- **Wide Field of View**: 75° FOV for comprehensive aerial perspective
|
||||||
|
- **Enhanced View Distance**: 3000m ahead for better route anticipation
|
||||||
|
- **Improved Tilt Angle**: 65-73° for optimal aerial viewing angle
|
||||||
|
|
||||||
|
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
|
||||||
1232
py_scripts/advanced_3d_generator.py
Normal file
1232
py_scripts/advanced_3d_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
333
py_scripts/blender_animator.py
Normal file
333
py_scripts/blender_animator.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Blender dependencies with fallback handling
|
||||||
|
try:
|
||||||
|
import bpy
|
||||||
|
import bmesh
|
||||||
|
from mathutils import Vector, Euler
|
||||||
|
BLENDER_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
BLENDER_AVAILABLE = False
|
||||||
|
print("Warning: Blender (bpy) not available. This module requires Blender to be installed with Python API access.")
|
||||||
|
|
||||||
|
class BlenderGPSAnimator:
|
||||||
|
"""
|
||||||
|
Advanced GPS track animation using Blender for high-quality 3D rendering
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, output_folder):
|
||||||
|
self.output_folder = output_folder
|
||||||
|
if BLENDER_AVAILABLE:
|
||||||
|
self.setup_blender_scene()
|
||||||
|
else:
|
||||||
|
# Don't raise error here, let the caller handle the check
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_dependencies(self):
|
||||||
|
"""Check if Blender dependencies are available"""
|
||||||
|
if not BLENDER_AVAILABLE:
|
||||||
|
raise ImportError("Blender (bpy) is not available. Please install Blender with Python API access.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def setup_blender_scene(self):
|
||||||
|
"""Setup Blender scene for GPS animation"""
|
||||||
|
# Clear existing mesh objects
|
||||||
|
bpy.ops.object.select_all(action='SELECT')
|
||||||
|
bpy.ops.object.delete(use_global=False)
|
||||||
|
|
||||||
|
# Add camera
|
||||||
|
bpy.ops.object.camera_add(location=(0, 0, 10))
|
||||||
|
self.camera = bpy.context.object
|
||||||
|
|
||||||
|
# Add sun light
|
||||||
|
bpy.ops.object.light_add(type='SUN', location=(0, 0, 20))
|
||||||
|
light = bpy.context.object
|
||||||
|
light.data.energy = 5
|
||||||
|
|
||||||
|
# Setup world environment
|
||||||
|
world = bpy.context.scene.world
|
||||||
|
world.use_nodes = True
|
||||||
|
env_texture = world.node_tree.nodes.new('ShaderNodeTexEnvironment')
|
||||||
|
world.node_tree.links.new(env_texture.outputs[0], world.node_tree.nodes['Background'].inputs[0])
|
||||||
|
|
||||||
|
# Set render settings
|
||||||
|
scene = bpy.context.scene
|
||||||
|
scene.render.engine = 'CYCLES'
|
||||||
|
scene.render.resolution_x = 1920
|
||||||
|
scene.render.resolution_y = 1080
|
||||||
|
scene.render.fps = 30
|
||||||
|
scene.cycles.samples = 64
|
||||||
|
|
||||||
|
def load_gps_data(self, positions_file):
|
||||||
|
"""Load GPS data from JSON file"""
|
||||||
|
with open(positions_file, 'r') as f:
|
||||||
|
positions = json.load(f)
|
||||||
|
|
||||||
|
# Convert to numpy array for easier processing
|
||||||
|
coords = []
|
||||||
|
times = []
|
||||||
|
speeds = []
|
||||||
|
|
||||||
|
for pos in positions:
|
||||||
|
coords.append([pos['longitude'], pos['latitude'], pos.get('altitude', 0)])
|
||||||
|
times.append(pos['fixTime'])
|
||||||
|
speeds.append(pos.get('speed', 0) * 1.852) # Convert to km/h
|
||||||
|
|
||||||
|
return np.array(coords), times, speeds
|
||||||
|
|
||||||
|
def create_terrain_mesh(self, coords):
|
||||||
|
"""Create a simple terrain mesh based on GPS bounds"""
|
||||||
|
# Calculate bounds
|
||||||
|
min_lon, min_lat = coords[:, :2].min(axis=0)
|
||||||
|
max_lon, max_lat = coords[:, :2].max(axis=0)
|
||||||
|
|
||||||
|
# Expand bounds slightly
|
||||||
|
padding = 0.001
|
||||||
|
min_lon -= padding
|
||||||
|
min_lat -= padding
|
||||||
|
max_lon += padding
|
||||||
|
max_lat += padding
|
||||||
|
|
||||||
|
# Create terrain mesh
|
||||||
|
bpy.ops.mesh.primitive_plane_add(size=2, location=(0, 0, 0))
|
||||||
|
terrain = bpy.context.object
|
||||||
|
terrain.name = "Terrain"
|
||||||
|
|
||||||
|
# Scale terrain to match GPS bounds
|
||||||
|
lon_range = max_lon - min_lon
|
||||||
|
lat_range = max_lat - min_lat
|
||||||
|
scale_factor = max(lon_range, lat_range) * 100000 # Convert to reasonable scale
|
||||||
|
|
||||||
|
terrain.scale = (scale_factor, scale_factor, 1)
|
||||||
|
|
||||||
|
# Apply material
|
||||||
|
mat = bpy.data.materials.new(name="TerrainMaterial")
|
||||||
|
mat.use_nodes = True
|
||||||
|
mat.node_tree.nodes.clear()
|
||||||
|
|
||||||
|
# Add principled BSDF
|
||||||
|
bsdf = mat.node_tree.nodes.new(type='ShaderNodeBsdfPrincipled')
|
||||||
|
bsdf.inputs['Base Color'].default_value = (0.2, 0.5, 0.2, 1.0) # Green
|
||||||
|
bsdf.inputs['Roughness'].default_value = 0.8
|
||||||
|
|
||||||
|
material_output = mat.node_tree.nodes.new(type='ShaderNodeOutputMaterial')
|
||||||
|
mat.node_tree.links.new(bsdf.outputs['BSDF'], material_output.inputs['Surface'])
|
||||||
|
|
||||||
|
terrain.data.materials.append(mat)
|
||||||
|
|
||||||
|
return terrain
|
||||||
|
|
||||||
|
def create_gps_track_mesh(self, coords):
|
||||||
|
"""Create a 3D mesh for the GPS track"""
|
||||||
|
# Normalize coordinates to Blender scale
|
||||||
|
coords_normalized = self.normalize_coordinates(coords)
|
||||||
|
|
||||||
|
# Create curve from GPS points
|
||||||
|
curve_data = bpy.data.curves.new('GPSTrack', type='CURVE')
|
||||||
|
curve_data.dimensions = '3D'
|
||||||
|
curve_data.bevel_depth = 0.02
|
||||||
|
curve_data.bevel_resolution = 4
|
||||||
|
|
||||||
|
# Create spline
|
||||||
|
spline = curve_data.splines.new('BEZIER')
|
||||||
|
spline.bezier_points.add(len(coords_normalized) - 1)
|
||||||
|
|
||||||
|
for i, coord in enumerate(coords_normalized):
|
||||||
|
point = spline.bezier_points[i]
|
||||||
|
point.co = coord
|
||||||
|
point.handle_left_type = 'AUTO'
|
||||||
|
point.handle_right_type = 'AUTO'
|
||||||
|
|
||||||
|
# Create object from curve
|
||||||
|
track_obj = bpy.data.objects.new('GPSTrack', curve_data)
|
||||||
|
bpy.context.collection.objects.link(track_obj)
|
||||||
|
|
||||||
|
# Apply material
|
||||||
|
mat = bpy.data.materials.new(name="TrackMaterial")
|
||||||
|
mat.use_nodes = True
|
||||||
|
bsdf = mat.node_tree.nodes["Principled BSDF"]
|
||||||
|
bsdf.inputs['Base Color'].default_value = (1.0, 0.0, 0.0, 1.0) # Red
|
||||||
|
bsdf.inputs['Emission'].default_value = (1.0, 0.2, 0.2, 1.0)
|
||||||
|
bsdf.inputs['Emission Strength'].default_value = 2.0
|
||||||
|
|
||||||
|
track_obj.data.materials.append(mat)
|
||||||
|
|
||||||
|
return track_obj
|
||||||
|
|
||||||
|
def create_vehicle_model(self):
|
||||||
|
"""Create a simple vehicle model"""
|
||||||
|
# Create a simple car shape using cubes
|
||||||
|
bpy.ops.mesh.primitive_cube_add(size=0.1, location=(0, 0, 0.05))
|
||||||
|
vehicle = bpy.context.object
|
||||||
|
vehicle.name = "Vehicle"
|
||||||
|
vehicle.scale = (2, 1, 0.5)
|
||||||
|
|
||||||
|
# Apply material
|
||||||
|
mat = bpy.data.materials.new(name="VehicleMaterial")
|
||||||
|
mat.use_nodes = True
|
||||||
|
bsdf = mat.node_tree.nodes["Principled BSDF"]
|
||||||
|
bsdf.inputs['Base Color'].default_value = (0.0, 0.0, 1.0, 1.0) # Blue
|
||||||
|
bsdf.inputs['Metallic'].default_value = 0.5
|
||||||
|
bsdf.inputs['Roughness'].default_value = 0.2
|
||||||
|
|
||||||
|
vehicle.data.materials.append(mat)
|
||||||
|
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
def normalize_coordinates(self, coords):
|
||||||
|
"""Normalize GPS coordinates to Blender scale"""
|
||||||
|
# Center coordinates
|
||||||
|
center = coords.mean(axis=0)
|
||||||
|
coords_centered = coords - center
|
||||||
|
|
||||||
|
# Scale to reasonable size for Blender
|
||||||
|
scale_factor = 100
|
||||||
|
coords_scaled = coords_centered * scale_factor
|
||||||
|
|
||||||
|
# Convert to Vector objects
|
||||||
|
return [Vector((x, y, z)) for x, y, z in coords_scaled]
|
||||||
|
|
||||||
|
def animate_vehicle(self, vehicle, coords, times, speeds):
|
||||||
|
"""Create animation keyframes for vehicle movement"""
|
||||||
|
coords_normalized = self.normalize_coordinates(coords)
|
||||||
|
|
||||||
|
scene = bpy.context.scene
|
||||||
|
scene.frame_start = 1
|
||||||
|
scene.frame_end = len(coords_normalized) * 2 # 2 frames per GPS point
|
||||||
|
|
||||||
|
for i, (coord, speed) in enumerate(zip(coords_normalized, speeds)):
|
||||||
|
frame = i * 2 + 1
|
||||||
|
|
||||||
|
# Set location
|
||||||
|
vehicle.location = coord
|
||||||
|
vehicle.keyframe_insert(data_path="location", frame=frame)
|
||||||
|
|
||||||
|
# Calculate rotation based on direction
|
||||||
|
if i < len(coords_normalized) - 1:
|
||||||
|
next_coord = coords_normalized[i + 1]
|
||||||
|
direction = next_coord - coord
|
||||||
|
if direction.length > 0:
|
||||||
|
direction.normalize()
|
||||||
|
# Calculate rotation angle
|
||||||
|
angle = math.atan2(direction.y, direction.x)
|
||||||
|
vehicle.rotation_euler = Euler((0, 0, angle), 'XYZ')
|
||||||
|
vehicle.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||||
|
|
||||||
|
# Set interpolation mode
|
||||||
|
if vehicle.animation_data:
|
||||||
|
for fcurve in vehicle.animation_data.action.fcurves:
|
||||||
|
for keyframe in fcurve.keyframe_points:
|
||||||
|
keyframe.interpolation = 'BEZIER'
|
||||||
|
|
||||||
|
def animate_camera(self, coords):
|
||||||
|
"""Create smooth camera animation following the vehicle"""
|
||||||
|
coords_normalized = self.normalize_coordinates(coords)
|
||||||
|
|
||||||
|
# Create camera path
|
||||||
|
for i, coord in enumerate(coords_normalized):
|
||||||
|
frame = i * 2 + 1
|
||||||
|
|
||||||
|
# Position camera above and behind the vehicle
|
||||||
|
offset = Vector((0, -2, 3))
|
||||||
|
cam_location = coord + offset
|
||||||
|
|
||||||
|
self.camera.location = cam_location
|
||||||
|
self.camera.keyframe_insert(data_path="location", frame=frame)
|
||||||
|
|
||||||
|
# Look at the vehicle
|
||||||
|
direction = coord - cam_location
|
||||||
|
if direction.length > 0:
|
||||||
|
rot_quat = direction.to_track_quat('-Z', 'Y')
|
||||||
|
self.camera.rotation_euler = rot_quat.to_euler()
|
||||||
|
self.camera.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||||
|
|
||||||
|
def add_particles_effects(self, vehicle):
|
||||||
|
"""Add particle effects for enhanced visuals"""
|
||||||
|
# Add dust particles
|
||||||
|
bpy.context.view_layer.objects.active = vehicle
|
||||||
|
bpy.ops.object.modifier_add(type='PARTICLE_SYSTEM')
|
||||||
|
|
||||||
|
particles = vehicle.modifiers["ParticleSystem"].particle_system
|
||||||
|
particles.settings.count = 100
|
||||||
|
particles.settings.lifetime = 30
|
||||||
|
particles.settings.emit_from = 'FACE'
|
||||||
|
particles.settings.physics_type = 'NEWTON'
|
||||||
|
particles.settings.effector_weights.gravity = 0.1
|
||||||
|
|
||||||
|
# Set material for particles
|
||||||
|
particles.settings.material = 1
|
||||||
|
|
||||||
|
def render_animation(self, output_path, progress_callback=None):
|
||||||
|
"""Render the animation to video"""
|
||||||
|
scene = bpy.context.scene
|
||||||
|
|
||||||
|
# Set output settings
|
||||||
|
scene.render.filepath = output_path
|
||||||
|
scene.render.image_settings.file_format = 'FFMPEG'
|
||||||
|
scene.render.ffmpeg.format = 'MPEG4'
|
||||||
|
scene.render.ffmpeg.codec = 'H264'
|
||||||
|
|
||||||
|
# Render animation
|
||||||
|
total_frames = scene.frame_end - scene.frame_start + 1
|
||||||
|
|
||||||
|
for frame in range(scene.frame_start, scene.frame_end + 1):
|
||||||
|
scene.frame_set(frame)
|
||||||
|
|
||||||
|
# Render frame
|
||||||
|
frame_path = f"{output_path}_{frame:04d}.png"
|
||||||
|
scene.render.filepath = frame_path
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
if progress_callback:
|
||||||
|
progress = ((frame - scene.frame_start) / total_frames) * 100
|
||||||
|
progress_callback(progress, f"Rendering frame {frame}/{scene.frame_end}")
|
||||||
|
|
||||||
|
def create_gps_animation(self, positions_file, output_path, progress_callback=None):
|
||||||
|
"""Main method to create GPS animation in Blender"""
|
||||||
|
try:
|
||||||
|
# Load GPS data
|
||||||
|
coords, times, speeds = self.load_gps_data(positions_file)
|
||||||
|
|
||||||
|
# Create scene elements
|
||||||
|
terrain = self.create_terrain_mesh(coords)
|
||||||
|
track = self.create_gps_track_mesh(coords)
|
||||||
|
vehicle = self.create_vehicle_model()
|
||||||
|
|
||||||
|
# Create animations
|
||||||
|
self.animate_vehicle(vehicle, coords, times, speeds)
|
||||||
|
self.animate_camera(coords)
|
||||||
|
|
||||||
|
# Add effects
|
||||||
|
self.add_particles_effects(vehicle)
|
||||||
|
|
||||||
|
# Render animation
|
||||||
|
self.render_animation(output_path, progress_callback)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating Blender animation: {e}")
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(-1, f"Error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def generate_blender_animation(positions_file, output_folder, progress_callback=None):
|
||||||
|
"""
|
||||||
|
Convenience function to generate Blender animation
|
||||||
|
"""
|
||||||
|
animator = BlenderGPSAnimator(output_folder)
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
output_path = os.path.join(output_folder, f"blender_animation_{timestamp}")
|
||||||
|
|
||||||
|
success = animator.create_gps_animation(positions_file, output_path, progress_callback)
|
||||||
|
|
||||||
|
return f"{output_path}.mp4" if success else None
|
||||||
|
|
||||||
|
# Note: This script should be run from within Blender's Python environment
|
||||||
|
# or with Blender as a Python module (bpy)
|
||||||
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)
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
kivy
|
|
||||||
cryptography
|
|
||||||
kiwy-garden
|
|
||||||
folium
|
|
||||||
selenium
|
|
||||||
pillow
|
|
||||||
20
requirements.txt
Normal file
20
requirements.txt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
kivy
|
||||||
|
cryptography
|
||||||
|
kivy-garden
|
||||||
|
folium
|
||||||
|
selenium
|
||||||
|
pillow
|
||||||
|
geopy
|
||||||
|
opencv-python
|
||||||
|
requests
|
||||||
|
numpy
|
||||||
|
matplotlib
|
||||||
|
scipy
|
||||||
|
imageio
|
||||||
|
ffmpeg-python
|
||||||
|
pydeck
|
||||||
|
plotly
|
||||||
|
dash
|
||||||
|
pandas
|
||||||
|
geopandas
|
||||||
|
bpy
|
||||||
@@ -1 +0,0 @@
|
|||||||
gAAAAABoQuJn-THhBcB9uQut4cng4vNqljWnzVOe-jvl4j8_nDzq1KiWNF5G2BKJCxy-u2Lf72PE9WMHOA7n2EMYsLzwmF0mi_2me3DnrckEE4kaC4reSowP0AiiKNdYqrZVFcemUf7w
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
wetp_PNG9CC5432-W9H3rUbaqIurwldZxHlOgori5kY=
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,169 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
|
|
||||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.js"></script>
|
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.js"></script>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.css"/>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css"/>
|
|
||||||
<link rel="stylesheet" href="https://netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css"/>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.0/css/all.min.css"/>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css"/>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/python-visualization/folium/folium/templates/leaflet.awesome.rotate.min.css"/>
|
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width,
|
|
||||||
initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
||||||
<style>
|
|
||||||
#map_bf67b2d92a9afef5449c1b1b9845da94 {
|
|
||||||
position: relative;
|
|
||||||
width: 100.0%;
|
|
||||||
height: 100.0%;
|
|
||||||
left: 0.0%;
|
|
||||||
top: 0.0%;
|
|
||||||
}
|
|
||||||
.leaflet-container { font-size: 1rem; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>html, body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>#map {
|
|
||||||
position:absolute;
|
|
||||||
top:0;
|
|
||||||
bottom:0;
|
|
||||||
right:0;
|
|
||||||
left:0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
L_NO_TOUCH = false;
|
|
||||||
L_DISABLE_3D = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="folium-map" id="map_bf67b2d92a9afef5449c1b1b9845da94" ></div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
|
|
||||||
var map_bf67b2d92a9afef5449c1b1b9845da94 = L.map(
|
|
||||||
"map_bf67b2d92a9afef5449c1b1b9845da94",
|
|
||||||
{
|
|
||||||
center: [45.805146666666666, 24.126355555555556],
|
|
||||||
crs: L.CRS.EPSG3857,
|
|
||||||
...{
|
|
||||||
"zoom": 14,
|
|
||||||
"zoomControl": true,
|
|
||||||
"preferCanvas": false,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var tile_layer_48f00dde609689cd95b3e5b1020d2d03 = L.tileLayer(
|
|
||||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
{
|
|
||||||
"minZoom": 0,
|
|
||||||
"maxZoom": 19,
|
|
||||||
"maxNativeZoom": 19,
|
|
||||||
"noWrap": false,
|
|
||||||
"attribution": "\u0026copy; \u003ca href=\"https://www.openstreetmap.org/copyright\"\u003eOpenStreetMap\u003c/a\u003e contributors",
|
|
||||||
"subdomains": "abc",
|
|
||||||
"detectRetina": false,
|
|
||||||
"tms": false,
|
|
||||||
"opacity": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
tile_layer_48f00dde609689cd95b3e5b1020d2d03.addTo(map_bf67b2d92a9afef5449c1b1b9845da94);
|
|
||||||
|
|
||||||
|
|
||||||
var poly_line_ac2b74b1096aa06a1d4ab84860beacdc = L.polyline(
|
|
||||||
[[45.805146666666666, 24.126355555555556], [45.80562444444445, 24.123990555555554], [45.805820555555556, 24.122884444444445], [45.806001111111115, 24.121864444444444], [45.80658944444445, 24.118647777777777], [45.80706166666667, 24.11584], [45.80744277777778, 24.113130555555554], [45.80744444444444, 24.111027777777778], [45.807554999999994, 24.10904111111111], [45.80765388888889, 24.10791777777778], [45.80775722222222, 24.106204444444444], [45.80775722222222, 24.106204444444444], [45.807792777777784, 24.10529888888889], [45.80769222222222, 24.105220555555558], [45.807494444444444, 24.10537666666667], [45.80721722222222, 24.10552888888889], [45.80721722222222, 24.10552888888889], [45.80452833333334, 24.106312222222222], [45.80452833333334, 24.106312222222222], [45.802245000000006, 24.106793888888888], [45.802245000000006, 24.106793888888888], [45.80039166666667, 24.107621666666667], [45.80039166666667, 24.107621666666667], [45.79863111111111, 24.10826], [45.79706388888889, 24.109215], [45.796372222222224, 24.109560000000002], [45.79611444444444, 24.109526666666667], [45.79596611111111, 24.109244999999998], [45.79575722222222, 24.107441666666666], [45.79575722222222, 24.107441666666666], [45.79544, 24.105129444444444], [45.79544, 24.105129444444444], [45.795164444444445, 24.103232777777777], [45.794825555555555, 24.100786111111113], [45.79484444444444, 24.10045277777778], [45.79482, 24.100100555555557], [45.79452388888888, 24.098648333333333], [45.794362222222226, 24.097596666666668], [45.794362222222226, 24.097596666666668], [45.794362222222226, 24.097596666666668], [45.79418555555556, 24.09649111111111], [45.79419388888889, 24.096272777777777], [45.79433111111111, 24.095743333333335], [45.795445, 24.094136111111112], [45.796870000000006, 24.09261777777778], [45.797534444444445, 24.091910555555554], [45.79878277777778, 24.090588888888888], [45.79978833333333, 24.089429444444445], [45.799776111111115, 24.089080555555554], [45.79944055555555, 24.086607777777775], [45.79913277777778, 24.086008333333332], [45.79909722222222, 24.08582277777778], [45.79911555555555, 24.085697222222223], [45.79911555555555, 24.085697222222223], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79919388888889, 24.08558888888889], [45.79921, 24.085612222222224]],
|
|
||||||
{"bubblingMouseEvents": true, "color": "blue", "dashArray": null, "dashOffset": null, "fill": false, "fillColor": "blue", "fillOpacity": 0.2, "fillRule": "evenodd", "lineCap": "round", "lineJoin": "round", "noClip": false, "opacity": 1, "smoothFactor": 1.0, "stroke": true, "weight": 4.5}
|
|
||||||
).addTo(map_bf67b2d92a9afef5449c1b1b9845da94);
|
|
||||||
|
|
||||||
|
|
||||||
var marker_508cb899cfad4984ec8c6bacbc7d4450 = L.marker(
|
|
||||||
[45.805146666666666, 24.126355555555556],
|
|
||||||
{
|
|
||||||
}
|
|
||||||
).addTo(map_bf67b2d92a9afef5449c1b1b9845da94);
|
|
||||||
|
|
||||||
|
|
||||||
var icon_27f251d0f4b490eac1364fdc7c0e4bcb = L.AwesomeMarkers.icon(
|
|
||||||
{
|
|
||||||
"markerColor": "green",
|
|
||||||
"iconColor": "white",
|
|
||||||
"icon": "info-sign",
|
|
||||||
"prefix": "glyphicon",
|
|
||||||
"extraClasses": "fa-rotate-0",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
marker_508cb899cfad4984ec8c6bacbc7d4450.bindTooltip(
|
|
||||||
`<div>
|
|
||||||
Start
|
|
||||||
</div>`,
|
|
||||||
{
|
|
||||||
"sticky": true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
marker_508cb899cfad4984ec8c6bacbc7d4450.setIcon(icon_27f251d0f4b490eac1364fdc7c0e4bcb);
|
|
||||||
|
|
||||||
|
|
||||||
var marker_fdc9ee2260616a462ff09a9869579e98 = L.marker(
|
|
||||||
[45.79921, 24.085612222222224],
|
|
||||||
{
|
|
||||||
}
|
|
||||||
).addTo(map_bf67b2d92a9afef5449c1b1b9845da94);
|
|
||||||
|
|
||||||
|
|
||||||
var icon_5ab5f7e75df70e10f7a0380fd99ababf = L.AwesomeMarkers.icon(
|
|
||||||
{
|
|
||||||
"markerColor": "red",
|
|
||||||
"iconColor": "white",
|
|
||||||
"icon": "info-sign",
|
|
||||||
"prefix": "glyphicon",
|
|
||||||
"extraClasses": "fa-rotate-0",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
marker_fdc9ee2260616a462ff09a9869579e98.bindTooltip(
|
|
||||||
`<div>
|
|
||||||
End
|
|
||||||
</div>`,
|
|
||||||
{
|
|
||||||
"sticky": true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
marker_fdc9ee2260616a462ff09a9869579e98.setIcon(icon_5ab5f7e75df70e10f7a0380fd99ababf);
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 546 KiB |
@@ -1 +0,0 @@
|
|||||||
gAAAAABoPu5x585IL9U8GSHw4j-KQTpHJixfiwEHQf9KHR25D2fFcYDz6HrJzFP4U3iFxcV9dQQ1VhgDfDPO_nVDafVjMz9kiJdbp1KtiSyB8odqNmq1v6ZfLr_YXqzqNhMHfuA1zr4NgUkaivF-dQr84Z4WA4i1crmR-BA7tMIQti7rDjtmIxQATfKrNw1zD5yYrDiI2jOkUAGiJ1hIY0Ue-x0wmykzktwD_xIsixxX3IOeqgY39gZ7XmwRYA4boZsSbWqqmVDgjBElaUYCUKlp_t-50vHeMNySt5AHDwmY3cOb0zePMEVYzQiKMOTRsSMrAavnIquY6BHytWKOJuuOoWS5aTiuy1YGw6wMQZT7MFcza9u4iYjJm39cdLnGl4tWn8StvawbXepPFqrwcoJXAfkvd8f--eCPuAXIFi__EMM0jlO2PGSbj-5YjFnCdKspnycrlLB6
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
To update the colors to the specified values, we will convert the hex color codes to RGBA format (values between 0 and 1) and update the `server_box_color` property in the `HomeScreen` class.
|
|
||||||
|
|
||||||
Here are the RGBA equivalents of the provided hex colors:
|
|
||||||
|
|
||||||
- **Yellow (#FB8D14)**: `(0.984, 0.553, 0.078, 1)`
|
|
||||||
- **Red (#E8083E)**: `(0.909, 0.031, 0.243, 1)`
|
|
||||||
- **Green (#02864A)**: `(0.008, 0.525, 0.290, 1)`
|
|
||||||
The RGBA equivalent of `#573CFA` is `(0.341, 0.235, 0.980, 1)`.
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
python -m venv track
|
|
||||||
source track/bin/activate
|
|
||||||
pip install -r reqirements.txt
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
|||||||
import requests
|
|
||||||
|
|
||||||
def get_device_route(server_url, token, device_id, from_time, to_time):
|
|
||||||
"""
|
|
||||||
Fetch all positions for a device in a time frame from Traccar server.
|
|
||||||
"""
|
|
||||||
url = f"{server_url}/reports/route"
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
params = {
|
|
||||||
"deviceId": device_id,
|
|
||||||
"from": from_time,
|
|
||||||
"to": to_time
|
|
||||||
}
|
|
||||||
response = requests.get(url, headers=headers, params=params)
|
|
||||||
if response.status_code == 200:
|
|
||||||
try:
|
|
||||||
positions = response.json()
|
|
||||||
print(f"Retrieved {len(positions)} positions.")
|
|
||||||
return positions
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error parsing JSON: {e}")
|
|
||||||
print(response.text)
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
print(f"Failed to fetch positions: {response.status_code} - {response.text}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Example usage:
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Use your actual Traccar API endpoint (not /reports/route)
|
|
||||||
server_url = "https://gps.moto-adv.com/api"
|
|
||||||
token = "SDBGAiEA4sNXvVhL8w_Jd-5oCiXAuS5La5yelCQemfNysZYItaMCIQDOkzaoWKHbNnbZJw9ruGlsvbp35d90x3EGOZLXW_Gls3sidSI6MSwiZSI6IjIwMjUtMDYtMDlUMjE6MDA6MDAuMDAwKzAwOjAwIn0"
|
|
||||||
device_id = 1 # Replace with your device ID
|
|
||||||
from_time = "2024-06-02T21:00:00Z"
|
|
||||||
to_time = "2025-06-03T20:59:00Z"
|
|
||||||
positions = get_device_route(server_url, token, device_id, from_time, to_time)
|
|
||||||
for pos in positions:
|
|
||||||
print(f"{pos['deviceTime']}: {pos['latitude']}, {pos['longitude']}")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
SDBGAiEA4sNXvVhL8w_Jd-5oCiXAuS5La5yelCQemfNysZYItaMCIQDOkzaoWKHbNnbZJw9ruGlsvbp35d90x3EGOZLXW_Gls3sidSI6MSwiZSI6IjIwMjUtMDYtMDlUMjE6MDA6MDAuMDAwKzAwOjAwIn0
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,53 +1,40 @@
|
|||||||
import kivy
|
import kivy
|
||||||
from kivy.app import App
|
from kivy.uix.screenmanager import Screen
|
||||||
from kivy.uix.screenmanager import ScreenManager, Screen
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
from kivy.properties import StringProperty, ListProperty, AliasProperty
|
from kivy.properties import StringProperty, NumericProperty, AliasProperty
|
||||||
from utils import (
|
from py_scripts.utils import (
|
||||||
generate_key, load_key, encrypt_data, decrypt_data,
|
process_preview_util, optimize_route_entries_util
|
||||||
check_server_settings, save_server_settings,
|
|
||||||
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
|
||||||
)
|
)
|
||||||
from datetime import date
|
from py_scripts.advanced_3d_generator import NavigationAnimationGenerator
|
||||||
|
# BlenderGPSAnimator imported conditionally when needed
|
||||||
from kivy.uix.popup import Popup
|
from kivy.uix.popup import Popup
|
||||||
from kivy.uix.gridlayout import GridLayout
|
|
||||||
from kivy.uix.button import Button
|
from kivy.uix.button import Button
|
||||||
from kivy.uix.label import Label
|
from kivy.uix.label import Label
|
||||||
from kivy.uix.boxlayout import BoxLayout
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
from threading import Thread
|
|
||||||
from kivy.clock import mainthread
|
|
||||||
from kivy.uix.image import Image
|
|
||||||
from kivy.uix.behaviors import ButtonBehavior
|
|
||||||
from kivy.uix.progressbar import ProgressBar
|
from kivy.uix.progressbar import ProgressBar
|
||||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
from kivy.uix.textinput import TextInput
|
||||||
from selenium import webdriver
|
from config import RESOURCES_FOLDER
|
||||||
from selenium.webdriver.chrome.options import Options
|
|
||||||
from PIL import Image
|
|
||||||
import time
|
|
||||||
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
|
|
||||||
from utils import html_to_image
|
|
||||||
|
|
||||||
class CreateAnimationScreen(Screen):
|
class CreateAnimationScreen(Screen):
|
||||||
project_name = StringProperty("")
|
project_name = StringProperty("")
|
||||||
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
||||||
preview_image_path = StringProperty("") # Add this line
|
preview_image_path = StringProperty("") # Add this line
|
||||||
|
preview_image_version = NumericProperty(0) # Add this line
|
||||||
|
|
||||||
def get_preview_image_source(self):
|
def get_preview_image_source(self):
|
||||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
img_path = os.path.join(project_folder, "preview.png")
|
img_path = os.path.join(project_folder, "preview.png")
|
||||||
if os.path.exists(img_path):
|
if os.path.exists(img_path):
|
||||||
# Add a dummy query string to force reload
|
return img_path
|
||||||
return f"{img_path}?{int(time.time())}"
|
|
||||||
return "resources/images/track.png"
|
return "resources/images/track.png"
|
||||||
|
|
||||||
preview_image_source = AliasProperty(get_preview_image_source, None, bind=['project_name'])
|
preview_image_source = AliasProperty(
|
||||||
|
get_preview_image_source, None, bind=['project_name', 'preview_image_version']
|
||||||
|
)
|
||||||
|
|
||||||
def on_pre_enter(self):
|
def on_pre_enter(self):
|
||||||
# Update the route entries label with the actual number of entries
|
# Update the route entries label with the actual number of entries
|
||||||
@@ -115,7 +102,7 @@ class CreateAnimationScreen(Screen):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def optimize_route_entries(self):
|
def optimize_route_entries(self):
|
||||||
# Show popup with progress bar
|
# Create the popup and UI elements
|
||||||
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
label = Label(text="Processing route entries...")
|
label = Label(text="Processing route entries...")
|
||||||
progress = ProgressBar(max=100, value=0)
|
progress = ProgressBar(max=100, value=0)
|
||||||
@@ -130,70 +117,16 @@ class CreateAnimationScreen(Screen):
|
|||||||
)
|
)
|
||||||
popup.open()
|
popup.open()
|
||||||
|
|
||||||
def process_entries(dt):
|
# Now call the utility function with these objects
|
||||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
optimize_route_entries_util(
|
||||||
positions_path = os.path.join(project_folder, "positions.json")
|
self.project_name,
|
||||||
if not os.path.exists(positions_path):
|
RESOURCES_FOLDER,
|
||||||
label.text = "positions.json not found!"
|
label,
|
||||||
progress.value = 100
|
progress,
|
||||||
return
|
popup,
|
||||||
|
Clock,
|
||||||
with open(positions_path, "r") as f:
|
on_save=lambda: self.on_pre_enter()
|
||||||
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
|
|
||||||
|
|
||||||
progress.value = 100
|
|
||||||
label.text = (
|
|
||||||
f"Entries removable at start: {start_remove}\n"
|
|
||||||
f"Entries removable at end: {end_remove}"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
layout.add_widget(btn_box)
|
|
||||||
|
|
||||||
def save_optimized(instance):
|
|
||||||
new_positions = positions[start_remove:len(positions)-end_remove if end_remove > 0 else None]
|
|
||||||
with open(positions_path, "w") as f:
|
|
||||||
json.dump(new_positions, f, indent=2)
|
|
||||||
label.text = "File optimized and saved!"
|
|
||||||
btn_save.disabled = True
|
|
||||||
btn_cancel.disabled = True
|
|
||||||
def close_and_refresh(dt):
|
|
||||||
popup.dismiss()
|
|
||||||
self.on_pre_enter() # Refresh the screen
|
|
||||||
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)
|
|
||||||
|
|
||||||
def preview_route(self):
|
def preview_route(self):
|
||||||
# Show processing popup
|
# Show processing popup
|
||||||
@@ -211,54 +144,798 @@ class CreateAnimationScreen(Screen):
|
|||||||
)
|
)
|
||||||
popup.open()
|
popup.open()
|
||||||
|
|
||||||
def process_preview(dt):
|
def set_preview_image_path(path):
|
||||||
try:
|
self.preview_image_path = path
|
||||||
import folium
|
self.preview_image_version += 1 # Force AliasProperty to update
|
||||||
|
self.property('preview_image_source').dispatch(self)
|
||||||
|
self.ids.preview_image.reload()
|
||||||
|
# Schedule the processing function
|
||||||
|
Clock.schedule_once(
|
||||||
|
lambda dt: process_preview_util(
|
||||||
|
self.project_name,
|
||||||
|
RESOURCES_FOLDER,
|
||||||
|
label,
|
||||||
|
progress,
|
||||||
|
popup,
|
||||||
|
self.ids.preview_image,
|
||||||
|
set_preview_image_path,
|
||||||
|
Clock
|
||||||
|
),
|
||||||
|
0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_google_earth_animation(self):
|
||||||
|
"""Generate Google Earth-style flythrough animation using NavigationAnimationGenerator"""
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Initializing Google Earth flythrough...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Generating Google Earth Flythrough",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def run_google_earth_animation():
|
||||||
|
try:
|
||||||
|
# Update status
|
||||||
|
def update_status(progress_val, status_text):
|
||||||
|
def _update(dt):
|
||||||
|
progress.value = progress_val
|
||||||
|
label.text = status_text
|
||||||
|
Clock.schedule_once(_update, 0)
|
||||||
|
|
||||||
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
positions_path = os.path.join(project_folder, "positions.json")
|
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):
|
if not os.path.exists(positions_path):
|
||||||
label.text = "positions.json not found!"
|
update_status(0, "Error: No GPS data found")
|
||||||
progress.value = 100
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
return
|
return
|
||||||
|
|
||||||
with open(positions_path, "r") as f:
|
update_status(10, "Loading GPS data...")
|
||||||
positions = json.load(f)
|
|
||||||
|
# Check dependencies first
|
||||||
if not positions:
|
generator = NavigationAnimationGenerator(project_folder)
|
||||||
label.text = "No positions to preview."
|
generator.check_dependencies()
|
||||||
progress.value = 100
|
|
||||||
return
|
update_status(20, "Processing GPS coordinates...")
|
||||||
|
df = generator.load_gps_data(positions_path)
|
||||||
coords = [(pos['latitude'], pos['longitude']) for pos in positions]
|
|
||||||
m = folium.Map(location=coords[0], zoom_start=14)
|
update_status(40, "Creating Google Earth flythrough...")
|
||||||
folium.PolyLine(coords, color="blue", weight=4.5, opacity=1).add_to(m)
|
output_video_path = os.path.join(project_folder, f"{self.project_name}_google_earth_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
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)
|
# Progress callback for the generator
|
||||||
m.save(html_path)
|
def generator_progress(progress, message):
|
||||||
|
update_status(40 + (progress * 0.5), message) # Map 0-100% to 40-90%
|
||||||
# Convert HTML to image
|
|
||||||
html_to_image(html_path, img_path)
|
update_status(90, "Creating flythrough video...")
|
||||||
self.property('preview_image_source').dispatch(self)
|
success = generator.generate_frames(positions_path, style='google_earth', progress_callback=generator_progress)
|
||||||
# Set the image path for Kivy Image widget
|
|
||||||
self.preview_image_path = img_path
|
if success and len(success) > 0:
|
||||||
|
update_status(95, "Rendering final video...")
|
||||||
label.text = "Preview ready!"
|
video_success = generator.create_video(success, output_video_path, generator_progress)
|
||||||
progress.value = 100
|
if video_success:
|
||||||
|
update_status(100, "Google Earth flythrough complete!")
|
||||||
def close_popup(dt):
|
output_path = output_video_path
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to create video from frames")
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to generate frames")
|
||||||
|
|
||||||
|
def show_success(dt):
|
||||||
popup.dismiss()
|
popup.dismiss()
|
||||||
Clock.schedule_once(close_popup, 1)
|
self.show_success_popup(
|
||||||
|
"Google Earth Flythrough Complete!",
|
||||||
|
f"Your Google Earth-style flythrough has been saved to:\n{output_path}",
|
||||||
|
output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_success, 1)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
label.text = f"Error: {e}"
|
error_message = str(e)
|
||||||
progress.value = 100
|
def show_error(dt):
|
||||||
def close_popup(dt):
|
|
||||||
popup.dismiss()
|
popup.dismiss()
|
||||||
Clock.schedule_once(close_popup, 2)
|
self.show_error_popup("Google Earth Animation Error", error_message)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_error, 0)
|
||||||
|
|
||||||
|
# Schedule the animation generation
|
||||||
|
Clock.schedule_once(lambda dt: run_google_earth_animation(), 0.5)
|
||||||
|
|
||||||
Clock.schedule_once(process_preview, 0.5)
|
def generate_blender_animation(self):
|
||||||
|
"""Generate cinema-quality animation using Blender (or fallback to advanced 3D)"""
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Initializing cinema rendering pipeline...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Generating Cinema Animation",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def run_blender_animation():
|
||||||
|
try:
|
||||||
|
# Update status
|
||||||
|
def update_status(progress_val, status_text):
|
||||||
|
def _update(dt):
|
||||||
|
progress.value = progress_val
|
||||||
|
label.text = status_text
|
||||||
|
Clock.schedule_once(_update, 0)
|
||||||
|
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
|
||||||
|
if not os.path.exists(positions_path):
|
||||||
|
update_status(0, "Error: No GPS data found")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if Blender is available
|
||||||
|
try:
|
||||||
|
from py_scripts.blender_animator import BLENDER_AVAILABLE, BlenderGPSAnimator
|
||||||
|
if BLENDER_AVAILABLE:
|
||||||
|
update_status(10, "Loading GPS data into Blender...")
|
||||||
|
|
||||||
|
# Use Blender for rendering
|
||||||
|
animator = BlenderGPSAnimator(project_folder)
|
||||||
|
animator.check_dependencies()
|
||||||
|
|
||||||
|
update_status(25, "Processing GPS coordinates...")
|
||||||
|
gps_data = animator.load_gps_data(positions_path)
|
||||||
|
|
||||||
|
output_video_path = os.path.join(project_folder, f"{self.project_name}_blender_cinema_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
|
|
||||||
|
# Progress callback for the animator
|
||||||
|
def animator_progress(progress, message):
|
||||||
|
update_status(25 + (progress * 0.6), message) # Map 0-100% to 25-85%
|
||||||
|
|
||||||
|
update_status(85, "Rendering cinema-quality video...")
|
||||||
|
success = animator.create_gps_animation(
|
||||||
|
positions_path,
|
||||||
|
output_video_path,
|
||||||
|
progress_callback=animator_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
update_status(100, "Blender cinema animation complete!")
|
||||||
|
output_path = output_video_path
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to generate Blender animation")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ImportError("Blender not available")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Fallback to advanced 3D animation with cinema-quality settings
|
||||||
|
update_status(10, "Blender not available - using advanced 3D cinema mode...")
|
||||||
|
|
||||||
|
# Import here to avoid startup delays
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from mpl_toolkits.mplot3d import Axes3D
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
# Load GPS data
|
||||||
|
with open(positions_path, 'r') as f:
|
||||||
|
positions = json.load(f)
|
||||||
|
|
||||||
|
if len(positions) < 2:
|
||||||
|
update_status(0, "Error: Need at least 2 GPS points")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(20, "Processing GPS coordinates for cinema rendering...")
|
||||||
|
|
||||||
|
# Extract coordinates
|
||||||
|
lats = np.array([pos['latitude'] for pos in positions])
|
||||||
|
lons = np.array([pos['longitude'] for pos in positions])
|
||||||
|
alts = np.array([pos.get('altitude', 0) for pos in positions])
|
||||||
|
timestamps = [pos.get('fixTime', '') for pos in positions]
|
||||||
|
|
||||||
|
# Convert to relative coordinates
|
||||||
|
lat_center = np.mean(lats)
|
||||||
|
lon_center = np.mean(lons)
|
||||||
|
alt_min = np.min(alts)
|
||||||
|
|
||||||
|
x = (lons - lon_center) * 111320 * np.cos(np.radians(lat_center))
|
||||||
|
y = (lats - lat_center) * 110540
|
||||||
|
z = alts - alt_min
|
||||||
|
|
||||||
|
update_status(30, "Creating cinema-quality frames...")
|
||||||
|
|
||||||
|
# Cinema settings - higher quality
|
||||||
|
frames_folder = os.path.join(project_folder, "cinema_frames")
|
||||||
|
os.makedirs(frames_folder, exist_ok=True)
|
||||||
|
|
||||||
|
fps = 24 # Cinema standard
|
||||||
|
total_frames = min(len(positions), 200) # Limit for reasonable processing time
|
||||||
|
points_per_frame = max(1, len(positions) // total_frames)
|
||||||
|
|
||||||
|
frame_files = []
|
||||||
|
|
||||||
|
# Generate cinema-quality frames
|
||||||
|
for frame_idx in range(total_frames):
|
||||||
|
current_progress = 30 + (frame_idx / total_frames) * 50
|
||||||
|
update_status(current_progress, f"Rendering cinema frame {frame_idx + 1}/{total_frames}...")
|
||||||
|
|
||||||
|
end_point = min((frame_idx + 1) * points_per_frame, len(positions))
|
||||||
|
|
||||||
|
# Create high-quality 3D plot
|
||||||
|
plt.style.use('dark_background') # Cinema-style dark theme
|
||||||
|
fig = plt.figure(figsize=(16, 12), dpi=150) # Higher resolution
|
||||||
|
ax = fig.add_subplot(111, projection='3d')
|
||||||
|
|
||||||
|
# Plot route with cinema styling
|
||||||
|
if end_point > 1:
|
||||||
|
# Gradient effect for completed route
|
||||||
|
colors = np.linspace(0, 1, end_point)
|
||||||
|
ax.scatter(x[:end_point], y[:end_point], z[:end_point],
|
||||||
|
c=colors, cmap='plasma', s=30, alpha=0.8)
|
||||||
|
ax.plot(x[:end_point], y[:end_point], z[:end_point],
|
||||||
|
color='cyan', linewidth=3, alpha=0.9)
|
||||||
|
|
||||||
|
# Current position with glow effect
|
||||||
|
if end_point > 0:
|
||||||
|
current_idx = end_point - 1
|
||||||
|
# Multiple layers for glow effect
|
||||||
|
for size, alpha in [(200, 0.3), (150, 0.5), (100, 0.8)]:
|
||||||
|
ax.scatter(x[current_idx], y[current_idx], z[current_idx],
|
||||||
|
c='yellow', s=size, alpha=alpha, marker='o')
|
||||||
|
|
||||||
|
# Trail effect
|
||||||
|
trail_start = max(0, current_idx - 10)
|
||||||
|
if current_idx > trail_start:
|
||||||
|
trail_alpha = np.linspace(0.3, 1.0, current_idx - trail_start + 1)
|
||||||
|
for i, alpha in enumerate(trail_alpha):
|
||||||
|
idx = trail_start + i
|
||||||
|
ax.scatter(x[idx], y[idx], z[idx],
|
||||||
|
c='orange', s=60, alpha=alpha)
|
||||||
|
|
||||||
|
# Remaining route preview
|
||||||
|
if end_point < len(positions):
|
||||||
|
ax.plot(x[end_point:], y[end_point:], z[end_point:],
|
||||||
|
color='gray', linewidth=1, alpha=0.4, linestyle='--')
|
||||||
|
|
||||||
|
# Cinema-style labels and styling
|
||||||
|
ax.set_xlabel('East-West (m)', color='white', fontsize=14)
|
||||||
|
ax.set_ylabel('North-South (m)', color='white', fontsize=14)
|
||||||
|
ax.set_zlabel('Elevation (m)', color='white', fontsize=14)
|
||||||
|
|
||||||
|
# Progress and time info
|
||||||
|
progress_percent = (end_point / len(positions)) * 100
|
||||||
|
timestamp_str = timestamps[end_point-1] if end_point > 0 else "Start"
|
||||||
|
ax.set_title(f'CINEMA GPS JOURNEY\nProgress: {progress_percent:.1f}% • Point {end_point}/{len(positions)} • {timestamp_str}',
|
||||||
|
color='white', fontsize=16, pad=20, weight='bold')
|
||||||
|
|
||||||
|
# Consistent view with cinematic angle
|
||||||
|
margin = max(np.ptp(x), np.ptp(y)) * 0.15
|
||||||
|
ax.set_xlim(np.min(x) - margin, np.max(x) + margin)
|
||||||
|
ax.set_ylim(np.min(y) - margin, np.max(y) + margin)
|
||||||
|
ax.set_zlim(np.min(z) - 20, np.max(z) + 20)
|
||||||
|
|
||||||
|
# Dynamic camera movement for cinematic effect
|
||||||
|
azim = 45 + (frame_idx * 0.5) % 360 # Slowly rotating view
|
||||||
|
ax.view_init(elev=25, azim=azim)
|
||||||
|
|
||||||
|
# Cinema-style grid
|
||||||
|
ax.grid(True, alpha=0.2, color='white')
|
||||||
|
ax.xaxis.pane.fill = False
|
||||||
|
ax.yaxis.pane.fill = False
|
||||||
|
ax.zaxis.pane.fill = False
|
||||||
|
|
||||||
|
# Make pane edges more subtle
|
||||||
|
ax.xaxis.pane.set_edgecolor('gray')
|
||||||
|
ax.yaxis.pane.set_edgecolor('gray')
|
||||||
|
ax.zaxis.pane.set_edgecolor('gray')
|
||||||
|
ax.xaxis.pane.set_alpha(0.1)
|
||||||
|
ax.yaxis.pane.set_alpha(0.1)
|
||||||
|
ax.zaxis.pane.set_alpha(0.1)
|
||||||
|
|
||||||
|
# Save high-quality frame
|
||||||
|
frame_path = os.path.join(frames_folder, f"cinema_frame_{frame_idx:04d}.png")
|
||||||
|
try:
|
||||||
|
plt.savefig(frame_path, dpi=150, bbox_inches='tight',
|
||||||
|
facecolor='black', edgecolor='none', format='png')
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1024:
|
||||||
|
test_frame = cv2.imread(frame_path)
|
||||||
|
if test_frame is not None:
|
||||||
|
frame_files.append(frame_path)
|
||||||
|
if frame_idx == 0:
|
||||||
|
h, w, c = test_frame.shape
|
||||||
|
update_status(current_progress, f"Cinema quality: {w}x{h} at {fps} FPS")
|
||||||
|
except Exception as frame_error:
|
||||||
|
update_status(current_progress, f"Error creating frame {frame_idx}: {str(frame_error)}")
|
||||||
|
plt.close(fig)
|
||||||
|
continue
|
||||||
|
|
||||||
|
plt.style.use('default') # Reset style
|
||||||
|
|
||||||
|
# Create cinema video
|
||||||
|
if not frame_files:
|
||||||
|
raise Exception("No valid cinema frames were generated")
|
||||||
|
|
||||||
|
update_status(80, f"Creating cinema video from {len(frame_files)} frames...")
|
||||||
|
|
||||||
|
output_video_path = os.path.join(project_folder, f"{self.project_name}_cinema_3d_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
|
|
||||||
|
# Cinema video creation with higher quality
|
||||||
|
first_frame = cv2.imread(frame_files[0])
|
||||||
|
height, width, layers = first_frame.shape
|
||||||
|
|
||||||
|
# Try to create high-quality video
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||||
|
video_writer = cv2.VideoWriter(output_video_path, fourcc, fps, (width, height))
|
||||||
|
|
||||||
|
if video_writer.isOpened():
|
||||||
|
for i, frame_file in enumerate(frame_files):
|
||||||
|
frame = cv2.imread(frame_file)
|
||||||
|
if frame is not None:
|
||||||
|
video_writer.write(frame)
|
||||||
|
|
||||||
|
if i % 10 == 0:
|
||||||
|
progress = 80 + (i / len(frame_files)) * 8
|
||||||
|
update_status(progress, f"Encoding cinema frame {i+1}/{len(frame_files)}")
|
||||||
|
|
||||||
|
video_writer.release()
|
||||||
|
|
||||||
|
if os.path.exists(output_video_path) and os.path.getsize(output_video_path) > 1024:
|
||||||
|
update_status(90, "Cinema video created successfully")
|
||||||
|
output_path = output_video_path
|
||||||
|
else:
|
||||||
|
raise Exception("Cinema video creation failed")
|
||||||
|
else:
|
||||||
|
raise Exception("Could not initialize cinema video writer")
|
||||||
|
|
||||||
|
# Clean up frames
|
||||||
|
for frame_file in frame_files:
|
||||||
|
try:
|
||||||
|
os.remove(frame_file)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.rmdir(frames_folder)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
update_status(100, "Cinema animation complete!")
|
||||||
|
|
||||||
|
def show_success(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_success_popup(
|
||||||
|
"Cinema Animation Complete!",
|
||||||
|
f"Your cinema-quality animation has been saved to:\n{output_path}\n\nNote: Blender was not available, so advanced 3D cinema mode was used instead.",
|
||||||
|
output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_success, 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
print(f"DEBUG: Cinema animation error: {error_message}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
def show_error(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_error_popup("Cinema Animation Error", error_message)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_error, 0)
|
||||||
|
|
||||||
|
# Schedule the animation generation
|
||||||
|
Clock.schedule_once(lambda dt: run_blender_animation(), 0.5)
|
||||||
|
|
||||||
|
def generate_progressive_3d_animation(self):
|
||||||
|
"""Generate a progressive 3D animation that builds the trip point by point"""
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Initializing progressive 3D animation...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Generating Progressive 3D Animation",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def run_progressive_animation():
|
||||||
|
try:
|
||||||
|
# Import here to avoid startup delays
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg') # Use non-interactive backend
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from mpl_toolkits.mplot3d import Axes3D
|
||||||
|
import numpy as np
|
||||||
|
import cv2 # Use OpenCV instead of MoviePy
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
def update_status(progress_val, status_text):
|
||||||
|
def _update(dt):
|
||||||
|
progress.value = progress_val
|
||||||
|
label.text = status_text
|
||||||
|
Clock.schedule_once(_update, 0)
|
||||||
|
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
|
||||||
|
if not os.path.exists(positions_path):
|
||||||
|
update_status(0, "Error: No GPS data found")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(10, "Loading GPS data...")
|
||||||
|
|
||||||
|
# Load GPS data
|
||||||
|
with open(positions_path, 'r') as f:
|
||||||
|
positions = json.load(f)
|
||||||
|
|
||||||
|
if len(positions) < 2:
|
||||||
|
update_status(0, "Error: Need at least 2 GPS points")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(20, "Processing GPS coordinates...")
|
||||||
|
|
||||||
|
# Extract coordinates and timestamps
|
||||||
|
lats = [pos['latitude'] for pos in positions]
|
||||||
|
lons = [pos['longitude'] for pos in positions]
|
||||||
|
alts = [pos.get('altitude', 0) for pos in positions]
|
||||||
|
timestamps = [pos.get('fixTime', '') for pos in positions]
|
||||||
|
|
||||||
|
# Convert to numpy arrays for easier manipulation
|
||||||
|
lats = np.array(lats)
|
||||||
|
lons = np.array(lons)
|
||||||
|
alts = np.array(alts)
|
||||||
|
|
||||||
|
# Normalize coordinates for better visualization
|
||||||
|
lat_center = np.mean(lats)
|
||||||
|
lon_center = np.mean(lons)
|
||||||
|
alt_min = np.min(alts)
|
||||||
|
|
||||||
|
# Convert to relative coordinates (in meters approximately)
|
||||||
|
x = (lons - lon_center) * 111320 * np.cos(np.radians(lat_center)) # longitude to meters
|
||||||
|
y = (lats - lat_center) * 110540 # latitude to meters
|
||||||
|
z = alts - alt_min # relative altitude
|
||||||
|
|
||||||
|
update_status(30, "Creating animation frames...")
|
||||||
|
|
||||||
|
# Create frames folder
|
||||||
|
frames_folder = os.path.join(project_folder, "progressive_frames")
|
||||||
|
os.makedirs(frames_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Animation settings
|
||||||
|
fps = 10 # frames per second
|
||||||
|
points_per_frame = max(1, len(positions) // 100) # Show multiple points per frame for long routes
|
||||||
|
total_frames = len(positions) // points_per_frame
|
||||||
|
|
||||||
|
frame_files = []
|
||||||
|
|
||||||
|
# Generate frames
|
||||||
|
for frame_idx in range(total_frames):
|
||||||
|
current_progress = 30 + (frame_idx / total_frames) * 50
|
||||||
|
update_status(current_progress, f"Creating frame {frame_idx + 1}/{total_frames}...")
|
||||||
|
|
||||||
|
# Points to show in this frame
|
||||||
|
end_point = min((frame_idx + 1) * points_per_frame, len(positions))
|
||||||
|
|
||||||
|
# Create 3D plot
|
||||||
|
fig = plt.figure(figsize=(12, 9), dpi=100)
|
||||||
|
ax = fig.add_subplot(111, projection='3d')
|
||||||
|
|
||||||
|
# Plot the route progressively
|
||||||
|
if end_point > 1:
|
||||||
|
# Plot completed route in blue
|
||||||
|
ax.plot(x[:end_point], y[:end_point], z[:end_point],
|
||||||
|
'b-', linewidth=2, alpha=0.7, label='Route')
|
||||||
|
|
||||||
|
# Plot points as small dots
|
||||||
|
ax.scatter(x[:end_point], y[:end_point], z[:end_point],
|
||||||
|
c='blue', s=20, alpha=0.6)
|
||||||
|
|
||||||
|
# Highlight current position in red
|
||||||
|
if end_point > 0:
|
||||||
|
current_idx = end_point - 1
|
||||||
|
ax.scatter(x[current_idx], y[current_idx], z[current_idx],
|
||||||
|
c='red', s=100, marker='o', label='Current Position')
|
||||||
|
|
||||||
|
# Add a small trail behind current position
|
||||||
|
trail_start = max(0, current_idx - 5)
|
||||||
|
if current_idx > trail_start:
|
||||||
|
ax.plot(x[trail_start:current_idx+1],
|
||||||
|
y[trail_start:current_idx+1],
|
||||||
|
z[trail_start:current_idx+1],
|
||||||
|
'r-', linewidth=4, alpha=0.8)
|
||||||
|
|
||||||
|
# Plot remaining route in light gray (preview)
|
||||||
|
if end_point < len(positions):
|
||||||
|
ax.plot(x[end_point:], y[end_point:], z[end_point:],
|
||||||
|
'lightgray', linewidth=1, alpha=0.3, label='Remaining Route')
|
||||||
|
|
||||||
|
# Set labels and title
|
||||||
|
ax.set_xlabel('East-West (meters)')
|
||||||
|
ax.set_ylabel('North-South (meters)')
|
||||||
|
ax.set_zlabel('Elevation (meters)')
|
||||||
|
|
||||||
|
# Add progress info
|
||||||
|
progress_percent = (end_point / len(positions)) * 100
|
||||||
|
timestamp_str = timestamps[end_point-1] if end_point > 0 else "Start"
|
||||||
|
ax.set_title(f'GPS Trip Animation\nProgress: {progress_percent:.1f}% - Point {end_point}/{len(positions)}\nTime: {timestamp_str}',
|
||||||
|
fontsize=14, pad=20)
|
||||||
|
|
||||||
|
# Set consistent view limits for all frames
|
||||||
|
margin = max(np.ptp(x), np.ptp(y)) * 0.1
|
||||||
|
ax.set_xlim(np.min(x) - margin, np.max(x) + margin)
|
||||||
|
ax.set_ylim(np.min(y) - margin, np.max(y) + margin)
|
||||||
|
ax.set_zlim(np.min(z) - 10, np.max(z) + 10)
|
||||||
|
|
||||||
|
# Set viewing angle for better 3D perspective
|
||||||
|
ax.view_init(elev=20, azim=45)
|
||||||
|
|
||||||
|
# Add legend
|
||||||
|
ax.legend(loc='upper right')
|
||||||
|
|
||||||
|
# Add grid
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# Save frame with comprehensive error handling
|
||||||
|
frame_path = os.path.join(frames_folder, f"frame_{frame_idx:04d}.png")
|
||||||
|
try:
|
||||||
|
plt.savefig(frame_path, dpi=100, bbox_inches='tight',
|
||||||
|
facecolor='white', edgecolor='none',
|
||||||
|
format='png', optimize=False)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# Verify frame was saved properly and is readable by OpenCV
|
||||||
|
if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1024:
|
||||||
|
# Test if OpenCV can read the frame
|
||||||
|
test_frame = cv2.imread(frame_path)
|
||||||
|
if test_frame is not None:
|
||||||
|
frame_files.append(frame_path)
|
||||||
|
if frame_idx == 0: # Log first frame details
|
||||||
|
h, w, c = test_frame.shape
|
||||||
|
update_status(current_progress, f"First frame: {w}x{h}, size: {os.path.getsize(frame_path)} bytes")
|
||||||
|
else:
|
||||||
|
update_status(current_progress, f"Warning: Frame {frame_idx} not readable by OpenCV")
|
||||||
|
try:
|
||||||
|
os.remove(frame_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
update_status(current_progress, f"Warning: Frame {frame_idx} too small or missing")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
update_status(current_progress, f"Error saving frame {frame_idx}: {str(e)}")
|
||||||
|
try:
|
||||||
|
plt.close(fig)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate frames before creating video
|
||||||
|
if not frame_files:
|
||||||
|
raise Exception("No valid frames were generated")
|
||||||
|
|
||||||
|
update_status(80, f"Creating video from {len(frame_files)} frames...")
|
||||||
|
|
||||||
|
# Create video using OpenCV with better error handling
|
||||||
|
output_video_path = os.path.join(project_folder,
|
||||||
|
f"{self.project_name}_progressive_3d_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
|
|
||||||
|
if frame_files:
|
||||||
|
try:
|
||||||
|
# Read first frame to get dimensions
|
||||||
|
first_frame = cv2.imread(frame_files[0])
|
||||||
|
if first_frame is None:
|
||||||
|
raise Exception(f"Could not read first frame: {frame_files[0]}")
|
||||||
|
|
||||||
|
height, width, layers = first_frame.shape
|
||||||
|
update_status(82, f"Video dimensions: {width}x{height}")
|
||||||
|
|
||||||
|
# Try different codecs for better compatibility
|
||||||
|
codecs_to_try = [
|
||||||
|
('mp4v', '.mp4'),
|
||||||
|
('XVID', '.avi'),
|
||||||
|
('MJPG', '.avi')
|
||||||
|
]
|
||||||
|
|
||||||
|
video_created = False
|
||||||
|
|
||||||
|
for codec, ext in codecs_to_try:
|
||||||
|
try:
|
||||||
|
# Update output path for different codecs
|
||||||
|
if ext != '.mp4':
|
||||||
|
test_output_path = output_video_path.replace('.mp4', ext)
|
||||||
|
else:
|
||||||
|
test_output_path = output_video_path
|
||||||
|
|
||||||
|
update_status(84, f"Trying codec {codec}...")
|
||||||
|
|
||||||
|
# Create video writer
|
||||||
|
fourcc = cv2.VideoWriter_fourcc(*codec)
|
||||||
|
video_writer = cv2.VideoWriter(test_output_path, fourcc, fps, (width, height))
|
||||||
|
|
||||||
|
if not video_writer.isOpened():
|
||||||
|
update_status(85, f"Failed to open video writer with {codec}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add frames to video
|
||||||
|
frames_written = 0
|
||||||
|
for i, frame_file in enumerate(frame_files):
|
||||||
|
frame = cv2.imread(frame_file)
|
||||||
|
if frame is not None:
|
||||||
|
# Ensure frame dimensions match
|
||||||
|
if frame.shape[:2] != (height, width):
|
||||||
|
frame = cv2.resize(frame, (width, height))
|
||||||
|
video_writer.write(frame)
|
||||||
|
frames_written += 1
|
||||||
|
|
||||||
|
if i % 10 == 0: # Update progress every 10 frames
|
||||||
|
progress = 85 + (i / len(frame_files)) * 3
|
||||||
|
update_status(progress, f"Writing frame {i+1}/{len(frame_files)} with {codec}")
|
||||||
|
|
||||||
|
video_writer.release()
|
||||||
|
|
||||||
|
# Check if video file was created and has reasonable size
|
||||||
|
if os.path.exists(test_output_path) and os.path.getsize(test_output_path) > 1024:
|
||||||
|
output_video_path = test_output_path
|
||||||
|
video_created = True
|
||||||
|
update_status(88, f"Video created successfully with {codec} ({frames_written} frames)")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
update_status(86, f"Video file not created or too small with {codec}")
|
||||||
|
|
||||||
|
except Exception as codec_error:
|
||||||
|
update_status(87, f"Error with {codec}: {str(codec_error)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not video_created:
|
||||||
|
raise Exception("Failed to create video with any codec")
|
||||||
|
|
||||||
|
except Exception as video_error:
|
||||||
|
raise Exception(f"Video creation failed: {str(video_error)}")
|
||||||
|
|
||||||
|
update_status(90, "Cleaning up temporary files...")
|
||||||
|
|
||||||
|
# Clean up frame files
|
||||||
|
for frame_file in frame_files:
|
||||||
|
try:
|
||||||
|
os.remove(frame_file)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.rmdir(frames_folder)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
update_status(100, "Progressive 3D animation complete!")
|
||||||
|
|
||||||
|
def show_success(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_success_popup(
|
||||||
|
"Progressive 3D Animation Complete!",
|
||||||
|
f"Your progressive 3D animation has been saved to:\n{output_video_path}\n\nThe animation shows {len(positions)} GPS points building the route progressively from start to finish.",
|
||||||
|
output_video_path
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_success, 1)
|
||||||
|
else:
|
||||||
|
raise Exception("No frames were generated")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
print(f"DEBUG: Progressive animation error: {error_message}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
def show_error(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_error_popup("Progressive Animation Error", error_message)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_error, 0)
|
||||||
|
|
||||||
|
# Schedule the animation generation
|
||||||
|
Clock.schedule_once(lambda dt: run_progressive_animation(), 0.5)
|
||||||
|
|
||||||
|
def open_pauses_popup(self):
|
||||||
|
"""Navigate to the pause edit screen"""
|
||||||
|
pause_edit_screen = self.manager.get_screen("pause_edit")
|
||||||
|
pause_edit_screen.set_project_and_callback(self.project_name, self.on_pre_enter)
|
||||||
|
self.manager.current = "pause_edit"
|
||||||
|
|
||||||
|
def show_success_popup(self, title, message, file_path):
|
||||||
|
"""Show success popup with option to open file location"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||||
|
|
||||||
|
# Success message
|
||||||
|
success_label = Label(
|
||||||
|
text=message,
|
||||||
|
text_size=(None, None),
|
||||||
|
halign="center",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
layout.add_widget(success_label)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=50)
|
||||||
|
|
||||||
|
open_folder_btn = Button(
|
||||||
|
text="Open Folder",
|
||||||
|
background_color=(0.2, 0.6, 0.9, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
ok_btn = Button(
|
||||||
|
text="OK",
|
||||||
|
background_color=(0.3, 0.7, 0.3, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
btn_layout.add_widget(open_folder_btn)
|
||||||
|
btn_layout.add_widget(ok_btn)
|
||||||
|
layout.add_widget(btn_layout)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title=title,
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, 0.6),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_folder(instance):
|
||||||
|
folder_path = os.path.dirname(file_path)
|
||||||
|
os.system(f'xdg-open "{folder_path}"') # Linux
|
||||||
|
popup.dismiss()
|
||||||
|
|
||||||
|
def close_popup(instance):
|
||||||
|
popup.dismiss()
|
||||||
|
|
||||||
|
open_folder_btn.bind(on_press=open_folder)
|
||||||
|
ok_btn.bind(on_press=close_popup)
|
||||||
|
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def show_error_popup(self, title, message):
|
||||||
|
"""Show error popup"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||||
|
|
||||||
|
error_label = Label(
|
||||||
|
text=f"Error: {message}",
|
||||||
|
text_size=(None, None),
|
||||||
|
halign="center",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
layout.add_widget(error_label)
|
||||||
|
|
||||||
|
ok_btn = Button(
|
||||||
|
text="OK",
|
||||||
|
background_color=(0.8, 0.3, 0.3, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=50
|
||||||
|
)
|
||||||
|
layout.add_widget(ok_btn)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title=title,
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.8, 0.4),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
|
|||||||
633
screens/create_animation_screen.py.backup
Normal file
633
screens/create_animation_screen.py.backup
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import kivy
|
||||||
|
from kivy.uix.screenmanager import Screen
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.properties import StringProperty, NumericProperty, AliasProperty
|
||||||
|
from py_scripts.utils import (
|
||||||
|
process_preview_util, optimize_route_entries_util
|
||||||
|
)
|
||||||
|
|
||||||
|
from py_scripts.advanced_3d_generator import NavigationAnimationGenerator
|
||||||
|
from py_scripts.blender_animator import BlenderGPSAnimator
|
||||||
|
from kivy.uix.popup import Popup
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.progressbar import ProgressBar
|
||||||
|
from kivy.uix.textinput import TextInput
|
||||||
|
from config import RESOURCES_FOLDER
|
||||||
|
|
||||||
|
class CreateAnimationScreen(Screen):
|
||||||
|
project_name = StringProperty("")
|
||||||
|
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
||||||
|
preview_image_path = StringProperty("") # Add this line
|
||||||
|
preview_image_version = NumericProperty(0) # Add this line
|
||||||
|
|
||||||
|
def get_preview_image_source(self):
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
img_path = os.path.join(project_folder, "preview.png")
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
return img_path
|
||||||
|
return "resources/images/track.png"
|
||||||
|
|
||||||
|
preview_image_source = AliasProperty(
|
||||||
|
get_preview_image_source, None, bind=['project_name', 'preview_image_version']
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_pre_enter(self):
|
||||||
|
# Update the route entries label with the actual number of entries
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
count = 0
|
||||||
|
if os.path.exists(positions_path):
|
||||||
|
with open(positions_path, "r") as f:
|
||||||
|
try:
|
||||||
|
positions = json.load(f)
|
||||||
|
count = len(positions)
|
||||||
|
except Exception:
|
||||||
|
count = 0
|
||||||
|
self.ids.route_entries_label.text = f"Your route has {count} entries,"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def open_rename_popup(self):
|
||||||
|
from kivy.uix.popup import Popup
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.textinput import TextInput
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Enter new project name:")
|
||||||
|
input_field = TextInput(text=self.project_name, multiline=False)
|
||||||
|
btn_save = Button(text="Save", background_color=(0.008, 0.525, 0.290, 1))
|
||||||
|
btn_cancel = Button(text="Cancel")
|
||||||
|
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(input_field)
|
||||||
|
layout.add_widget(btn_save)
|
||||||
|
layout.add_widget(btn_cancel)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title="Rename Project",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.92, None),
|
||||||
|
size=(0, 260),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_rename(instance):
|
||||||
|
new_name = input_field.text.strip()
|
||||||
|
if new_name and new_name != self.project_name:
|
||||||
|
if self.rename_project_folder(self.project_name, new_name):
|
||||||
|
self.project_name = new_name
|
||||||
|
popup.dismiss()
|
||||||
|
self.on_pre_enter() # Refresh label
|
||||||
|
else:
|
||||||
|
label.text = "Rename failed (name exists?)"
|
||||||
|
else:
|
||||||
|
label.text = "Please enter a new name."
|
||||||
|
|
||||||
|
btn_save.bind(on_press=do_rename)
|
||||||
|
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def rename_project_folder(self, old_name, new_name):
|
||||||
|
import os
|
||||||
|
old_path = os.path.join(RESOURCES_FOLDER, "projects", old_name)
|
||||||
|
new_path = os.path.join(RESOURCES_FOLDER, "projects", new_name)
|
||||||
|
if os.path.exists(old_path) and not os.path.exists(new_path):
|
||||||
|
os.rename(old_path, new_path)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def optimize_route_entries(self):
|
||||||
|
# Create the popup and UI elements
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Processing route entries...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Optimizing Route",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.92, None),
|
||||||
|
size=(0, 260),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
# Now call the utility function with these objects
|
||||||
|
optimize_route_entries_util(
|
||||||
|
self.project_name,
|
||||||
|
RESOURCES_FOLDER,
|
||||||
|
label,
|
||||||
|
progress,
|
||||||
|
popup,
|
||||||
|
Clock,
|
||||||
|
on_save=lambda: self.on_pre_enter()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_route(self):
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Processing route preview...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Previewing Route",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.8, None),
|
||||||
|
size=(0, 180),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def set_preview_image_path(path):
|
||||||
|
self.preview_image_path = path
|
||||||
|
self.preview_image_version += 1 # Force AliasProperty to update
|
||||||
|
self.property('preview_image_source').dispatch(self)
|
||||||
|
self.ids.preview_image.reload()
|
||||||
|
# Schedule the processing function
|
||||||
|
Clock.schedule_once(
|
||||||
|
lambda dt: process_preview_util(
|
||||||
|
self.project_name,
|
||||||
|
RESOURCES_FOLDER,
|
||||||
|
label,
|
||||||
|
progress,
|
||||||
|
popup,
|
||||||
|
self.ids.preview_image,
|
||||||
|
set_preview_image_path,
|
||||||
|
Clock
|
||||||
|
),
|
||||||
|
0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_pauses_popup(self):
|
||||||
|
"""Navigate to the pause edit screen"""
|
||||||
|
pause_edit_screen = self.manager.get_screen("pause_edit")
|
||||||
|
pause_edit_screen.set_project_and_callback(self.project_name, self.on_pre_enter)
|
||||||
|
self.manager.current = "pause_edit"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def show_video_generation_options(self):
|
||||||
|
"""Show popup with video generation mode options including new advanced animations"""
|
||||||
|
from kivy.uix.popup import Popup
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=12, padding=15)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title_label = Label(
|
||||||
|
text="Choose Animation Style & Quality",
|
||||||
|
font_size=20,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=40,
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
layout.add_widget(title_label)
|
||||||
|
|
||||||
|
# Classic 3D Mode
|
||||||
|
classic_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||||
|
classic_title = Label(
|
||||||
|
text="🏃♂️ Classic 3D (Original Pipeline)",
|
||||||
|
font_size=16,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=30,
|
||||||
|
color=(0.2, 0.8, 0.2, 1)
|
||||||
|
)
|
||||||
|
classic_desc = Label(
|
||||||
|
text="• Traditional OpenCV/PIL approach\n• Fast generation\n• Good for simple tracks\n• Test (720p) or Production (2K)",
|
||||||
|
font_size=11,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=70,
|
||||||
|
color=(0.9, 0.9, 0.9, 1),
|
||||||
|
halign="left",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
classic_desc.text_size = (None, None)
|
||||||
|
classic_layout.add_widget(classic_title)
|
||||||
|
classic_layout.add_widget(classic_desc)
|
||||||
|
layout.add_widget(classic_layout)
|
||||||
|
|
||||||
|
# Classic buttons
|
||||||
|
classic_btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=45)
|
||||||
|
classic_test_btn = Button(
|
||||||
|
text="Classic 720p",
|
||||||
|
background_color=(0.2, 0.8, 0.2, 1),
|
||||||
|
font_size=12
|
||||||
|
)
|
||||||
|
classic_prod_btn = Button(
|
||||||
|
text="Classic 2K",
|
||||||
|
background_color=(0.3, 0.6, 0.3, 1),
|
||||||
|
font_size=12
|
||||||
|
)
|
||||||
|
classic_btn_layout.add_widget(classic_test_btn)
|
||||||
|
classic_btn_layout.add_widget(classic_prod_btn)
|
||||||
|
layout.add_widget(classic_btn_layout)
|
||||||
|
|
||||||
|
# Advanced Navigation Mode
|
||||||
|
advanced_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||||
|
advanced_title = Label(
|
||||||
|
text="🧭 Navigation Animation",
|
||||||
|
font_size=16,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=30,
|
||||||
|
color=(0.2, 0.6, 0.9, 1)
|
||||||
|
)
|
||||||
|
advanced_desc = Label(
|
||||||
|
text="• Satellite terrain details\n• 3D camera following at 1000-2000m\n• Google Earth entry scene\n• Professional navigation view",
|
||||||
|
font_size=11,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=70,
|
||||||
|
color=(0.9, 0.9, 0.9, 1),
|
||||||
|
halign="left",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
advanced_desc.text_size = (None, None)
|
||||||
|
advanced_layout.add_widget(advanced_title)
|
||||||
|
advanced_layout.add_widget(advanced_desc)
|
||||||
|
layout.add_widget(advanced_layout)
|
||||||
|
|
||||||
|
# Advanced button
|
||||||
|
advanced_btn = Button(
|
||||||
|
text="Generate Navigation Animation",
|
||||||
|
background_color=(0.2, 0.6, 0.9, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=45,
|
||||||
|
font_size=13
|
||||||
|
)
|
||||||
|
layout.add_widget(advanced_btn)
|
||||||
|
|
||||||
|
# Google Earth Flythrough Mode
|
||||||
|
google_earth_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||||
|
google_earth_title = Label(
|
||||||
|
text="🌍 Google Earth Flythrough",
|
||||||
|
font_size=16,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=30,
|
||||||
|
color=(0.1, 0.8, 0.1, 1)
|
||||||
|
)
|
||||||
|
google_earth_desc = Label(
|
||||||
|
text="• Realistic 3D terrain with mountains\n• Cinematic camera following at 1000-2000m\n• Google Earth-style flythrough\n• Professional geographic animation",
|
||||||
|
font_size=11,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=70,
|
||||||
|
color=(0.9, 0.9, 0.9, 1),
|
||||||
|
halign="left",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
google_earth_desc.text_size = (None, None)
|
||||||
|
google_earth_layout.add_widget(google_earth_title)
|
||||||
|
google_earth_layout.add_widget(google_earth_desc)
|
||||||
|
layout.add_widget(google_earth_layout)
|
||||||
|
|
||||||
|
# Google Earth button
|
||||||
|
google_earth_btn = Button(
|
||||||
|
text="Generate Google Earth Flythrough",
|
||||||
|
background_color=(0.1, 0.8, 0.1, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=45,
|
||||||
|
font_size=13
|
||||||
|
)
|
||||||
|
layout.add_widget(google_earth_btn)
|
||||||
|
|
||||||
|
# Blender Cinema Mode
|
||||||
|
blender_layout = BoxLayout(orientation='vertical', spacing=5)
|
||||||
|
blender_title = Label(
|
||||||
|
text="<22> Cinema Quality (Blender)",
|
||||||
|
font_size=16,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=30,
|
||||||
|
color=(0.9, 0.6, 0.2, 1)
|
||||||
|
)
|
||||||
|
blender_desc = Label(
|
||||||
|
text="• Professional 3D rendering\n• Photorealistic visuals\n• Cinema-grade quality\n• Longer processing time",
|
||||||
|
font_size=11,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=70,
|
||||||
|
color=(0.9, 0.9, 0.9, 1),
|
||||||
|
halign="left",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
blender_desc.text_size = (None, None)
|
||||||
|
blender_layout.add_widget(blender_title)
|
||||||
|
blender_layout.add_widget(blender_desc)
|
||||||
|
layout.add_widget(blender_layout)
|
||||||
|
|
||||||
|
# Blender button
|
||||||
|
blender_btn = Button(
|
||||||
|
text="Generate Blender Cinema Animation",
|
||||||
|
background_color=(0.9, 0.6, 0.2, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=45,
|
||||||
|
font_size=13
|
||||||
|
)
|
||||||
|
layout.add_widget(blender_btn)
|
||||||
|
|
||||||
|
# Cancel button
|
||||||
|
cancel_btn = Button(
|
||||||
|
text="Cancel",
|
||||||
|
background_color=(0.5, 0.5, 0.5, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=40,
|
||||||
|
font_size=12
|
||||||
|
)
|
||||||
|
layout.add_widget(cancel_btn)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title="Select Animation Style",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.95, 0.9),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_classic_test(instance):
|
||||||
|
popup.dismiss()
|
||||||
|
self.generate_3d_video_test_mode()
|
||||||
|
|
||||||
|
def start_classic_production(instance):
|
||||||
|
popup.dismiss()
|
||||||
|
self.generate_3d_video_production_mode()
|
||||||
|
|
||||||
|
def start_advanced_3d(instance):
|
||||||
|
popup.dismiss()
|
||||||
|
self.generate_advanced_3d_animation()
|
||||||
|
|
||||||
|
def start_google_earth(instance):
|
||||||
|
popup.dismiss()
|
||||||
|
self.generate_google_earth_animation()
|
||||||
|
|
||||||
|
def start_blender_animation(instance):
|
||||||
|
popup.dismiss()
|
||||||
|
self.generate_blender_animation()
|
||||||
|
|
||||||
|
classic_test_btn.bind(on_press=start_classic_test)
|
||||||
|
classic_prod_btn.bind(on_press=start_classic_production)
|
||||||
|
advanced_btn.bind(on_press=start_advanced_3d)
|
||||||
|
google_earth_btn.bind(on_press=start_google_earth)
|
||||||
|
blender_btn.bind(on_press=start_blender_animation)
|
||||||
|
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def generate_blender_animation(self):
|
||||||
|
"""Generate cinema-quality animation using Blender"""
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Initializing Blender rendering pipeline...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Generating Blender Cinema Animation",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def run_blender_animation():
|
||||||
|
try:
|
||||||
|
# Update status
|
||||||
|
def update_status(progress_val, status_text):
|
||||||
|
def _update(dt):
|
||||||
|
progress.value = progress_val
|
||||||
|
label.text = status_text
|
||||||
|
Clock.schedule_once(_update, 0)
|
||||||
|
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
|
||||||
|
if not os.path.exists(positions_path):
|
||||||
|
update_status(0, "Error: No GPS data found")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(10, "Loading GPS data into Blender...")
|
||||||
|
|
||||||
|
# Check dependencies first
|
||||||
|
animator = BlenderGPSAnimator(project_folder)
|
||||||
|
animator.check_dependencies()
|
||||||
|
|
||||||
|
update_status(25, "Processing GPS coordinates...")
|
||||||
|
gps_data = animator.load_gps_data(positions_path)
|
||||||
|
|
||||||
|
output_video_path = os.path.join(project_folder, f"{self.project_name}_blender_cinema_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
|
|
||||||
|
# Progress callback for the animator
|
||||||
|
def animator_progress(progress, message):
|
||||||
|
update_status(25 + (progress * 0.6), message) # Map 0-100% to 25-85%
|
||||||
|
|
||||||
|
update_status(85, "Rendering cinema-quality video...")
|
||||||
|
success = animator.create_gps_animation(
|
||||||
|
positions_path,
|
||||||
|
output_video_path,
|
||||||
|
progress_callback=animator_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
update_status(100, "Blender cinema animation complete!")
|
||||||
|
output_path = output_video_path
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to render Blender animation")
|
||||||
|
|
||||||
|
def show_success(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_success_popup(
|
||||||
|
"Blender Cinema Animation Complete!",
|
||||||
|
f"Your cinema-quality animation has been rendered to:\n{output_path}",
|
||||||
|
output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_success, 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
def show_error(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_error_popup("Blender Animation Error", error_message)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_error, 0)
|
||||||
|
|
||||||
|
# Schedule the animation generation
|
||||||
|
Clock.schedule_once(lambda dt: run_blender_animation(), 0.5)
|
||||||
|
|
||||||
|
def generate_google_earth_animation(self):
|
||||||
|
"""Generate Google Earth-style flythrough animation with terrain"""
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Initializing Google Earth flythrough...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Generating Google Earth Flythrough",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def run_google_earth_animation():
|
||||||
|
try:
|
||||||
|
# Update status
|
||||||
|
def update_status(progress_val, status_text):
|
||||||
|
def _update(dt):
|
||||||
|
progress.value = progress_val
|
||||||
|
label.text = status_text
|
||||||
|
Clock.schedule_once(_update, 0)
|
||||||
|
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
|
||||||
|
if not os.path.exists(positions_path):
|
||||||
|
update_status(0, "Error: No GPS data found")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(10, "Checking dependencies...")
|
||||||
|
|
||||||
|
# Check dependencies first
|
||||||
|
generator = NavigationAnimationGenerator(project_folder)
|
||||||
|
generator.check_dependencies()
|
||||||
|
|
||||||
|
update_status(20, "Loading GPS data...")
|
||||||
|
df = generator.load_gps_data(positions_path)
|
||||||
|
|
||||||
|
update_status(30, "Generating navigation flythrough...")
|
||||||
|
output_video_path = os.path.join(project_folder, f"{self.project_name}_navigation_flythrough_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
|
|
||||||
|
# Progress callback for the generator
|
||||||
|
def generator_progress(progress, message):
|
||||||
|
update_status(30 + (progress * 0.5), message) # Map 0-100% to 30-80%
|
||||||
|
|
||||||
|
update_status(80, "Creating navigation flythrough...")
|
||||||
|
success = generator.generate_3d_animation(
|
||||||
|
positions_path,
|
||||||
|
output_video_path,
|
||||||
|
style='advanced',
|
||||||
|
progress_callback=generator_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
update_status(100, "Navigation flythrough complete!")
|
||||||
|
output_path = output_video_path
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to generate navigation flythrough")
|
||||||
|
|
||||||
|
def show_success(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_success_popup(
|
||||||
|
"Google Earth Flythrough Complete!",
|
||||||
|
f"Your cinematic flythrough has been created:\n{output_path}",
|
||||||
|
output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_success, 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
def show_error(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_error_popup("Google Earth Animation Error", error_message)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_error, 0)
|
||||||
|
|
||||||
|
# Schedule the animation generation
|
||||||
|
Clock.schedule_once(lambda dt: run_google_earth_animation(), 0.5)
|
||||||
|
|
||||||
|
def show_success_popup(self, title, message, file_path=None):
|
||||||
|
"""Show success popup with option to open file location"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
|
||||||
|
success_label = Label(
|
||||||
|
text=message,
|
||||||
|
text_size=(400, None),
|
||||||
|
halign="center",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
layout.add_widget(success_label)
|
||||||
|
|
||||||
|
button_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=50)
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
open_btn = Button(text="Open Folder", background_color=(0.2, 0.8, 0.2, 1))
|
||||||
|
open_btn.bind(on_press=lambda x: self.open_file_location(file_path))
|
||||||
|
button_layout.add_widget(open_btn)
|
||||||
|
|
||||||
|
ok_btn = Button(text="OK", background_color=(0.2, 0.6, 0.9, 1))
|
||||||
|
button_layout.add_widget(ok_btn)
|
||||||
|
layout.add_widget(button_layout)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title=title,
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.8, None),
|
||||||
|
size=(0, 250),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def show_error_popup(self, title, message):
|
||||||
|
"""Show error popup"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
|
||||||
|
error_label = Label(
|
||||||
|
text=f"Error: {message}",
|
||||||
|
text_size=(400, None),
|
||||||
|
halign="center",
|
||||||
|
valign="middle",
|
||||||
|
color=(1, 0.3, 0.3, 1)
|
||||||
|
)
|
||||||
|
layout.add_widget(error_label)
|
||||||
|
|
||||||
|
ok_btn = Button(text="OK", background_color=(0.8, 0.2, 0.2, 1), size_hint_y=None, height=50)
|
||||||
|
layout.add_widget(ok_btn)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title=title,
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.8, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def open_file_location(self, file_path):
|
||||||
|
"""Open file location in system file manager"""
|
||||||
|
import subprocess
|
||||||
|
import platform
|
||||||
|
|
||||||
|
folder_path = os.path.dirname(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if platform.system() == "Linux":
|
||||||
|
subprocess.run(["xdg-open", folder_path])
|
||||||
|
elif platform.system() == "Darwin": # macOS
|
||||||
|
subprocess.run(["open", folder_path])
|
||||||
|
elif platform.system() == "Windows":
|
||||||
|
subprocess.run(["explorer", folder_path])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not open folder: {e}")
|
||||||
|
|
||||||
419
screens/create_animation_screen_clean.py
Normal file
419
screens/create_animation_screen_clean.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import kivy
|
||||||
|
from kivy.uix.screenmanager import Screen
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.properties import StringProperty, NumericProperty, AliasProperty
|
||||||
|
from py_scripts.utils import (
|
||||||
|
process_preview_util, optimize_route_entries_util
|
||||||
|
)
|
||||||
|
from py_scripts.advanced_3d_generator import NavigationAnimationGenerator
|
||||||
|
from py_scripts.blender_animator import BlenderGPSAnimator
|
||||||
|
from kivy.uix.popup import Popup
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.progressbar import ProgressBar
|
||||||
|
from kivy.uix.textinput import TextInput
|
||||||
|
from config import RESOURCES_FOLDER
|
||||||
|
|
||||||
|
class CreateAnimationScreen(Screen):
|
||||||
|
project_name = StringProperty("")
|
||||||
|
preview_html_path = StringProperty("") # Path to the HTML file for preview
|
||||||
|
preview_image_path = StringProperty("") # Add this line
|
||||||
|
preview_image_version = NumericProperty(0) # Add this line
|
||||||
|
|
||||||
|
def get_preview_image_source(self):
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
img_path = os.path.join(project_folder, "preview.png")
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
return img_path
|
||||||
|
return "resources/images/track.png"
|
||||||
|
|
||||||
|
preview_image_source = AliasProperty(
|
||||||
|
get_preview_image_source, None, bind=['project_name', 'preview_image_version']
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_pre_enter(self):
|
||||||
|
# Update the route entries label with the actual number of entries
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
count = 0
|
||||||
|
if os.path.exists(positions_path):
|
||||||
|
with open(positions_path, "r") as f:
|
||||||
|
try:
|
||||||
|
positions = json.load(f)
|
||||||
|
count = len(positions)
|
||||||
|
except Exception:
|
||||||
|
count = 0
|
||||||
|
self.ids.route_entries_label.text = f"Your route has {count} entries,"
|
||||||
|
|
||||||
|
def open_rename_popup(self):
|
||||||
|
from kivy.uix.popup import Popup
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.textinput import TextInput
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Enter new project name:")
|
||||||
|
input_field = TextInput(text=self.project_name, multiline=False)
|
||||||
|
btn_save = Button(text="Save", background_color=(0.008, 0.525, 0.290, 1))
|
||||||
|
btn_cancel = Button(text="Cancel")
|
||||||
|
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(input_field)
|
||||||
|
layout.add_widget(btn_save)
|
||||||
|
layout.add_widget(btn_cancel)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title="Rename Project",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.92, None),
|
||||||
|
size=(0, 260),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_rename(instance):
|
||||||
|
new_name = input_field.text.strip()
|
||||||
|
if new_name and new_name != self.project_name:
|
||||||
|
if self.rename_project_folder(self.project_name, new_name):
|
||||||
|
self.project_name = new_name
|
||||||
|
popup.dismiss()
|
||||||
|
self.on_pre_enter() # Refresh label
|
||||||
|
else:
|
||||||
|
label.text = "Rename failed (name exists?)"
|
||||||
|
else:
|
||||||
|
label.text = "Please enter a new name."
|
||||||
|
|
||||||
|
btn_save.bind(on_press=do_rename)
|
||||||
|
btn_cancel.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def rename_project_folder(self, old_name, new_name):
|
||||||
|
import os
|
||||||
|
old_path = os.path.join(RESOURCES_FOLDER, "projects", old_name)
|
||||||
|
new_path = os.path.join(RESOURCES_FOLDER, "projects", new_name)
|
||||||
|
if os.path.exists(old_path) and not os.path.exists(new_path):
|
||||||
|
os.rename(old_path, new_path)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def optimize_route_entries(self):
|
||||||
|
# Create the popup and UI elements
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Processing route entries...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Optimizing Route",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.92, None),
|
||||||
|
size=(0, 260),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
# Now call the utility function with these objects
|
||||||
|
optimize_route_entries_util(
|
||||||
|
self.project_name,
|
||||||
|
RESOURCES_FOLDER,
|
||||||
|
label,
|
||||||
|
progress,
|
||||||
|
popup,
|
||||||
|
Clock,
|
||||||
|
on_save=lambda: self.on_pre_enter()
|
||||||
|
)
|
||||||
|
|
||||||
|
def preview_route(self):
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Processing route preview...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Previewing Route",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.8, None),
|
||||||
|
size=(0, 180),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def set_preview_image_path(path):
|
||||||
|
self.preview_image_path = path
|
||||||
|
self.preview_image_version += 1 # Force AliasProperty to update
|
||||||
|
self.property('preview_image_source').dispatch(self)
|
||||||
|
self.ids.preview_image.reload()
|
||||||
|
# Schedule the processing function
|
||||||
|
Clock.schedule_once(
|
||||||
|
lambda dt: process_preview_util(
|
||||||
|
self.project_name,
|
||||||
|
RESOURCES_FOLDER,
|
||||||
|
label,
|
||||||
|
progress,
|
||||||
|
popup,
|
||||||
|
self.ids.preview_image,
|
||||||
|
set_preview_image_path,
|
||||||
|
Clock
|
||||||
|
),
|
||||||
|
0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_pauses_popup(self):
|
||||||
|
"""Navigate to the pause edit screen"""
|
||||||
|
pause_edit_screen = self.manager.get_screen("pause_edit")
|
||||||
|
pause_edit_screen.set_project_and_callback(self.project_name, self.on_pre_enter)
|
||||||
|
self.manager.current = "pause_edit"
|
||||||
|
|
||||||
|
def generate_google_earth_animation(self):
|
||||||
|
"""Generate Google Earth-style flythrough animation using NavigationAnimationGenerator"""
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Initializing Google Earth flythrough...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Generating Google Earth Flythrough",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def run_google_earth_animation():
|
||||||
|
try:
|
||||||
|
# Update status
|
||||||
|
def update_status(progress_val, status_text):
|
||||||
|
def _update(dt):
|
||||||
|
progress.value = progress_val
|
||||||
|
label.text = status_text
|
||||||
|
Clock.schedule_once(_update, 0)
|
||||||
|
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
|
||||||
|
if not os.path.exists(positions_path):
|
||||||
|
update_status(0, "Error: No GPS data found")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(10, "Loading GPS data...")
|
||||||
|
|
||||||
|
# Check dependencies first
|
||||||
|
generator = NavigationAnimationGenerator(project_folder)
|
||||||
|
generator.check_dependencies()
|
||||||
|
|
||||||
|
update_status(20, "Processing GPS coordinates...")
|
||||||
|
df = generator.load_gps_data(positions_path)
|
||||||
|
|
||||||
|
update_status(40, "Creating Google Earth flythrough...")
|
||||||
|
output_video_path = os.path.join(project_folder, f"{self.project_name}_google_earth_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
|
|
||||||
|
# Progress callback for the generator
|
||||||
|
def generator_progress(progress, message):
|
||||||
|
update_status(40 + (progress * 0.5), message) # Map 0-100% to 40-90%
|
||||||
|
|
||||||
|
update_status(90, "Creating flythrough video...")
|
||||||
|
success = generator.generate_frames(positions_path, style='google_earth', progress_callback=generator_progress)
|
||||||
|
|
||||||
|
if success and len(success) > 0:
|
||||||
|
update_status(95, "Rendering final video...")
|
||||||
|
video_success = generator.create_video(success, output_video_path, generator_progress)
|
||||||
|
if video_success:
|
||||||
|
update_status(100, "Google Earth flythrough complete!")
|
||||||
|
output_path = output_video_path
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to create video from frames")
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to generate frames")
|
||||||
|
|
||||||
|
def show_success(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_success_popup(
|
||||||
|
"Google Earth Flythrough Complete!",
|
||||||
|
f"Your Google Earth-style flythrough has been saved to:\n{output_path}",
|
||||||
|
output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_success, 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
def show_error(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_error_popup("Google Earth Animation Error", error_message)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_error, 0)
|
||||||
|
|
||||||
|
# Schedule the animation generation
|
||||||
|
Clock.schedule_once(lambda dt: run_google_earth_animation(), 0.5)
|
||||||
|
|
||||||
|
def generate_blender_animation(self):
|
||||||
|
"""Generate cinema-quality animation using Blender"""
|
||||||
|
# Show processing popup
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
label = Label(text="Initializing Blender rendering pipeline...")
|
||||||
|
progress = ProgressBar(max=100, value=0)
|
||||||
|
layout.add_widget(label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
popup = Popup(
|
||||||
|
title="Generating Blender Cinema Animation",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, None),
|
||||||
|
size=(0, 200),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def run_blender_animation():
|
||||||
|
try:
|
||||||
|
# Update status
|
||||||
|
def update_status(progress_val, status_text):
|
||||||
|
def _update(dt):
|
||||||
|
progress.value = progress_val
|
||||||
|
label.text = status_text
|
||||||
|
Clock.schedule_once(_update, 0)
|
||||||
|
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
positions_path = os.path.join(project_folder, "positions.json")
|
||||||
|
|
||||||
|
if not os.path.exists(positions_path):
|
||||||
|
update_status(0, "Error: No GPS data found")
|
||||||
|
Clock.schedule_once(lambda dt: popup.dismiss(), 2)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(10, "Loading GPS data into Blender...")
|
||||||
|
|
||||||
|
# Check dependencies first
|
||||||
|
animator = BlenderGPSAnimator(project_folder)
|
||||||
|
animator.check_dependencies()
|
||||||
|
|
||||||
|
update_status(25, "Processing GPS coordinates...")
|
||||||
|
gps_data = animator.load_gps_data(positions_path)
|
||||||
|
|
||||||
|
output_video_path = os.path.join(project_folder, f"{self.project_name}_blender_cinema_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4")
|
||||||
|
|
||||||
|
# Progress callback for the animator
|
||||||
|
def animator_progress(progress, message):
|
||||||
|
update_status(25 + (progress * 0.6), message) # Map 0-100% to 25-85%
|
||||||
|
|
||||||
|
update_status(85, "Rendering cinema-quality video...")
|
||||||
|
success = animator.create_gps_animation(
|
||||||
|
positions_path,
|
||||||
|
output_video_path,
|
||||||
|
progress_callback=animator_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
update_status(100, "Blender cinema animation complete!")
|
||||||
|
output_path = output_video_path
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to generate Blender animation")
|
||||||
|
|
||||||
|
def show_success(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_success_popup(
|
||||||
|
"Blender Cinema Animation Complete!",
|
||||||
|
f"Your cinema-quality animation has been saved to:\n{output_path}",
|
||||||
|
output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_success, 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)
|
||||||
|
def show_error(dt):
|
||||||
|
popup.dismiss()
|
||||||
|
self.show_error_popup("Blender Animation Error", error_message)
|
||||||
|
|
||||||
|
Clock.schedule_once(show_error, 0)
|
||||||
|
|
||||||
|
# Schedule the animation generation
|
||||||
|
Clock.schedule_once(lambda dt: run_blender_animation(), 0.5)
|
||||||
|
|
||||||
|
def show_success_popup(self, title, message, file_path):
|
||||||
|
"""Show success popup with option to open file location"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||||
|
|
||||||
|
# Success message
|
||||||
|
success_label = Label(
|
||||||
|
text=message,
|
||||||
|
text_size=(None, None),
|
||||||
|
halign="center",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
layout.add_widget(success_label)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=50)
|
||||||
|
|
||||||
|
open_folder_btn = Button(
|
||||||
|
text="Open Folder",
|
||||||
|
background_color=(0.2, 0.6, 0.9, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
ok_btn = Button(
|
||||||
|
text="OK",
|
||||||
|
background_color=(0.3, 0.7, 0.3, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
btn_layout.add_widget(open_folder_btn)
|
||||||
|
btn_layout.add_widget(ok_btn)
|
||||||
|
layout.add_widget(btn_layout)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title=title,
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.9, 0.6),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_folder(instance):
|
||||||
|
folder_path = os.path.dirname(file_path)
|
||||||
|
os.system(f'xdg-open "{folder_path}"') # Linux
|
||||||
|
popup.dismiss()
|
||||||
|
|
||||||
|
def close_popup(instance):
|
||||||
|
popup.dismiss()
|
||||||
|
|
||||||
|
open_folder_btn.bind(on_press=open_folder)
|
||||||
|
ok_btn.bind(on_press=close_popup)
|
||||||
|
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def show_error_popup(self, title, message):
|
||||||
|
"""Show error popup"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=15)
|
||||||
|
|
||||||
|
error_label = Label(
|
||||||
|
text=f"Error: {message}",
|
||||||
|
text_size=(None, None),
|
||||||
|
halign="center",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
layout.add_widget(error_label)
|
||||||
|
|
||||||
|
ok_btn = Button(
|
||||||
|
text="OK",
|
||||||
|
background_color=(0.8, 0.3, 0.3, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=50
|
||||||
|
)
|
||||||
|
layout.add_widget(ok_btn)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title=title,
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.8, 0.4),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
from kivy.properties import StringProperty, ListProperty
|
from kivy.properties import StringProperty, ListProperty
|
||||||
from utils import (
|
from py_scripts.utils import (
|
||||||
generate_key, load_key, encrypt_data, decrypt_data,
|
generate_key, load_key, encrypt_data, decrypt_data,
|
||||||
check_server_settings, save_server_settings,
|
check_server_settings, save_server_settings,
|
||||||
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from kivy.clock import Clock
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
||||||
from utils import encrypt_data, decrypt_data, check_server_settings, save_server_settings, test_connection
|
from py_scripts.utils import encrypt_data, decrypt_data, check_server_settings, save_server_settings, test_connection
|
||||||
|
|
||||||
|
|
||||||
class IconButton(ButtonBehavior, Image):
|
class IconButton(ButtonBehavior, Image):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from kivy.clock import Clock
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
from config import RESOURCES_FOLDER, CREDENTIALS_FILE
|
||||||
from utils import encrypt_data, decrypt_data, check_server_settings, save_server_settings, test_connection
|
from py_scripts.utils import encrypt_data, decrypt_data, check_server_settings, save_server_settings, test_connection
|
||||||
|
|
||||||
|
|
||||||
class LoginScreen(Screen):
|
class LoginScreen(Screen):
|
||||||
|
|||||||
0
screens/pause_edit_screen.py
Normal file
0
screens/pause_edit_screen.py
Normal file
889
screens/pause_edit_screen_improved.py
Normal file
889
screens/pause_edit_screen_improved.py
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
import kivy
|
||||||
|
from kivy.uix.screenmanager import Screen
|
||||||
|
from kivy.uix.boxlayout import BoxLayout
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.uix.button import Button
|
||||||
|
from kivy.uix.textinput import TextInput
|
||||||
|
from kivy.uix.filechooser import FileChooserIconView
|
||||||
|
from kivy.uix.widget import Widget
|
||||||
|
from kivy.uix.image import Image
|
||||||
|
from kivy.uix.carousel import Carousel
|
||||||
|
from kivy.uix.progressbar import ProgressBar
|
||||||
|
from kivy.graphics import Color, Rectangle, Line
|
||||||
|
from kivy.uix.scrollview import ScrollView
|
||||||
|
from kivy.uix.popup import Popup
|
||||||
|
from kivy.uix.gridlayout import GridLayout
|
||||||
|
from kivy.properties import StringProperty
|
||||||
|
from kivy.clock import Clock
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from geopy.geocoders import Nominatim
|
||||||
|
from config import RESOURCES_FOLDER
|
||||||
|
|
||||||
|
class PauseEditScreen(Screen):
|
||||||
|
project_name = StringProperty("")
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.pauses = []
|
||||||
|
self.on_save_callback = None
|
||||||
|
self.loading_popup = None
|
||||||
|
self.carousel = None
|
||||||
|
|
||||||
|
def on_pre_enter(self):
|
||||||
|
"""Called when entering the screen"""
|
||||||
|
self.show_loading_popup()
|
||||||
|
# Delay the layout building to show loading popup first
|
||||||
|
Clock.schedule_once(self.start_loading_process, 0.1)
|
||||||
|
|
||||||
|
def show_loading_popup(self):
|
||||||
|
"""Show loading popup while building the layout"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=20, padding=20)
|
||||||
|
|
||||||
|
# Loading animation/progress bar
|
||||||
|
progress = ProgressBar(
|
||||||
|
max=100,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Animate the progress bar
|
||||||
|
def animate_progress(dt):
|
||||||
|
if progress.value < 95:
|
||||||
|
progress.value += 5
|
||||||
|
else:
|
||||||
|
progress.value = 10 # Reset for continuous animation
|
||||||
|
|
||||||
|
Clock.schedule_interval(animate_progress, 0.1)
|
||||||
|
|
||||||
|
loading_label = Label(
|
||||||
|
text="Loading pause information...\nPlease wait",
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=16,
|
||||||
|
halign="center",
|
||||||
|
text_size=(300, None)
|
||||||
|
)
|
||||||
|
|
||||||
|
layout.add_widget(loading_label)
|
||||||
|
layout.add_widget(progress)
|
||||||
|
|
||||||
|
self.loading_popup = Popup(
|
||||||
|
title="Loading Pauses",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.8, 0.3),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
self.loading_popup.open()
|
||||||
|
|
||||||
|
def start_loading_process(self, dt):
|
||||||
|
"""Start the loading process in background"""
|
||||||
|
# Run the heavy loading in a separate thread
|
||||||
|
thread = threading.Thread(target=self.load_data_background)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def load_data_background(self):
|
||||||
|
"""Load pause data in background thread"""
|
||||||
|
try:
|
||||||
|
# Load pauses
|
||||||
|
self.load_pauses()
|
||||||
|
|
||||||
|
# Pre-process location suggestions to speed up UI
|
||||||
|
for pause in self.pauses:
|
||||||
|
lat = pause["location"]["latitude"]
|
||||||
|
lon = pause["location"]["longitude"]
|
||||||
|
# Cache the location suggestion
|
||||||
|
if 'location_suggestion' not in pause:
|
||||||
|
pause['location_suggestion'] = self.suggest_location_name(lat, lon)
|
||||||
|
|
||||||
|
# Schedule UI update on main thread
|
||||||
|
Clock.schedule_once(self.finish_loading, 0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading pause data: {e}")
|
||||||
|
Clock.schedule_once(self.finish_loading, 0)
|
||||||
|
|
||||||
|
def finish_loading(self, dt):
|
||||||
|
"""Finish loading and build the UI"""
|
||||||
|
try:
|
||||||
|
self.build_pause_layout()
|
||||||
|
finally:
|
||||||
|
# Close loading popup
|
||||||
|
if self.loading_popup:
|
||||||
|
self.loading_popup.dismiss()
|
||||||
|
self.loading_popup = None
|
||||||
|
|
||||||
|
def suggest_location_name(self, lat, lon):
|
||||||
|
"""Simplified location suggestion"""
|
||||||
|
try:
|
||||||
|
geolocator = Nominatim(user_agent="traccar_animation")
|
||||||
|
location = geolocator.reverse((lat, lon), exactly_one=True, timeout=8, addressdetails=True)
|
||||||
|
|
||||||
|
if location and location.raw:
|
||||||
|
address = location.raw.get('address', {})
|
||||||
|
|
||||||
|
# Look for street address first
|
||||||
|
if 'road' in address:
|
||||||
|
road = address['road']
|
||||||
|
if 'house_number' in address:
|
||||||
|
return f"{road} {address['house_number']}"
|
||||||
|
return road
|
||||||
|
|
||||||
|
# Look for area name
|
||||||
|
for key in ['neighbourhood', 'suburb', 'village', 'town', 'city']:
|
||||||
|
if key in address and address[key]:
|
||||||
|
return address[key]
|
||||||
|
|
||||||
|
return f"Location {lat:.4f}, {lon:.4f}"
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return f"Location {lat:.4f}, {lon:.4f}"
|
||||||
|
|
||||||
|
def load_pauses(self):
|
||||||
|
"""Load pauses from the project folder"""
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||||
|
|
||||||
|
if os.path.exists(pauses_path):
|
||||||
|
with open(pauses_path, "r") as f:
|
||||||
|
self.pauses = json.load(f)
|
||||||
|
else:
|
||||||
|
self.pauses = []
|
||||||
|
|
||||||
|
def save_pauses(self):
|
||||||
|
"""Save pauses to the project folder"""
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
pauses_path = os.path.join(project_folder, "pauses.json")
|
||||||
|
|
||||||
|
with open(pauses_path, "w") as f:
|
||||||
|
json.dump(self.pauses, f, indent=2)
|
||||||
|
|
||||||
|
def build_pause_layout(self):
|
||||||
|
"""Build the main pause editing layout with carousel for multiple pauses"""
|
||||||
|
self.clear_widgets()
|
||||||
|
|
||||||
|
# Main layout with dark background
|
||||||
|
main_layout = BoxLayout(orientation='vertical', spacing=5, padding=[5, 5, 5, 5])
|
||||||
|
with main_layout.canvas.before:
|
||||||
|
Color(0.11, 0.10, 0.15, 1)
|
||||||
|
main_layout.bg_rect = Rectangle(pos=main_layout.pos, size=main_layout.size)
|
||||||
|
main_layout.bind(pos=self.update_bg_rect, size=self.update_bg_rect)
|
||||||
|
|
||||||
|
# Header with back button and pause counter
|
||||||
|
header = BoxLayout(orientation='horizontal', size_hint_y=None, height=40, spacing=10)
|
||||||
|
|
||||||
|
back_btn = Button(
|
||||||
|
text="← Back",
|
||||||
|
size_hint_x=None,
|
||||||
|
width=70,
|
||||||
|
font_size=14,
|
||||||
|
background_color=(0.341, 0.235, 0.980, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
back_btn.bind(on_press=self.go_back)
|
||||||
|
|
||||||
|
# Dynamic title based on number of pauses
|
||||||
|
pause_count = len(self.pauses)
|
||||||
|
if pause_count > 2:
|
||||||
|
title_text = f"Edit Pauses ({pause_count} total)\nSwipe to navigate"
|
||||||
|
else:
|
||||||
|
title_text = "Edit Pauses"
|
||||||
|
|
||||||
|
title_label = Label(
|
||||||
|
text=title_text,
|
||||||
|
font_size=14 if pause_count > 2 else 16,
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
halign="center",
|
||||||
|
bold=True
|
||||||
|
)
|
||||||
|
|
||||||
|
header.add_widget(back_btn)
|
||||||
|
header.add_widget(title_label)
|
||||||
|
header.add_widget(Widget(size_hint_x=None, width=70))
|
||||||
|
|
||||||
|
main_layout.add_widget(header)
|
||||||
|
|
||||||
|
# Choose layout based on number of pauses
|
||||||
|
if pause_count > 2:
|
||||||
|
# Use carousel for multiple pauses
|
||||||
|
content_area = self.create_carousel_layout()
|
||||||
|
else:
|
||||||
|
# Use simple scroll view for 1-2 pauses
|
||||||
|
content_area = self.create_simple_scroll_layout()
|
||||||
|
|
||||||
|
main_layout.add_widget(content_area)
|
||||||
|
|
||||||
|
# Save all button at bottom
|
||||||
|
save_all_btn = Button(
|
||||||
|
text="Save All Changes & Go Back",
|
||||||
|
size_hint_y=None,
|
||||||
|
height=45,
|
||||||
|
font_size=14,
|
||||||
|
background_color=(0.2, 0.7, 0.2, 1),
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
bold=True
|
||||||
|
)
|
||||||
|
save_all_btn.bind(on_press=self.save_all_and_close)
|
||||||
|
main_layout.add_widget(save_all_btn)
|
||||||
|
|
||||||
|
self.add_widget(main_layout)
|
||||||
|
|
||||||
|
def create_carousel_layout(self):
|
||||||
|
"""Create carousel layout for multiple pauses"""
|
||||||
|
# Create carousel
|
||||||
|
self.carousel = Carousel(
|
||||||
|
direction='right',
|
||||||
|
loop=True,
|
||||||
|
size_hint=(1, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add each pause as a slide
|
||||||
|
for idx, pause in enumerate(self.pauses):
|
||||||
|
# Create a slide container
|
||||||
|
slide = BoxLayout(orientation='vertical', spacing=5, padding=[5, 5, 5, 5])
|
||||||
|
|
||||||
|
# Add pause indicator
|
||||||
|
indicator = Label(
|
||||||
|
text=f"Pause {idx + 1} of {len(self.pauses)} - Swipe for more",
|
||||||
|
font_size=12,
|
||||||
|
color=(0.8, 0.8, 0.8, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=25,
|
||||||
|
halign="center"
|
||||||
|
)
|
||||||
|
slide.add_widget(indicator)
|
||||||
|
|
||||||
|
# Create pause frame
|
||||||
|
pause_frame = self.create_pause_frame(idx, pause)
|
||||||
|
|
||||||
|
# Wrap in scroll view for this slide
|
||||||
|
scroll = ScrollView(size_hint=(1, 1))
|
||||||
|
scroll_content = BoxLayout(orientation='vertical', size_hint_y=None, padding=[2, 2, 2, 2])
|
||||||
|
scroll_content.bind(minimum_height=scroll_content.setter('height'))
|
||||||
|
scroll_content.add_widget(pause_frame)
|
||||||
|
scroll.add_widget(scroll_content)
|
||||||
|
|
||||||
|
slide.add_widget(scroll)
|
||||||
|
self.carousel.add_widget(slide)
|
||||||
|
|
||||||
|
return self.carousel
|
||||||
|
|
||||||
|
def create_simple_scroll_layout(self):
|
||||||
|
"""Create simple scroll layout for 1-2 pauses"""
|
||||||
|
scroll_content = BoxLayout(orientation='vertical', spacing=10, size_hint_y=None, padding=[5, 5, 5, 5])
|
||||||
|
scroll_content.bind(minimum_height=scroll_content.setter('height'))
|
||||||
|
|
||||||
|
for idx, pause in enumerate(self.pauses):
|
||||||
|
pause_frame = self.create_pause_frame(idx, pause)
|
||||||
|
scroll_content.add_widget(pause_frame)
|
||||||
|
|
||||||
|
scroll = ScrollView(size_hint=(1, 1))
|
||||||
|
scroll.add_widget(scroll_content)
|
||||||
|
return scroll
|
||||||
|
|
||||||
|
def update_bg_rect(self, instance, value):
|
||||||
|
"""Update background rectangle"""
|
||||||
|
instance.bg_rect.pos = instance.pos
|
||||||
|
instance.bg_rect.size = instance.size
|
||||||
|
|
||||||
|
def create_pause_frame(self, idx, pause):
|
||||||
|
"""Create a frame for a single pause"""
|
||||||
|
# Main frame with border
|
||||||
|
frame = BoxLayout(
|
||||||
|
orientation='vertical',
|
||||||
|
spacing=8,
|
||||||
|
padding=[8, 8, 8, 8],
|
||||||
|
size_hint_y=None,
|
||||||
|
height=380 # Increased height for better photo scrolling
|
||||||
|
)
|
||||||
|
|
||||||
|
with frame.canvas.before:
|
||||||
|
Color(0.18, 0.18, 0.22, 1) # Frame background
|
||||||
|
frame.bg_rect = Rectangle(pos=frame.pos, size=frame.size)
|
||||||
|
Color(0.4, 0.6, 1.0, 1) # Frame border
|
||||||
|
frame.border_line = Line(rectangle=(frame.x, frame.y, frame.width, frame.height), width=2)
|
||||||
|
|
||||||
|
def update_frame(instance, value, frame_widget=frame):
|
||||||
|
frame_widget.bg_rect.pos = frame_widget.pos
|
||||||
|
frame_widget.bg_rect.size = frame_widget.size
|
||||||
|
frame_widget.border_line.rectangle = (frame_widget.x, frame_widget.y, frame_widget.width, frame_widget.height)
|
||||||
|
frame.bind(pos=update_frame, size=update_frame)
|
||||||
|
|
||||||
|
# 1. Pause number label (centered)
|
||||||
|
pause_number_label = Label(
|
||||||
|
text=f"[b]PAUSE {idx + 1}[/b]",
|
||||||
|
markup=True,
|
||||||
|
font_size=16,
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=30,
|
||||||
|
halign="center",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
pause_number_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||||
|
frame.add_widget(pause_number_label)
|
||||||
|
|
||||||
|
# 2. Location suggestion (left aligned) - use cached version if available
|
||||||
|
suggested_place = pause.get('location_suggestion') or self.suggest_location_name(
|
||||||
|
pause["location"]["latitude"], pause["location"]["longitude"]
|
||||||
|
)
|
||||||
|
|
||||||
|
location_label = Label(
|
||||||
|
text=f"Location: {suggested_place}",
|
||||||
|
font_size=12,
|
||||||
|
color=(0.8, 0.9, 1, 1),
|
||||||
|
size_hint_y=None,
|
||||||
|
height=25,
|
||||||
|
halign="left",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
location_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||||
|
frame.add_widget(location_label)
|
||||||
|
|
||||||
|
# 3. Custom name entry and save button
|
||||||
|
name_layout = BoxLayout(orientation='horizontal', spacing=5, size_hint_y=None, height=35)
|
||||||
|
|
||||||
|
name_input = TextInput(
|
||||||
|
text=pause.get('name', ''),
|
||||||
|
hint_text="Enter custom location name...",
|
||||||
|
multiline=False,
|
||||||
|
background_color=(0.25, 0.25, 0.3, 1),
|
||||||
|
foreground_color=(1, 1, 1, 1),
|
||||||
|
font_size=12,
|
||||||
|
padding=[8, 8, 8, 8]
|
||||||
|
)
|
||||||
|
|
||||||
|
save_name_btn = Button(
|
||||||
|
text="Save Name",
|
||||||
|
size_hint_x=None,
|
||||||
|
width=80,
|
||||||
|
font_size=11,
|
||||||
|
background_color=(0.341, 0.235, 0.980, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
save_name_btn.bind(on_press=lambda x: self.save_pause_name(pause, name_input))
|
||||||
|
|
||||||
|
name_layout.add_widget(name_input)
|
||||||
|
name_layout.add_widget(save_name_btn)
|
||||||
|
frame.add_widget(name_layout)
|
||||||
|
|
||||||
|
# 4. Photos area - vertical scrolling
|
||||||
|
photos_area = self.create_photos_area_vertical(idx, pause)
|
||||||
|
frame.add_widget(photos_area)
|
||||||
|
|
||||||
|
# 5. Save and Delete buttons row
|
||||||
|
button_layout = BoxLayout(orientation='horizontal', spacing=5, size_hint_y=None, height=30)
|
||||||
|
|
||||||
|
save_pause_btn = Button(
|
||||||
|
text="Save Pause Info",
|
||||||
|
font_size=12,
|
||||||
|
background_color=(0.2, 0.6, 0.8, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
save_pause_btn.bind(on_press=lambda x: self.save_individual_pause(idx))
|
||||||
|
|
||||||
|
delete_pause_btn = Button(
|
||||||
|
text="Delete Pause",
|
||||||
|
font_size=12,
|
||||||
|
background_color=(0.8, 0.2, 0.2, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
delete_pause_btn.bind(on_press=lambda x: self.delete_pause(idx))
|
||||||
|
|
||||||
|
button_layout.add_widget(save_pause_btn)
|
||||||
|
button_layout.add_widget(delete_pause_btn)
|
||||||
|
frame.add_widget(button_layout)
|
||||||
|
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def create_photos_area_vertical(self, pause_idx, pause):
|
||||||
|
"""Create photos area with vertical scrolling"""
|
||||||
|
photos_layout = BoxLayout(orientation='vertical', spacing=5, size_hint_y=None, height=200)
|
||||||
|
|
||||||
|
# Photos header with add button
|
||||||
|
photos_header = BoxLayout(orientation='horizontal', size_hint_y=None, height=30)
|
||||||
|
|
||||||
|
photos_title = Label(
|
||||||
|
text="Photos:",
|
||||||
|
font_size=12,
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
size_hint_x=0.5,
|
||||||
|
halign="left",
|
||||||
|
valign="middle"
|
||||||
|
)
|
||||||
|
photos_title.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||||
|
|
||||||
|
add_photos_btn = Button(
|
||||||
|
text="Add Photos",
|
||||||
|
size_hint_x=0.5,
|
||||||
|
font_size=11,
|
||||||
|
background_color=(0.2, 0.7, 0.2, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
add_photos_btn.bind(on_press=lambda x: self.add_photos(pause_idx))
|
||||||
|
|
||||||
|
photos_header.add_widget(photos_title)
|
||||||
|
photos_header.add_widget(add_photos_btn)
|
||||||
|
photos_layout.add_widget(photos_header)
|
||||||
|
|
||||||
|
# Get photos for this pause
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||||
|
os.makedirs(pause_img_folder, exist_ok=True)
|
||||||
|
|
||||||
|
img_list = [f for f in os.listdir(pause_img_folder) if os.path.isfile(os.path.join(pause_img_folder, f))]
|
||||||
|
|
||||||
|
if img_list:
|
||||||
|
# Create vertical scrolling photo gallery
|
||||||
|
photos_scroll = ScrollView(size_hint=(1, 1), do_scroll_y=True, do_scroll_x=False)
|
||||||
|
photos_content = BoxLayout(orientation='vertical', spacing=2, size_hint_y=None, padding=[2, 2, 2, 2])
|
||||||
|
photos_content.bind(minimum_height=photos_content.setter('height'))
|
||||||
|
|
||||||
|
for img_file in img_list:
|
||||||
|
photo_item = self.create_vertical_photo_item(pause_idx, img_file, photos_content)
|
||||||
|
photos_content.add_widget(photo_item)
|
||||||
|
|
||||||
|
photos_scroll.add_widget(photos_content)
|
||||||
|
photos_layout.add_widget(photos_scroll)
|
||||||
|
else:
|
||||||
|
no_photos_label = Label(
|
||||||
|
text="No photos added yet",
|
||||||
|
font_size=12,
|
||||||
|
color=(0.6, 0.6, 0.6, 1),
|
||||||
|
size_hint_y=1,
|
||||||
|
halign="center"
|
||||||
|
)
|
||||||
|
photos_layout.add_widget(no_photos_label)
|
||||||
|
|
||||||
|
return photos_layout
|
||||||
|
|
||||||
|
def create_vertical_photo_item(self, pause_idx, img_file, parent_layout):
|
||||||
|
"""Create a photo item for vertical scrolling"""
|
||||||
|
# Main container with border
|
||||||
|
photo_item = BoxLayout(orientation='horizontal', spacing=5, size_hint_y=None, height=60, padding=[2, 2, 2, 2])
|
||||||
|
|
||||||
|
# Add border and background to photo item
|
||||||
|
with photo_item.canvas.before:
|
||||||
|
Color(0.25, 0.25, 0.30, 1) # Background
|
||||||
|
photo_item.bg_rect = Rectangle(pos=photo_item.pos, size=photo_item.size)
|
||||||
|
Color(0.4, 0.4, 0.5, 1) # Border
|
||||||
|
photo_item.border_line = Line(rectangle=(photo_item.x, photo_item.y, photo_item.width, photo_item.height), width=1)
|
||||||
|
|
||||||
|
def update_photo_item(instance, value, item=photo_item):
|
||||||
|
item.bg_rect.pos = item.pos
|
||||||
|
item.bg_rect.size = item.size
|
||||||
|
item.border_line.rectangle = (item.x, item.y, item.width, item.height)
|
||||||
|
photo_item.bind(pos=update_photo_item, size=update_photo_item)
|
||||||
|
|
||||||
|
# Get full path to the image
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||||
|
img_path = os.path.join(pause_img_folder, img_file)
|
||||||
|
|
||||||
|
# Image thumbnail container
|
||||||
|
image_container = BoxLayout(size_hint_x=None, width=55, padding=[2, 2, 2, 2])
|
||||||
|
|
||||||
|
try:
|
||||||
|
photo_image = Image(
|
||||||
|
source=img_path,
|
||||||
|
size_hint=(1, 1),
|
||||||
|
allow_stretch=True,
|
||||||
|
keep_ratio=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to a placeholder if image can't be loaded
|
||||||
|
photo_image = Widget(size_hint=(1, 1))
|
||||||
|
with photo_image.canvas:
|
||||||
|
Color(0.3, 0.3, 0.3, 1)
|
||||||
|
Rectangle(pos=photo_image.pos, size=photo_image.size)
|
||||||
|
|
||||||
|
image_container.add_widget(photo_image)
|
||||||
|
|
||||||
|
# Photo info layout (filename and details)
|
||||||
|
info_layout = BoxLayout(orientation='vertical', spacing=2, size_hint_x=0.55, padding=[5, 2, 5, 2])
|
||||||
|
|
||||||
|
# Filename label (truncate if too long)
|
||||||
|
display_name = img_file if len(img_file) <= 20 else f"{img_file[:17]}..."
|
||||||
|
filename_label = Label(
|
||||||
|
text=display_name,
|
||||||
|
font_size=9,
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
halign="left",
|
||||||
|
valign="top",
|
||||||
|
size_hint_y=0.6,
|
||||||
|
bold=True
|
||||||
|
)
|
||||||
|
filename_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||||
|
|
||||||
|
# File size and type info
|
||||||
|
try:
|
||||||
|
file_size = os.path.getsize(img_path)
|
||||||
|
if file_size < 1024:
|
||||||
|
size_text = f"{file_size} B"
|
||||||
|
elif file_size < 1024*1024:
|
||||||
|
size_text = f"{file_size/1024:.1f} KB"
|
||||||
|
else:
|
||||||
|
size_text = f"{file_size/(1024*1024):.1f} MB"
|
||||||
|
|
||||||
|
# Get file extension
|
||||||
|
file_ext = os.path.splitext(img_file)[1].upper().replace('.', '')
|
||||||
|
info_text = f"{file_ext} • {size_text}"
|
||||||
|
except:
|
||||||
|
info_text = "Unknown format"
|
||||||
|
|
||||||
|
size_label = Label(
|
||||||
|
text=info_text,
|
||||||
|
font_size=7,
|
||||||
|
color=(0.8, 0.8, 0.8, 1),
|
||||||
|
halign="left",
|
||||||
|
valign="bottom",
|
||||||
|
size_hint_y=0.4
|
||||||
|
)
|
||||||
|
size_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||||
|
|
||||||
|
info_layout.add_widget(filename_label)
|
||||||
|
info_layout.add_widget(size_label)
|
||||||
|
|
||||||
|
# Button layout for actions
|
||||||
|
button_layout = BoxLayout(orientation='vertical', spacing=2, size_hint_x=0.28, padding=[2, 2, 2, 2])
|
||||||
|
|
||||||
|
# View button to show full image
|
||||||
|
view_btn = Button(
|
||||||
|
text="👁 View",
|
||||||
|
font_size=8,
|
||||||
|
background_color=(0.2, 0.6, 0.8, 1),
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
size_hint_y=0.5
|
||||||
|
)
|
||||||
|
view_btn.bind(on_press=lambda x: self.view_full_image(img_path, img_file))
|
||||||
|
|
||||||
|
# Delete button
|
||||||
|
delete_btn = Button(
|
||||||
|
text="🗑 Del",
|
||||||
|
font_size=8,
|
||||||
|
background_color=(0.8, 0.2, 0.2, 1),
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
size_hint_y=0.5
|
||||||
|
)
|
||||||
|
delete_btn.bind(on_press=lambda x: self.delete_single_photo(pause_idx, img_file, photo_item, parent_layout))
|
||||||
|
|
||||||
|
button_layout.add_widget(view_btn)
|
||||||
|
button_layout.add_widget(delete_btn)
|
||||||
|
|
||||||
|
# Add all components to photo item
|
||||||
|
photo_item.add_widget(image_container)
|
||||||
|
photo_item.add_widget(info_layout)
|
||||||
|
photo_item.add_widget(button_layout)
|
||||||
|
|
||||||
|
return photo_item
|
||||||
|
|
||||||
|
def delete_single_photo(self, pause_idx, img_file, photo_item, parent_layout):
|
||||||
|
"""Delete a single photo with confirmation"""
|
||||||
|
def confirm_delete():
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||||
|
file_path = os.path.join(pause_img_folder, img_file)
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
parent_layout.remove_widget(photo_item)
|
||||||
|
parent_layout.height = max(20, len(parent_layout.children) * 62)
|
||||||
|
|
||||||
|
self.show_confirmation(
|
||||||
|
f"Delete Photo",
|
||||||
|
f"Are you sure you want to delete '{img_file}'?",
|
||||||
|
confirm_delete
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_pause(self, pause_idx):
|
||||||
|
"""Delete an entire pause with confirmation"""
|
||||||
|
def confirm_delete_pause():
|
||||||
|
try:
|
||||||
|
# Remove pause from the list
|
||||||
|
if 0 <= pause_idx < len(self.pauses):
|
||||||
|
self.pauses.pop(pause_idx)
|
||||||
|
|
||||||
|
# Save the updated pauses list
|
||||||
|
self.save_pauses()
|
||||||
|
|
||||||
|
# Remove pause folder and its contents
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||||
|
|
||||||
|
if os.path.exists(pause_img_folder):
|
||||||
|
shutil.rmtree(pause_img_folder)
|
||||||
|
|
||||||
|
# Reorganize remaining pause folders
|
||||||
|
self.reorganize_pause_folders()
|
||||||
|
|
||||||
|
# Refresh the entire layout
|
||||||
|
self.show_message("Pause Deleted", f"Pause {pause_idx + 1} has been deleted successfully!")
|
||||||
|
Clock.schedule_once(lambda dt: self.build_pause_layout(), 0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.show_message("Error", f"Failed to delete pause: {str(e)}")
|
||||||
|
|
||||||
|
self.show_confirmation(
|
||||||
|
"Delete Pause",
|
||||||
|
f"Are you sure you want to delete Pause {pause_idx + 1}?\nThis will remove the pause location and all its photos permanently.",
|
||||||
|
confirm_delete_pause
|
||||||
|
)
|
||||||
|
|
||||||
|
def reorganize_pause_folders(self):
|
||||||
|
"""Reorganize pause folders after deletion to maintain sequential numbering"""
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
|
||||||
|
# Get all existing pause folders
|
||||||
|
existing_folders = []
|
||||||
|
for item in os.listdir(project_folder):
|
||||||
|
item_path = os.path.join(project_folder, item)
|
||||||
|
if os.path.isdir(item_path) and item.startswith("pause_"):
|
||||||
|
try:
|
||||||
|
folder_num = int(item.split("_")[1])
|
||||||
|
existing_folders.append((folder_num, item_path))
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by folder number
|
||||||
|
existing_folders.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
# Rename folders to be sequential starting from 1
|
||||||
|
temp_folders = []
|
||||||
|
for i, (old_num, old_path) in enumerate(existing_folders):
|
||||||
|
new_num = i + 1
|
||||||
|
if old_num != new_num:
|
||||||
|
# Create temporary name to avoid conflicts
|
||||||
|
temp_path = os.path.join(project_folder, f"temp_pause_{new_num}")
|
||||||
|
os.rename(old_path, temp_path)
|
||||||
|
temp_folders.append((temp_path, new_num))
|
||||||
|
else:
|
||||||
|
temp_folders.append((old_path, new_num))
|
||||||
|
|
||||||
|
# Final rename to correct names
|
||||||
|
for temp_path, new_num in temp_folders:
|
||||||
|
final_path = os.path.join(project_folder, f"pause_{new_num}")
|
||||||
|
if temp_path != final_path:
|
||||||
|
if os.path.exists(final_path):
|
||||||
|
shutil.rmtree(final_path)
|
||||||
|
os.rename(temp_path, final_path)
|
||||||
|
|
||||||
|
def save_pause_name(self, pause, name_input):
|
||||||
|
"""Save the custom name for a pause"""
|
||||||
|
pause['name'] = name_input.text
|
||||||
|
|
||||||
|
def save_individual_pause(self, pause_idx):
|
||||||
|
"""Save individual pause info"""
|
||||||
|
self.save_pauses()
|
||||||
|
# Show confirmation
|
||||||
|
self.show_message("Pause Saved", f"Pause {pause_idx + 1} information saved successfully!")
|
||||||
|
|
||||||
|
def add_photos(self, pause_idx):
|
||||||
|
"""Open file browser to add photos"""
|
||||||
|
project_folder = os.path.join(RESOURCES_FOLDER, "projects", self.project_name)
|
||||||
|
pause_img_folder = os.path.join(project_folder, f"pause_{pause_idx+1}")
|
||||||
|
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
with layout.canvas.before:
|
||||||
|
Color(0.13, 0.13, 0.16, 1)
|
||||||
|
layout.bg_rect = Rectangle(pos=layout.pos, size=layout.size)
|
||||||
|
layout.bind(
|
||||||
|
pos=lambda inst, val: setattr(layout.bg_rect, 'pos', inst.pos),
|
||||||
|
size=lambda inst, val: setattr(layout.bg_rect, 'size', inst.size)
|
||||||
|
)
|
||||||
|
|
||||||
|
title_label = Label(
|
||||||
|
text=f"Select photos for Pause {pause_idx + 1}:",
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=14,
|
||||||
|
size_hint_y=None,
|
||||||
|
height=30
|
||||||
|
)
|
||||||
|
|
||||||
|
filechooser = FileChooserIconView(
|
||||||
|
filters=['*.png', '*.jpg', '*.jpeg', '*.gif', '*.bmp'],
|
||||||
|
path=os.path.expanduser('~'),
|
||||||
|
multiselect=True
|
||||||
|
)
|
||||||
|
|
||||||
|
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=40)
|
||||||
|
|
||||||
|
add_btn = Button(
|
||||||
|
text="Add Selected",
|
||||||
|
background_color=(0.2, 0.7, 0.2, 1),
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=12
|
||||||
|
)
|
||||||
|
|
||||||
|
cancel_btn = Button(
|
||||||
|
text="Cancel",
|
||||||
|
background_color=(0.6, 0.3, 0.3, 1),
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=12
|
||||||
|
)
|
||||||
|
|
||||||
|
btn_layout.add_widget(cancel_btn)
|
||||||
|
btn_layout.add_widget(add_btn)
|
||||||
|
|
||||||
|
layout.add_widget(title_label)
|
||||||
|
layout.add_widget(filechooser)
|
||||||
|
layout.add_widget(btn_layout)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title="Add Photos",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.95, 0.9),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_selected_files(instance):
|
||||||
|
if filechooser.selection:
|
||||||
|
for file_path in filechooser.selection:
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
dest_path = os.path.join(pause_img_folder, filename)
|
||||||
|
if not os.path.exists(dest_path):
|
||||||
|
shutil.copy2(file_path, dest_path)
|
||||||
|
Clock.schedule_once(lambda dt: self.refresh_photos_display(), 0.1)
|
||||||
|
popup.dismiss()
|
||||||
|
else:
|
||||||
|
popup.dismiss()
|
||||||
|
|
||||||
|
add_btn.bind(on_press=add_selected_files)
|
||||||
|
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def refresh_photos_display(self):
|
||||||
|
"""Refresh the entire display to show updated photos"""
|
||||||
|
self.build_pause_layout()
|
||||||
|
|
||||||
|
def show_message(self, title, message):
|
||||||
|
"""Show a simple message popup"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
|
||||||
|
msg_label = Label(
|
||||||
|
text=message,
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=12,
|
||||||
|
halign="center"
|
||||||
|
)
|
||||||
|
msg_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||||
|
|
||||||
|
ok_btn = Button(
|
||||||
|
text="OK",
|
||||||
|
size_hint_y=None,
|
||||||
|
height=35,
|
||||||
|
background_color=(0.341, 0.235, 0.980, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
layout.add_widget(msg_label)
|
||||||
|
layout.add_widget(ok_btn)
|
||||||
|
|
||||||
|
popup = Popup(title=title, content=layout, size_hint=(0.8, 0.4))
|
||||||
|
ok_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def show_confirmation(self, title, message, confirm_callback):
|
||||||
|
"""Show a confirmation dialog"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
|
||||||
|
msg_label = Label(
|
||||||
|
text=message,
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=12,
|
||||||
|
halign="center"
|
||||||
|
)
|
||||||
|
msg_label.bind(width=lambda instance, value: setattr(instance, 'text_size', (value, None)))
|
||||||
|
|
||||||
|
btn_layout = BoxLayout(orientation='horizontal', spacing=10, size_hint_y=None, height=35)
|
||||||
|
|
||||||
|
cancel_btn = Button(
|
||||||
|
text="Cancel",
|
||||||
|
background_color=(0.6, 0.3, 0.3, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
confirm_btn = Button(
|
||||||
|
text="Confirm",
|
||||||
|
background_color=(0.8, 0.2, 0.2, 1),
|
||||||
|
color=(1, 1, 1, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
btn_layout.add_widget(cancel_btn)
|
||||||
|
btn_layout.add_widget(confirm_btn)
|
||||||
|
|
||||||
|
layout.add_widget(msg_label)
|
||||||
|
layout.add_widget(btn_layout)
|
||||||
|
|
||||||
|
popup = Popup(title=title, content=layout, size_hint=(0.8, 0.4), auto_dismiss=False)
|
||||||
|
|
||||||
|
cancel_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
confirm_btn.bind(on_press=lambda x: (confirm_callback(), popup.dismiss()))
|
||||||
|
|
||||||
|
popup.open()
|
||||||
|
|
||||||
|
def save_all_and_close(self, instance):
|
||||||
|
"""Save all pauses and return to previous screen"""
|
||||||
|
self.save_pauses()
|
||||||
|
if self.on_save_callback:
|
||||||
|
self.on_save_callback()
|
||||||
|
self.go_back()
|
||||||
|
|
||||||
|
def go_back(self, instance=None):
|
||||||
|
"""Return to the previous screen"""
|
||||||
|
self.manager.current = "create_animation"
|
||||||
|
|
||||||
|
def set_project_and_callback(self, project_name, callback=None):
|
||||||
|
"""Set the project name and callback for this screen"""
|
||||||
|
self.project_name = project_name
|
||||||
|
self.on_save_callback = callback
|
||||||
|
|
||||||
|
def view_full_image(self, img_path, img_file):
|
||||||
|
"""Show full image in a popup"""
|
||||||
|
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
|
||||||
|
with layout.canvas.before:
|
||||||
|
Color(0.05, 0.05, 0.08, 1)
|
||||||
|
layout.bg_rect = Rectangle(pos=layout.pos, size=layout.size)
|
||||||
|
layout.bind(
|
||||||
|
pos=lambda inst, val: setattr(layout.bg_rect, 'pos', inst.pos),
|
||||||
|
size=lambda inst, val: setattr(layout.bg_rect, 'size', inst.size)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image display
|
||||||
|
try:
|
||||||
|
full_image = Image(
|
||||||
|
source=img_path,
|
||||||
|
allow_stretch=True,
|
||||||
|
keep_ratio=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
full_image = Label(
|
||||||
|
text="Unable to load image",
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=16
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close button
|
||||||
|
close_btn = Button(
|
||||||
|
text="Close",
|
||||||
|
size_hint_y=None,
|
||||||
|
height=40,
|
||||||
|
background_color=(0.341, 0.235, 0.980, 1),
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
font_size=14
|
||||||
|
)
|
||||||
|
|
||||||
|
layout.add_widget(full_image)
|
||||||
|
layout.add_widget(close_btn)
|
||||||
|
|
||||||
|
popup = Popup(
|
||||||
|
title=f"Photo: {img_file}",
|
||||||
|
content=layout,
|
||||||
|
size_hint=(0.95, 0.95),
|
||||||
|
auto_dismiss=False
|
||||||
|
)
|
||||||
|
|
||||||
|
close_btn.bind(on_press=lambda x: popup.dismiss())
|
||||||
|
popup.open()
|
||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
from kivy.properties import StringProperty, ListProperty
|
from kivy.properties import StringProperty, ListProperty
|
||||||
from utils import (
|
from py_scripts.utils import (
|
||||||
generate_key, load_key, encrypt_data, decrypt_data,
|
generate_key, load_key, encrypt_data, decrypt_data,
|
||||||
check_server_settings, save_server_settings,
|
check_server_settings, save_server_settings,
|
||||||
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
test_connection, get_devices_from_server, save_route_to_file, fetch_positions_for_selected_day
|
||||||
|
|||||||
165
traccar.kv
165
traccar.kv
@@ -642,13 +642,46 @@
|
|||||||
valign: "middle"
|
valign: "middle"
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
on_press: root.optimize_route_entries()
|
on_press: root.optimize_route_entries()
|
||||||
|
|
||||||
|
# Pauses frame
|
||||||
|
BoxLayout:
|
||||||
|
id: pauses_frame
|
||||||
|
orientation: "horizontal"
|
||||||
|
spacing: 10
|
||||||
|
padding: 10
|
||||||
|
size_hint_y: None
|
||||||
|
height: 60
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: 0.15, 0.15, 0.18, 1
|
||||||
|
Rectangle:
|
||||||
|
pos: self.pos
|
||||||
|
size: self.size
|
||||||
|
Label:
|
||||||
|
id: pauses_label
|
||||||
|
text: "Pauses"
|
||||||
|
font_size: 16
|
||||||
|
halign: "left"
|
||||||
|
valign: "middle"
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
size_hint_x: 0.7
|
||||||
|
text_size: self.width, None
|
||||||
|
Button:
|
||||||
|
id: pauses_edit_btn
|
||||||
|
text: "Edit"
|
||||||
|
size_hint_x: 0.3
|
||||||
|
width: 120
|
||||||
|
font_size: 16
|
||||||
|
background_color: 0.341, 0.235, 0.980, 1
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
on_press: root.open_pauses_popup()
|
||||||
|
|
||||||
# Preview frame (label + button on first row, image on second row)
|
# Preview frame (label + button on first row, image on second row)
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: "vertical"
|
orientation: "vertical"
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: 300 # Adjust as needed for your image size
|
height: 255 # Adjust as needed for your image size
|
||||||
padding: [10, 10, 10, 10]
|
padding: [5, 5, 5, 5]
|
||||||
spacing: 10
|
spacing: 10
|
||||||
canvas.before:
|
canvas.before:
|
||||||
Color:
|
Color:
|
||||||
@@ -660,8 +693,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: "horizontal"
|
orientation: "horizontal"
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: 60
|
height: 30
|
||||||
spacing: 10
|
spacing: 5
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: "Preview your route"
|
text: "Preview your route"
|
||||||
@@ -683,12 +716,115 @@
|
|||||||
Image:
|
Image:
|
||||||
id: preview_image
|
id: preview_image
|
||||||
source: root.preview_image_source
|
source: root.preview_image_source
|
||||||
allow_stretch: True
|
allow_stretch: False
|
||||||
keep_ratio: False
|
keep_ratio: True
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: 220
|
height: 202
|
||||||
size_hint_x: 1
|
size_hint_x: 1
|
||||||
|
|
||||||
|
# Progressive 3D Animation frame
|
||||||
|
BoxLayout:
|
||||||
|
orientation: "horizontal"
|
||||||
|
size_hint_y: None
|
||||||
|
height: 60
|
||||||
|
padding: [10, 10, 10, 10]
|
||||||
|
spacing: 10
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: 0.15, 0.15, 0.18, 1
|
||||||
|
Rectangle:
|
||||||
|
pos: self.pos
|
||||||
|
size: self.size
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: "Generate progressive 3D animation\nBuilds trip point by point"
|
||||||
|
font_size: 16
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
size_hint_x: 0.7
|
||||||
|
halign: "left"
|
||||||
|
valign: "middle"
|
||||||
|
text_size: self.size
|
||||||
|
|
||||||
|
Button:
|
||||||
|
text: "Generate\n3D Trip"
|
||||||
|
size_hint_x: 0.3
|
||||||
|
font_size: 14
|
||||||
|
background_color: 0.2, 0.8, 0.4, 1
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
halign: "center"
|
||||||
|
valign: "middle"
|
||||||
|
text_size: self.size
|
||||||
|
on_press: root.generate_progressive_3d_animation()
|
||||||
|
|
||||||
|
# Google Earth Animation frame
|
||||||
|
BoxLayout:
|
||||||
|
orientation: "horizontal"
|
||||||
|
size_hint_y: None
|
||||||
|
height: 60
|
||||||
|
padding: [10, 10, 10, 10]
|
||||||
|
spacing: 10
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: 0.15, 0.15, 0.18, 1
|
||||||
|
Rectangle:
|
||||||
|
pos: self.pos
|
||||||
|
size: self.size
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: "Generate Google Earth flythrough\nCinematic aerial view"
|
||||||
|
font_size: 16
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
size_hint_x: 0.7
|
||||||
|
halign: "left"
|
||||||
|
valign: "middle"
|
||||||
|
text_size: self.size
|
||||||
|
|
||||||
|
Button:
|
||||||
|
text: "Generate\nFlythrough"
|
||||||
|
size_hint_x: 0.3
|
||||||
|
font_size: 14
|
||||||
|
background_color: 0.1, 0.8, 0.1, 1
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
halign: "center"
|
||||||
|
valign: "middle"
|
||||||
|
text_size: self.size
|
||||||
|
on_press: root.generate_google_earth_animation()
|
||||||
|
|
||||||
|
# Blender Animation frame
|
||||||
|
BoxLayout:
|
||||||
|
orientation: "horizontal"
|
||||||
|
size_hint_y: None
|
||||||
|
height: 60
|
||||||
|
padding: [10, 10, 10, 10]
|
||||||
|
spacing: 10
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: 0.15, 0.15, 0.18, 1
|
||||||
|
Rectangle:
|
||||||
|
pos: self.pos
|
||||||
|
size: self.size
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: "Generate cinema-quality animation\nProfessional 3D rendering"
|
||||||
|
font_size: 16
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
size_hint_x: 0.7
|
||||||
|
halign: "left"
|
||||||
|
valign: "middle"
|
||||||
|
text_size: self.size
|
||||||
|
|
||||||
|
Button:
|
||||||
|
text: "Generate\nCinema"
|
||||||
|
size_hint_x: 0.3
|
||||||
|
font_size: 14
|
||||||
|
background_color: 0.9, 0.6, 0.2, 1
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
halign: "center"
|
||||||
|
valign: "middle"
|
||||||
|
text_size: self.size
|
||||||
|
on_press: root.generate_blender_animation()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Widget:
|
Widget:
|
||||||
size_hint_y: 1
|
size_hint_y: 1
|
||||||
@@ -700,4 +836,17 @@
|
|||||||
background_color: 0.341, 0.235, 0.980, 1
|
background_color: 0.341, 0.235, 0.980, 1
|
||||||
color: 1, 1, 1, 1
|
color: 1, 1, 1, 1
|
||||||
font_size: 16
|
font_size: 16
|
||||||
on_press: app.root.current = "home"
|
on_press: app.root.current = "home"
|
||||||
|
|
||||||
|
<PauseEditScreen>:
|
||||||
|
BoxLayout:
|
||||||
|
orientation: "vertical"
|
||||||
|
spacing: 8
|
||||||
|
padding: 8
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: 0.11, 0.10, 0.15, 1
|
||||||
|
Rectangle:
|
||||||
|
pos: self.pos
|
||||||
|
size: self.size
|
||||||
|
|
||||||
|
|||||||
211
utils.py
211
utils.py
@@ -1,211 +0,0 @@
|
|||||||
import os
|
|
||||||
import json
|
|
||||||
import requests
|
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
|
|
||||||
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=800, height=600, delay=2, driver_path='/usr/bin/chromedriver'):
|
|
||||||
"""
|
|
||||||
Convert an HTML file to an image using Selenium and Pillow.
|
|
||||||
Args:
|
|
||||||
html_path (str): Path to the HTML file.
|
|
||||||
img_path (str): Path to save the output image (PNG).
|
|
||||||
width (int): Width of the browser window.
|
|
||||||
height (int): Height of the browser window.
|
|
||||||
delay (int): Seconds to wait for the page to render.
|
|
||||||
driver_path (str): Path to chromedriver binary.
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
chrome_options = Options()
|
|
||||||
chrome_options.add_argument("--headless")
|
|
||||||
chrome_options.add_argument(f"--window-size={width},{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.get("file://" + os.path.abspath(html_path))
|
|
||||||
time.sleep(delay) # Wait for the page to render
|
|
||||||
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))
|
|
||||||
img.save(img_path)
|
|
||||||
os.remove(tmp_img)
|
|
||||||
print(f"Image saved to: {img_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error converting HTML to image: {e}")
|
|
||||||
driver.quit()
|
|
||||||
Reference in New Issue
Block a user