updated to video creation

This commit is contained in:
2025-07-02 16:41:44 +03:00
parent 3ccbf72599
commit 291e5bab44
22 changed files with 206303 additions and 105 deletions

157
3D_VIDEO_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,157 @@
# 3D Video Animation Feature
## Overview
The 3D Video Animation feature generates Relive-style video animations from GPS route data. This creates engaging, cinematic videos that visualize your journey in a 3D perspective.
## Features
### Visual Elements
- **3D Isometric View**: Perspective projection that simulates 3D depth
- **Sky Gradient Background**: Blue gradient background that mimics sky
- **Animated Route Trail**: Color-coded path from blue (start) to red (end)
- **Pulsing Position Marker**: Animated current position indicator
- **Grid Overlay**: 3D grid effect for depth perception
- **Real-time Data Display**: Speed, timestamp, and progress information
### Technical Specifications
- **Resolution**: 1920x1080 (Full HD)
- **Frame Rate**: 30 FPS
- **Format**: MP4 video
- **Compression**: MP4V codec for broad compatibility
### Animation Effects
- **Shadow Effects**: Route lines and markers have 3D shadows
- **Elevation Simulation**: Simulated terrain elevation using sine waves
- **Smooth Transitions**: Interpolated movement between GPS points
- **Progress Indicators**: Visual progress through the route
## Required Libraries
### Core Dependencies
- **OpenCV (cv2)**: Video generation and frame composition
- **NumPy**: Mathematical operations and array handling
- **PIL/Pillow**: Image processing and text rendering
- **Requests**: API calls for elevation data (future enhancement)
### Optional Enhancements
- **MoviePy**: Advanced video editing and effects
- **Matplotlib**: Additional visualization options
- **SciPy**: Mathematical transformations
## Usage
1. **Navigate** to the Create Animation screen
2. **Select** a project with GPS route data
3. **Click** "Generate 3D Video" button
4. **Wait** for processing (can take several minutes)
5. **View** the generated video in the project folder
## Processing Steps
### 1. Data Loading (10%)
- Loads GPS positions from `positions.json`
- Validates minimum route length (10+ points)
- Calculates route boundaries and center point
### 2. Route Analysis (20%)
- Determines optimal viewport and scaling
- Calculates center coordinates for camera position
- Sets up coordinate transformation matrices
### 3. Frame Generation (30-70%)
- Creates individual frames for each GPS point
- Applies 3D perspective transformation
- Renders route trail with color progression
- Adds animated markers and text overlays
### 4. Video Compilation (75-90%)
- Combines frames into MP4 video
- Applies compression and optimization
- Adds metadata and timing information
### 5. Finalization (90-100%)
- Saves video to project folder
- Cleans up temporary files
- Shows completion notification
## File Output
### Naming Convention
```
{project_name}_3d_animation_{timestamp}.mp4
```
### Example
```
MyTrip_3d_animation_20250702_143522.mp4
```
### Location
Videos are saved in the project folder:
```
resources/projects/{project_name}/
```
## Customization Options
### Future Enhancements
- **Real Elevation Data**: Integration with elevation APIs
- **Custom Colors**: User-selectable color schemes
- **Speed Control**: Variable playback speeds
- **Camera Angles**: Multiple perspective options
- **Terrain Textures**: Realistic ground textures
- **Weather Effects**: Animated weather overlays
### Performance Optimization
- **Multi-threading**: Parallel frame generation
- **GPU Acceleration**: OpenGL rendering support
- **Compression Options**: Quality vs. file size settings
- **Preview Mode**: Lower quality for faster processing
## Error Handling
### Common Issues
- **Insufficient GPS Data**: Minimum 10 points required
- **Memory Limitations**: Large routes may require optimization
- **Storage Space**: Videos can be 50-200MB depending on route length
- **Processing Time**: Can take 5-15 minutes for long routes
### Troubleshooting
- **Reduce Route Size**: Use route optimization before generation
- **Free Disk Space**: Ensure adequate storage available
- **Close Other Apps**: Free memory for processing
- **Check File Permissions**: Ensure write access to project folder
## Technical Architecture
### Frame Generation Pipeline
```
GPS Point → Coordinate Transform → 3D Projection →
Visual Effects → Text Overlay → Frame Export
```
### Video Assembly Pipeline
```
Frame Sequence → Video Encoder → Compression →
Metadata Addition → File Output
```
### Memory Management
- **Temporary Files**: Frames stored in temp directory
- **Batch Processing**: Processes frames in chunks
- **Automatic Cleanup**: Removes temporary files after completion
## Integration
### UI Integration
- **Progress Bar**: Real-time processing updates
- **Status Messages**: Step-by-step progress information
- **Error Dialogs**: User-friendly error messages
- **Result Notification**: Success/failure feedback
### File System Integration
- **Project Structure**: Maintains existing folder organization
- **Automatic Naming**: Prevents file name conflicts
- **Folder Opening**: Direct access to output location
This feature transforms GPS tracking data into professional-quality video animations suitable for sharing, presentations, or personal memories.

105
PAUSE_EDIT_IMPROVEMENTS.md Normal file
View 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

Binary file not shown.

Binary file not shown.

View File

@@ -32,7 +32,7 @@ from screens.get_trip_from_server import GetTripFromServer
from screens.create_animation_screen import CreateAnimationScreen
from screens.settings_screen import SettingsScreen
from screens.settings_screen import RegisterScreen
from screens.pause_edit_screen import PauseEditScreen
from screens.pause_edit_screen_improved import PauseEditScreen
from config import RESOURCES_FOLDER
kivy.require("2.0.0")
from kivy.core.window import Window

View File

@@ -4,4 +4,10 @@ kiwy-garden
folium
selenium
pillow
geopy
geopy
opencv-python
moviepy
requests
numpy
matplotlib
scipy

View File

@@ -1 +1 @@
gAAAAABoZPqiU5moDgLAGyehIgyLPV8d2Bdf2Fg8PZHf-d0O3MX1BopCMa_-9iEMeuiLoO9i2UFiafydQxVrGtH7db57u8CrjFKA383mrHn8O6yqvxa3GcIQmoIlCO1d0I5cWwNWQzEd
gAAAAABoZS4Ed-JJHBU90HAwhBvMJ7HfCSU-AeSaFDEt-Hg1wJW57NBoZJw_9lXEw-uooZCbQnMEUAd9mmF_I_FR1oRC-u_6cqU01jHGQq8OzJubmvBiZ1zYVlCO-kpmmFB8jv0LaBLU

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

View File

@@ -0,0 +1,232 @@
[
{
"start_time": "2025-06-15T06:00:23.000+00:00",
"end_time": "2025-06-15T06:15:19.000+00:00",
"duration_seconds": 897,
"location": {
"latitude": 45.78012944444444,
"longitude": 24.16865166666667
},
"location_suggestion": "\u0218oseaua Sibiului"
},
{
"start_time": "2025-06-15T07:27:39.000+00:00",
"end_time": "2025-06-15T07:56:41.000+00:00",
"duration_seconds": 1744,
"location": {
"latitude": 45.35275055555555,
"longitude": 24.042727222222222
},
"location_suggestion": "Strada Podul \u0218ipotului 1"
},
{
"start_time": "2025-06-15T08:36:25.000+00:00",
"end_time": "2025-06-15T08:48:10.000+00:00",
"duration_seconds": 702,
"location": {
"latitude": 45.390987777777774,
"longitude": 23.980355
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T09:02:08.000+00:00",
"end_time": "2025-06-15T09:09:53.765+00:00",
"duration_seconds": 466,
"location": {
"latitude": 45.409075,
"longitude": 23.938845
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T10:29:06.000+00:00",
"end_time": "2025-06-15T11:04:51.000+00:00",
"duration_seconds": 2180,
"location": {
"latitude": 45.40981166666667,
"longitude": 23.793883888888892
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T11:21:38.000+00:00",
"end_time": "2025-06-15T11:24:05.928+00:00",
"duration_seconds": 147,
"location": {
"latitude": 45.38780444444444,
"longitude": 23.751438888888888
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T11:25:30.000+00:00",
"end_time": "2025-06-15T11:28:36.000+00:00",
"duration_seconds": 186,
"location": {
"latitude": 45.390328333333336,
"longitude": 23.74829666666667
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T11:42:42.000+00:00",
"end_time": "2025-06-15T11:51:12.000+00:00",
"duration_seconds": 501,
"location": {
"latitude": 45.39019166666667,
"longitude": 23.70160277777778
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T11:52:48.000+00:00",
"end_time": "2025-06-15T12:01:47.000+00:00",
"duration_seconds": 540,
"location": {
"latitude": 45.39456,
"longitude": 23.69228777777778
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T12:12:38.000+00:00",
"end_time": "2025-06-15T12:19:57.845+00:00",
"duration_seconds": 440,
"location": {
"latitude": 45.38043944444445,
"longitude": 23.651888333333336
},
"location_suggestion": "Strategica"
},
{
"start_time": "2025-06-15T12:58:38.000+00:00",
"end_time": "2025-06-15T13:53:36.000+00:00",
"duration_seconds": 3297,
"location": {
"latitude": 45.57911,
"longitude": 23.62189888888889
},
"location_suggestion": "Transalpina"
},
{
"start_time": "2025-06-15T14:28:22.000+00:00",
"end_time": "2025-06-15T14:38:39.000+00:00",
"duration_seconds": 618,
"location": {
"latitude": 45.78371777777778,
"longitude": 23.613761666666665
},
"location_suggestion": "Strada Valea Frumoasei"
},
{
"start_time": "2025-06-19T05:25:20.000+00:00",
"end_time": "2025-06-19T14:09:05.000+00:00",
"duration_seconds": 31426,
"location": {
"latitude": 45.79909222222222,
"longitude": 24.085676666666668
},
"location_suggestion": "Strada Monaco"
},
{
"start_time": "2025-06-20T05:18:03.000+00:00",
"end_time": "2025-06-20T14:05:26.784+00:00",
"duration_seconds": 31644,
"location": {
"latitude": 45.798976111111116,
"longitude": 24.085469444444445
},
"location_suggestion": "Strada Monaco"
},
{
"start_time": "2025-06-21T07:45:07.000+00:00",
"end_time": "2025-06-21T08:04:04.000+00:00",
"duration_seconds": 1049,
"location": {
"latitude": 45.82062444444445,
"longitude": 24.151072222222222
},
"location_suggestion": "Calea \u0218urii Mari 36"
},
{
"start_time": "2025-06-23T05:16:09.000+00:00",
"end_time": "2025-06-23T05:18:52.000+00:00",
"duration_seconds": 163,
"location": {
"latitude": 45.79242277777778,
"longitude": 24.139758888888892
},
"location_suggestion": "\u0218oseaua Alba Iulia"
},
{
"start_time": "2025-06-23T05:26:59.000+00:00",
"end_time": "2025-06-23T13:42:32.000+00:00",
"duration_seconds": 29558,
"location": {
"latitude": 45.79897166666667,
"longitude": 24.085509444444444
},
"location_suggestion": "Strada Monaco"
},
{
"start_time": "2025-06-24T05:02:36.000+00:00",
"end_time": "2025-06-24T05:06:24.161+00:00",
"duration_seconds": 228,
"location": {
"latitude": 45.79917111111111,
"longitude": 24.086246666666668
},
"location_suggestion": "Strada Europa Unit\u0103"
},
{
"start_time": "2025-06-24T05:06:26.000+00:00",
"end_time": "2025-06-24T13:44:34.000+00:00",
"duration_seconds": 31088,
"location": {
"latitude": 45.79897666666667,
"longitude": 24.08552166666667
},
"location_suggestion": "Strada Monaco"
},
{
"start_time": "2025-06-25T05:23:38.000+00:00",
"end_time": "2025-06-25T13:52:00.000+00:00",
"duration_seconds": 30500,
"location": {
"latitude": 45.799183888888884,
"longitude": 24.085637777777777
},
"location_suggestion": "Strada Monaco"
},
{
"start_time": "2025-06-26T05:10:24.000+00:00",
"end_time": "2025-06-26T14:09:48.774+00:00",
"duration_seconds": 32365,
"location": {
"latitude": 45.799045,
"longitude": 24.085640555555557
},
"location_suggestion": "Strada Monaco"
},
{
"start_time": "2025-06-27T05:13:04.000+00:00",
"end_time": "2025-06-27T14:08:01.630+00:00",
"duration_seconds": 32098,
"location": {
"latitude": 45.79897444444445,
"longitude": 24.085511666666665
},
"location_suggestion": "Strada Monaco"
},
{
"start_time": "2025-06-30T04:39:06.000+00:00",
"end_time": "2025-06-30T13:13:43.000+00:00",
"duration_seconds": 30878,
"location": {
"latitude": 45.798997222222226,
"longitude": 24.085517777777778
},
"location_suggestion": "Strada Monaco"
}
]

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

View File

@@ -1,101 +0,0 @@
[
{
"start_time": "2025-06-19T05:25:20.000+00:00",
"end_time": "2025-06-19T14:09:05.000+00:00",
"duration_seconds": 31426,
"location": {
"latitude": 45.79909222222222,
"longitude": 24.085676666666668
}
},
{
"start_time": "2025-06-20T05:18:03.000+00:00",
"end_time": "2025-06-20T14:05:26.784+00:00",
"duration_seconds": 31644,
"location": {
"latitude": 45.798976111111116,
"longitude": 24.085469444444445
}
},
{
"start_time": "2025-06-21T07:45:07.000+00:00",
"end_time": "2025-06-21T08:04:04.000+00:00",
"duration_seconds": 1049,
"location": {
"latitude": 45.82062444444445,
"longitude": 24.151072222222222
}
},
{
"start_time": "2025-06-23T05:16:09.000+00:00",
"end_time": "2025-06-23T05:18:52.000+00:00",
"duration_seconds": 163,
"location": {
"latitude": 45.79242277777778,
"longitude": 24.139758888888892
}
},
{
"start_time": "2025-06-23T05:26:59.000+00:00",
"end_time": "2025-06-23T13:42:32.000+00:00",
"duration_seconds": 29558,
"location": {
"latitude": 45.79897166666667,
"longitude": 24.085509444444444
}
},
{
"start_time": "2025-06-24T05:02:36.000+00:00",
"end_time": "2025-06-24T05:06:24.161+00:00",
"duration_seconds": 228,
"location": {
"latitude": 45.79917111111111,
"longitude": 24.086246666666668
}
},
{
"start_time": "2025-06-24T05:06:26.000+00:00",
"end_time": "2025-06-24T13:44:34.000+00:00",
"duration_seconds": 31088,
"location": {
"latitude": 45.79897666666667,
"longitude": 24.08552166666667
}
},
{
"start_time": "2025-06-25T05:23:38.000+00:00",
"end_time": "2025-06-25T13:52:00.000+00:00",
"duration_seconds": 30500,
"location": {
"latitude": 45.799183888888884,
"longitude": 24.085637777777777
}
},
{
"start_time": "2025-06-26T05:10:24.000+00:00",
"end_time": "2025-06-26T14:09:48.774+00:00",
"duration_seconds": 32365,
"location": {
"latitude": 45.799045,
"longitude": 24.085640555555557
}
},
{
"start_time": "2025-06-27T05:13:04.000+00:00",
"end_time": "2025-06-27T14:08:01.630+00:00",
"duration_seconds": 32098,
"location": {
"latitude": 45.79897444444445,
"longitude": 24.085511666666665
}
},
{
"start_time": "2025-06-30T04:39:06.000+00:00",
"end_time": "2025-06-30T13:13:43.000+00:00",
"duration_seconds": 30878,
"location": {
"latitude": 45.798997222222226,
"longitude": 24.085517777777778
}
}
]

View File

@@ -8,6 +8,7 @@ from kivy.properties import StringProperty, NumericProperty, AliasProperty
from utils import (
process_preview_util, optimize_route_entries_util
)
from video_3d_generator import generate_3d_video_animation
from kivy.uix.popup import Popup
from kivy.uix.button import Button
from kivy.uix.label import Label
@@ -170,3 +171,33 @@ class CreateAnimationScreen(Screen):
pause_edit_screen.set_project_and_callback(self.project_name, self.on_pre_enter)
self.manager.current = "pause_edit"
def generate_3d_video(self):
"""Generate a 3D video animation similar to Relive"""
# Show processing popup
layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
label = Label(text="Preparing 3D video generation...")
progress = ProgressBar(max=100, value=0)
layout.add_widget(label)
layout.add_widget(progress)
popup = Popup(
title="Generating 3D Video Animation",
content=layout,
size_hint=(0.9, None),
size=(0, 200),
auto_dismiss=False
)
popup.open()
# Schedule the 3D video generation
Clock.schedule_once(
lambda dt: generate_3d_video_animation(
self.project_name,
RESOURCES_FOLDER,
label,
progress,
popup,
Clock
),
0.5
)

View File

@@ -7,14 +7,18 @@ 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
@@ -25,10 +29,91 @@ class PauseEditScreen(Screen):
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.build_pause_layout()
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 and improved location suggestion with focus on practical results"""

View 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()

File diff suppressed because it is too large Load Diff

View File

@@ -722,6 +722,40 @@
height: 202
size_hint_x: 1
# 3D Video 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 3D video animation\nsimilar to Relive style"
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 Video"
size_hint_x: 0.3
font_size: 14
background_color: 0.8, 0.2, 0.4, 1
color: 1, 1, 1, 1
halign: "center"
valign: "middle"
text_size: self.size
on_press: root.generate_3d_video()
Widget:
size_hint_y: 1

296
video_3d_generator.py Normal file
View File

@@ -0,0 +1,296 @@
"""
3D Video Animation Generator
Creates Relive-style 3D video animations from GPS route data
"""
import json
import os
import math
import requests
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import tempfile
import shutil
from datetime import datetime
def generate_3d_video_animation(project_name, resources_folder, label_widget, progress_widget, popup_widget, clock_module):
"""
Generate a 3D video animation similar to Relive
Args:
project_name: Name of the project
resources_folder: Path to resources folder
label_widget: Kivy label for status updates
progress_widget: Kivy progress bar
popup_widget: Kivy popup to dismiss when done
clock_module: Kivy Clock module for scheduling
"""
def update_progress(progress_val, status_text):
"""Update UI from background thread"""
def _update(dt):
progress_widget.value = progress_val
label_widget.text = status_text
clock_module.schedule_once(_update, 0)
def finish_generation(success, message, output_path=None):
"""Finish the generation process"""
def _finish(dt):
if popup_widget:
popup_widget.dismiss()
# Show result popup
from kivy.uix.popup import Popup
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
result_layout = BoxLayout(orientation='vertical', spacing=10, padding=10)
if success:
result_label = Label(
text=f"3D Video Generated Successfully!\n\nSaved to:\n{output_path}",
color=(0, 1, 0, 1),
halign="center"
)
open_btn = Button(
text="Open Video Folder",
size_hint_y=None,
height=40,
background_color=(0.2, 0.7, 0.2, 1)
)
open_btn.bind(on_press=lambda x: (os.system(f"xdg-open '{os.path.dirname(output_path)}'"), result_popup.dismiss()))
result_layout.add_widget(result_label)
result_layout.add_widget(open_btn)
else:
result_label = Label(
text=f"Generation Failed:\n{message}",
color=(1, 0, 0, 1),
halign="center"
)
result_layout.add_widget(result_label)
close_btn = Button(
text="Close",
size_hint_y=None,
height=40,
background_color=(0.3, 0.3, 0.3, 1)
)
result_layout.add_widget(close_btn)
result_popup = Popup(
title="3D Video Generation Result",
content=result_layout,
size_hint=(0.9, 0.6),
auto_dismiss=False
)
close_btn.bind(on_press=lambda x: result_popup.dismiss())
result_popup.open()
clock_module.schedule_once(_finish, 0)
def run_generation():
"""Main generation function"""
try:
# Step 1: Load route data
update_progress(10, "Loading route data...")
project_folder = os.path.join(resources_folder, "projects", project_name)
positions_path = os.path.join(project_folder, "positions.json")
if not os.path.exists(positions_path):
finish_generation(False, "No route data found!")
return
with open(positions_path, "r") as f:
positions = json.load(f)
if len(positions) < 10:
finish_generation(False, "Route too short for 3D animation (minimum 10 points)")
return
# Step 2: Calculate route bounds and center
update_progress(20, "Calculating route boundaries...")
lats = [pos['latitude'] for pos in positions]
lons = [pos['longitude'] for pos in positions]
center_lat = sum(lats) / len(lats)
center_lon = sum(lons) / len(lons)
min_lat, max_lat = min(lats), max(lats)
min_lon, max_lon = min(lons), max(lons)
# Step 3: Generate frames
update_progress(30, "Generating 3D frames...")
# Create temporary directory for frames
temp_dir = tempfile.mkdtemp()
frames_dir = os.path.join(temp_dir, "frames")
os.makedirs(frames_dir)
# Video settings
width, height = 1920, 1080
fps = 30
total_frames = len(positions) * 2 # 2 frames per position for smooth animation
# Generate frames
for i, pos in enumerate(positions):
progress = 30 + (i / len(positions)) * 40
update_progress(progress, f"Generating frame {i+1}/{len(positions)}...")
frame = create_3d_frame(
pos, positions, i, center_lat, center_lon,
min_lat, max_lat, min_lon, max_lon,
width, height
)
# Save frame
frame_path = os.path.join(frames_dir, f"frame_{i:06d}.png")
cv2.imwrite(frame_path, frame)
# Step 4: Create video
update_progress(75, "Compiling video...")
# Output path
output_filename = f"{project_name}_3d_animation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4"
output_path = os.path.join(project_folder, output_filename)
# Create video writer
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
video_writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
# Add frames to video
frame_files = sorted([f for f in os.listdir(frames_dir) if f.endswith('.png')])
for frame_file in frame_files:
frame_path = os.path.join(frames_dir, frame_file)
frame = cv2.imread(frame_path)
video_writer.write(frame)
video_writer.release()
# Step 5: Add audio (optional)
update_progress(90, "Adding finishing touches...")
# Clean up
shutil.rmtree(temp_dir)
update_progress(100, "3D Video generated successfully!")
finish_generation(True, "Success!", output_path)
except Exception as e:
finish_generation(False, str(e))
# Start generation in background
import threading
thread = threading.Thread(target=run_generation)
thread.daemon = True
thread.start()
def create_3d_frame(current_pos, all_positions, frame_index, center_lat, center_lon,
min_lat, max_lat, min_lon, max_lon, width, height):
"""
Create a single 3D-style frame
"""
# Create canvas
frame = np.zeros((height, width, 3), dtype=np.uint8)
# Background gradient (sky effect)
for y in range(height):
color_intensity = int(255 * (1 - y / height))
sky_color = (min(255, color_intensity + 50), min(255, color_intensity + 100), 255)
frame[y, :] = sky_color
# Calculate perspective transformation
# Simple isometric-style projection
scale_x = width * 0.6 / (max_lon - min_lon) if max_lon != min_lon else 1000
scale_y = height * 0.6 / (max_lat - min_lat) if max_lat != min_lat else 1000
# Draw route path with 3D effect
route_points = []
for i, pos in enumerate(all_positions[:frame_index + 1]):
# Convert GPS to screen coordinates
x = int((pos['longitude'] - min_lon) * scale_x + width * 0.2)
y = int(height * 0.8 - (pos['latitude'] - min_lat) * scale_y)
# Add 3D effect (elevation simulation)
elevation_offset = int(20 * math.sin(i * 0.1)) # Simulated elevation
y -= elevation_offset
route_points.append((x, y))
# Draw route trail with gradient
if len(route_points) > 1:
for i in range(1, len(route_points)):
# Color gradient from blue to red
progress = i / len(route_points)
color_r = int(255 * progress)
color_b = int(255 * (1 - progress))
color = (color_b, 100, color_r)
# Draw thick line with 3D shadow effect
pt1, pt2 = route_points[i-1], route_points[i]
# Shadow
cv2.line(frame, (pt1[0]+2, pt1[1]+2), (pt2[0]+2, pt2[1]+2), (50, 50, 50), 8)
# Main line
cv2.line(frame, pt1, pt2, color, 6)
# Draw current position marker
if route_points:
current_point = route_points[-1]
# Pulsing effect
pulse_size = int(15 + 10 * math.sin(frame_index * 0.3))
# Shadow
cv2.circle(frame, (current_point[0]+3, current_point[1]+3), pulse_size, (0, 0, 0), -1)
# Main marker
cv2.circle(frame, current_point, pulse_size, (0, 255, 255), -1)
cv2.circle(frame, current_point, pulse_size-3, (255, 255, 255), 2)
# Add grid effect for 3D feel
grid_spacing = 50
for x in range(0, width, grid_spacing):
cv2.line(frame, (x, 0), (x, height), (100, 100, 100), 1)
for y in range(0, height, grid_spacing):
cv2.line(frame, (0, y), (width, y), (100, 100, 100), 1)
# Add text overlay
try:
# Position info
speed = current_pos.get('speed', 0) if current_pos else 0
timestamp = current_pos.get('deviceTime', '') if current_pos else ''
text_y = 50
cv2.putText(frame, f"Speed: {speed:.1f} km/h", (50, text_y),
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
text_y += 40
if timestamp:
cv2.putText(frame, f"Time: {timestamp[:16]}", (50, text_y),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
text_y += 40
cv2.putText(frame, f"Point: {frame_index + 1}/{len(all_positions)}", (50, text_y),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
except Exception:
pass # Skip text if font issues
return frame
def get_elevation_data(lat, lon):
"""
Get elevation data for a coordinate (optional enhancement)
"""
try:
# Using a free elevation API
url = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}"
response = requests.get(url, timeout=5)
if response.status_code == 200:
data = response.json()
return data['results'][0]['elevation']
except Exception:
pass
return 0 # Default elevation