daniel-saed commited on
Commit
b60c4ae
·
verified ·
1 Parent(s): 8210811

Upload 26 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/demo_video.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ img/example.png filter=lfs diff=lfs merge=lfs -text
38
+ img/verstappen_china_2025.jpg filter=lfs diff=lfs merge=lfs -text
39
+ img/web1.jpg filter=lfs diff=lfs merge=lfs -text
40
+ img/web2.jpg filter=lfs diff=lfs merge=lfs -text
.streamlit/config.toml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ base="dark"
3
+ primaryColor="#ffffff"
4
+ backgroundColor="#0e0e10"
5
+ secondaryBackgroundColor="#1d1d21"
6
+ textColor="#ffffff"
7
+ font="sans serif"
8
+
9
+ [server]
10
+ maxUploadSize=200
11
+ maxMessageSize=100
12
+ enableCORS=false
13
+
14
+ [browser]
15
+ gatherUsageStats=false
16
+
17
+ [runner]
18
+ fastRerenderEnabled=false
19
+ magicEnabled=true
20
+
21
+ [logger]
22
+ level="warning"
23
+
24
+ [client]
25
+ toolbarMode="minimal"
26
+ showErrorDetails=false
27
+ # Ocultar la barra de progreso (línea naranja)
28
+
29
+ displayEnabled=false
30
+
31
+ [global]
32
+ dataFrameSerialization="arrow"
33
+
34
+ # Desactivar elementos de la interfaz de usuario
35
+ [ui]
36
+ hideTopBar=true # Oculta la barra superior completa
37
+
.streamlit/secrets.toml ADDED
File without changes
assets/demo_video.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:12f9865d3ac1b2f0a65e6bf175279b8b1c614aa8c4959acfd4565b1dd5c50317
3
+ size 6081173
assets/style.css ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap');
2
+
3
+ /* Global styles
4
+ .stApp {
5
+ background: linear-gradient(135deg, #272730 0%, #000000 100%);
6
+ }*/
7
+
8
+
9
+
10
+ /* Button styles
11
+ .stButton button {
12
+ background: linear-gradient(90deg, #FF1E1E, #FF1E1E);
13
+ color: white;
14
+ border: none;
15
+ border-radius: 8px;
16
+ padding: 12px 24px;
17
+ font-family: 'Orbitron', sans-serif;
18
+ font-weight: 600;
19
+ letter-spacing: 1px;
20
+ text-transform: uppercase;
21
+ transition: all 0.3s ease;
22
+ align-items: center;
23
+ }
24
+
25
+ .stButton button:hover {
26
+ transform: translateY(-2px);
27
+ box-shadow: 0 5px 15px rgba(255, 30, 30, 0.4);
28
+ }*/
29
+
30
+ /* Tabs styling */
31
+ .stTabs {
32
+ /*background: rgba(51, 49, 49, 0.4);*/
33
+ border-radius: 15px;
34
+ padding: 10px;
35
+ margin-bottom: 20px;
36
+ }
37
+
38
+ /* Center the tab container */
39
+ .stTabs [data-testid="stTabsHeader"] {
40
+ display: flex;
41
+ justify-content: center;
42
+ align-items: center;
43
+ }
44
+
45
+ /* Style for individual tabs */
46
+ .stTab {
47
+ background: transparent !important;
48
+ color: #FFFFFF !important;
49
+ font-family: 'Orbitron', sans-serif;
50
+ margin: 0 5px; /* Add some space between tabs */
51
+ min-width: 120px; /* Set a minimum width for all tabs */
52
+ text-align: center;
53
+ padding: 8px 16px !important; /* Add consistent padding */
54
+ }
55
+
56
+ .stTab[aria-selected="true"] {
57
+ background: linear-gradient(90deg, #FF1E1E, #FF8E53) !important;
58
+ color: white !important;
59
+ border-radius: 8px;
60
+ }
61
+
62
+ /* Make sure the tabs don't stretch too wide */
63
+ .stTabs [role="tablist"] {
64
+ max-width: fit-content;
65
+ margin: 0 auto;
66
+ }
67
+
68
+ /* Ensure tab buttons have consistent width */
69
+ .stTabs button[role="tab"] {
70
+ min-width: 200px;
71
+ display: inline-flex;
72
+ justify-content: center;
73
+ }
74
+
75
+ /* Input fields */
76
+ .stNumberInput div {
77
+ background: rgba(36, 59, 85, 0.4);
78
+ border-radius: 8px;
79
+ padding: 5px;
80
+ }
81
+
82
+ .stTextInput div {
83
+ background: rgba(36, 59, 85, 0.4);
84
+ border-radius: 8px;
85
+ padding: 5px;
86
+ }
87
+
88
+ /* File uploader */
89
+ .stUploader div {
90
+ background: rgba(36, 59, 85, 0.3);
91
+ border-radius: 8px;
92
+ padding: 10px;
93
+ }
94
+
95
+ /* DataFrame styling */
96
+ .stDataFrame {
97
+ font-family: 'JetBrains Mono', monospace;
98
+ background: rgba(20, 30, 48, 0.4);
99
+ border-radius: 8px;
100
+ padding: 10px;
101
+ }
102
+
103
+ /* Estilo para imágenes redondeadas con sombras */
104
+ img {
105
+ border-radius: 15px !important;
106
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
107
+ transition: transform 0.3s ease, box-shadow 0.3s ease !important;
108
+ }
109
+
110
+ img:hover {
111
+ transform: translateY(-3px) !important;
112
+ box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2) !important;
113
+ }
114
+
115
+ /* Estilo para videos redondeados con sombras */
116
+ .stVideo {
117
+ border-radius: 15px !important;
118
+ overflow: hidden !important;
119
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2) !important;
120
+ }
121
+
122
+ .stVideo > video {
123
+ border-radius: 15px !important;
124
+ }
125
+
126
+
127
+
128
+
129
+ @keyframes border-animation {
130
+ 0% {
131
+ transform: rotate(0deg);
132
+ }
133
+ 100% {
134
+ transform: rotate(360deg);
135
+ }
136
+ }
img/example.png ADDED

Git LFS Details

  • SHA256: fc906e7b98382aaa3a8267f740e12489b6bb5bbab0821ae756916572ebe4843c
  • Pointer size: 131 Bytes
  • Size of remote file: 239 kB
img/verstappen_china_2025.jpg ADDED

Git LFS Details

  • SHA256: 76abe71dee14a5376d3db1c36eba4f73b0bd20047d839040022a811d446b81d9
  • Pointer size: 131 Bytes
  • Size of remote file: 361 kB
img/verstappen_china_2025_clahe.jpg ADDED
img/verstappen_china_2025_cropped.jpg ADDED
img/verstappen_china_2025_nohelmet.jpg ADDED
img/verstappen_china_2025_tresh.jpg ADDED
img/web1.jpg ADDED

Git LFS Details

  • SHA256: 5b2f5acf08807e1681f3c7d6b3606bc31eaf5379376d37b7742551c2239e409c
  • Pointer size: 131 Bytes
  • Size of remote file: 246 kB
img/web2.jpg ADDED

Git LFS Details

  • SHA256: bd6782f2b6d8326a8c36076e7f84b9bc24e70013e7f4270ce96c99b2802f5808
  • Pointer size: 131 Bytes
  • Size of remote file: 121 kB
models/best-224.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4b098cf5d8646efd385df8186978289b1b5c617a181a42b490ea9933872a9945
3
+ size 13123617
models/f1-steering-angle-model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:afd3b47818d91146e09d1dfc495eaaa49122400c26b5e0c4b9cf4dd88259f88d
3
+ size 17341463
models/f1-steering-angle-model_100.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5d20f7509cd1a7dd1b74a77ce418a63f65531b6de2852ec8b58b07c315ca9bba
3
+ size 17341463
navigation/soon.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import streamlit as st
3
+ import pandas as pd
4
+ from pymongo import MongoClient
5
+ from utils.ui_components import (
6
+ display_results,
7
+ create_line_chart
8
+ )
9
+ from utils.helper import client
10
+
11
+
12
+ col1, col2,col3 = st.columns([1,3,1])
13
+
14
+ with col2:
15
+ st.title("Data Base")
16
+
17
+ st.markdown("- All data is carefully matched to the start/finish lap")
18
+
19
+ if client is None:
20
+ st.warning("MongoDB client not connected. Please check your connection settings.")
21
+ else:
22
+ try:
23
+ collection = client["f1_data"]["steering_files"]
24
+
25
+ year = st.selectbox("Year", sorted(collection.distinct("year")),index=None,placeholder="Select year...",)
26
+
27
+ if year:
28
+
29
+
30
+ race = st.selectbox("Race", sorted(collection.distinct("race", {"year": year})),index=None,placeholder="Select race...")
31
+
32
+ if race:
33
+
34
+ session = st.selectbox("Session", sorted(collection.distinct("session", {"year": year, "race": race})),index=None,placeholder="Select session...")
35
+
36
+ if session:
37
+ driver = st.selectbox("Lap", sorted(collection.distinct("driver", {"year": year, "race": race, "session": session})),index=None,placeholder="Select lap")
38
+ if driver:
39
+
40
+ query = {
41
+ "year": year,
42
+ "race": race,
43
+ "session": session,
44
+ "driver": driver
45
+ }
46
+ doc = collection.find_one(query, {"_id": 0, "data": 1})
47
+
48
+ if doc and doc["data"]:
49
+ df = pd.DataFrame(doc["data"])
50
+ #st.line_chart(df,x="time", y="steering_angle")
51
+ #st.dataframe(df)
52
+
53
+
54
+ st.markdown("# Results")
55
+
56
+ with st.spinner("Processing frames..."):
57
+
58
+ display_results(df)
59
+
60
+ st.markdown("")
61
+ st.subheader("Steering Line Chart 📈")
62
+ # Create a Plotly figure
63
+
64
+ create_line_chart(df)
65
+
66
+ # Add steering angle statistics using Streamlit's built-in components
67
+ st.subheader("Steering Statistics 📊")
68
+ col1, col2, col3, col4 = st.columns(4)
69
+
70
+ with col1:
71
+ st.metric("Mean Angle", f"{df['steering_angle'].mean():.2f}°")
72
+
73
+ with col2:
74
+ st.metric("Max Right Turn", f"{df['steering_angle'].max():.2f}°")
75
+
76
+ with col3:
77
+ st.metric("Max Left Turn", f"{df['steering_angle'].min():.2f}°")
78
+
79
+ with col4:
80
+ # Calculate average rate of change of steering angle
81
+ angle_changes = abs(df['steering_angle'].diff().dropna())
82
+ st.metric("Avg. Change Rate", f"{angle_changes.mean():.2f}°/frame")
83
+ else:
84
+ st.warning("No se encontraron datos.")
85
+ except Exception as e:
86
+ st.error(f"Error at fetching data")
87
+ st.warning(f"If you are executing the app locally without the desktop app, you see this this message due to mongo keys")
navigation/steering-angle.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import cv2
3
+ import numpy as np
4
+ from pathlib import Path
5
+ import plotly.graph_objects as go
6
+ from PIL import Image, ImageDraw
7
+ from utils.video_processor import VideoProcessor
8
+ from utils.model_handler import ModelHandler
9
+ from utils.ui_components import (
10
+ create_header,
11
+ create_upload_section,
12
+ create_frame_selector,
13
+ display_results,
14
+ create_line_chart
15
+ )
16
+ from utils.video_processor import profiler
17
+
18
+
19
+ if 'BASE_DIR' not in st.session_state:
20
+ from utils.helper import BASE_DIR,metrics_collection
21
+ st.session_state.BASE_DIR = BASE_DIR
22
+ print("BASE_DIR", BASE_DIR)
23
+
24
+ if 'metrics_collection' not in st.session_state:
25
+ from utils.helper import BASE_DIR,metrics_collection
26
+ st.session_state.metrics_collection = metrics_collection
27
+ print("metrics_collection", metrics_collection)
28
+
29
+ BASE_DIR = st.session_state.BASE_DIR
30
+ metrics_collection = st.session_state.metrics_collection
31
+ path_load_css = Path(BASE_DIR) / "assets" / "style.css"
32
+ print(path_load_css)
33
+
34
+ def load_css():
35
+ with open(Path(BASE_DIR) / "assets" / "style.css") as f:
36
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
37
+
38
+
39
+
40
+ def create_upload_section():
41
+ """Create the video upload section"""
42
+ st.markdown("<div class='glassmorphic-container'>", unsafe_allow_html=True)
43
+ uploaded_file = st.file_uploader(
44
+ "Upload Video",
45
+ type=['mp4', 'avi', 'mov'],
46
+ help="Upload onboard camera footage for analysis"
47
+ )
48
+ st.markdown("</div>", unsafe_allow_html=True)
49
+ return uploaded_file
50
+
51
+
52
+ def clear_session_state():
53
+ """Clear unnecessary session state variables to free memory."""
54
+ keys_to_clear = ['video_processor', 'df', 'processed_frames', 'processed_frames1','end_dic', 'start_dic', 'start_preview', 'end_preview', 'start_preview1','end_frame_helper', 'start_frame_helper', 'start_frame', 'end_frame','driver_crop_type', 'driver_crop_type_2', 'start_frame_helper', 'end_frame_helper','postprocessing_mode']
55
+
56
+ for key in keys_to_clear:
57
+ if key in st.session_state:
58
+ del st.session_state[key]
59
+
60
+
61
+ load_css()
62
+ cont = 0
63
+
64
+
65
+
66
+ col1, col2,col3 = st.columns([1,3,1])
67
+
68
+ with col2:
69
+ st.title("F1 Steering Angle Model")
70
+
71
+ '''
72
+ [![Repo](https://badgen.net/badge/icon/GitHub?icon=github&label)](https://github.com/danielsaed/F1-steering-angle-predictor)
73
+ [![Dataset on HF](https://huggingface.co/datasets/huggingface/badges/resolve/main/dataset-on-hf-md.svg)](https://huggingface.co/datasets/daniel-saed/f1-steering-angle)
74
+ '''
75
+ tabs = st.tabs(["Use Model", "About"])
76
+ # Initialize session state
77
+ if 'video_processor' not in st.session_state:
78
+ st.session_state.video_processor = VideoProcessor()
79
+ if 'model_handler' not in st.session_state:
80
+ st.session_state.model_handler = ModelHandler()
81
+ if 'fps_target' not in st.session_state:
82
+ st.session_state.fps_target = 10 # Default FPS target
83
+ if 'driver_crop_type' not in st.session_state:
84
+ st.session_state.driver_crop_type = None # Default FPS target
85
+ if 'driver_crop_type_2' not in st.session_state:
86
+ st.session_state.driver_crop_type_2 = None # Default FPS target
87
+ if 'start_frame' not in st.session_state:
88
+ st.session_state.start_frame = 0 # Default FPS target
89
+ if 'end_frame' not in st.session_state:
90
+ st.session_state.end_frame = -1 # Default FPS target
91
+ if 'start_preview' not in st.session_state:
92
+ st.session_state.start_preview = None
93
+ if 'end_preview' not in st.session_state:
94
+ st.session_state.end_preview = None
95
+ if 'start_preview1' not in st.session_state:
96
+ st.session_state.start_preview1 = None
97
+ if 'start_frame_helper' not in st.session_state:
98
+ st.session_state.start_frame_helper = 0
99
+ if 'end_frame_helper' not in st.session_state:
100
+ st.session_state.end_frame_helper = -1
101
+ if 'end_dic' not in st.session_state:
102
+ st.session_state.end_dic = None
103
+ if 'start_dic' not in st.session_state:
104
+ st.session_state.start_dic = None
105
+ if 'postprocessing_mode' not in st.session_state:
106
+ st.session_state.model_handler.postprocessing_mode = None
107
+
108
+
109
+
110
+ with tabs[0]: # Steering Angle Detection tab
111
+ st.warning("Downloading or recording F1 onboards videos potentially violates F1/F1TV's terms of service.")
112
+ coll1, coll2,coll3 = st.columns([12,1,8])
113
+ with coll1:
114
+ st.markdown("#### Step 1: Upload F1 Onboard Video ⬆️")
115
+ st.markdown("")
116
+ st.markdown("Recomendations:")
117
+
118
+ st.markdown("- Check historical DB before, the lap may already be processed.")
119
+ st.markdown("- To record disable hardware aceleration on chrome.")
120
+ st.markdown("- Onboards with no steering wheel visibility, like Leclerc's 2025, may not work well.")
121
+
122
+
123
+ uploaded_file = create_upload_section()
124
+
125
+ with coll3:
126
+ st.markdown("<span style='margin-right: 18px;'><strong>Onboard example:</strong></span>", unsafe_allow_html=True)
127
+ st.markdown("- 1080p,720p,480p resolutions, 10 to 30 FPS.")
128
+ st.markdown("- Full onboard (mandatory).")
129
+ #st.markdown("( For testing, if needed )", unsafe_allow_html=True)
130
+
131
+ VIDEO_URL = Path(BASE_DIR) / "assets" / "demo_video.mp4"
132
+ st.video(VIDEO_URL)
133
+
134
+
135
+ if uploaded_file:
136
+ with st.spinner("Loading..."):
137
+
138
+ st.markdown("")
139
+ st.markdown("")
140
+ st.markdown("")
141
+ st.markdown("")
142
+ st.markdown("")
143
+ st.markdown("")
144
+ # Load video
145
+ if st.session_state.video_processor.load_video(uploaded_file):
146
+
147
+
148
+ if st.session_state.end_dic is None:
149
+ st.session_state.end_dic = st.session_state.video_processor.frames_list_end
150
+ print("End dic loaded:")
151
+
152
+ if st.session_state.start_dic is None:
153
+ st.session_state.start_dic = st.session_state.video_processor.frames_list_start
154
+ print("Start dic loaded:")
155
+
156
+
157
+ total_frames = st.session_state.video_processor.total_frames
158
+ original_fps = st.session_state.video_processor.fps
159
+ print("Original FPS:", original_fps)
160
+ print("Total frames:", total_frames)
161
+
162
+
163
+
164
+ # FPS selection dropdown - after video is loaded
165
+ st.markdown("<div class='glassmorphic-container'>", unsafe_allow_html=True)
166
+
167
+
168
+ st.markdown("#### Step 2: Select Start And End Frames ✂️")
169
+
170
+
171
+ start_frame_min = 0
172
+ start_frame_max = int(total_frames * 0.1) # 10% del total
173
+ end_frame_min = int(total_frames * 0.9) # 90% del total
174
+ end_frame_max = total_frames - 1
175
+ if st.session_state.end_frame_helper == -1:
176
+ st.session_state.end_frame_helper = end_frame_max
177
+
178
+ # Actualizar los valores de session_state basándose en el slider
179
+ st.markdown("- Match start & finish line")
180
+ # CSS personalizado para los botones
181
+ preview_cols1 = st.columns(2)
182
+
183
+ with preview_cols1[0]:
184
+ st.markdown("##### Start Frame")
185
+ #slicer
186
+ st.session_state.start_frame_helper = st.slider(
187
+ "Select Start Frame",
188
+ min_value=st.session_state.video_processor.start_frame_min,
189
+ max_value=st.session_state.video_processor.start_frame_max,
190
+ value=st.session_state.start_frame_helper,
191
+ step=1,
192
+ help="Select the start frame for processing",
193
+ key="start_frame_slider"
194
+ )
195
+ with preview_cols1[1]:
196
+ st.markdown("##### End Frame")
197
+ st.session_state.end_frame_helper = st.slider(
198
+ "Select Start Frame",
199
+ min_value=st.session_state.video_processor.end_frame_min,
200
+ max_value=st.session_state.video_processor.end_frame_max,
201
+ value=st.session_state.end_frame_helper,
202
+ step=1,
203
+ help="Select the start frame for processing",
204
+ key="end_frame_slider"
205
+ )
206
+
207
+
208
+
209
+ # Botones de control en la parte superior
210
+ btn_cols = st.columns([1, 1, 5, 1, 1, 5])
211
+
212
+ with btn_cols[0]:
213
+ if st.button("-1",key="start_minus_1",use_container_width=True):
214
+ st.session_state.start_frame_helper = max(start_frame_min, st.session_state.start_frame_helper - 1)
215
+ st.rerun() # Rerun to update the UI with the new value
216
+ with btn_cols[1]:
217
+ if st.button("+1",key="start_plus_1",use_container_width=True):
218
+ st.session_state.start_frame_helper = min(start_frame_max, st.session_state.start_frame_helper + 1)
219
+ st.rerun() # Rerun to update the UI with the new value
220
+ with btn_cols[3]:
221
+ if st.button("-1", key="end_minus_1",
222
+ help="Decrease end frame by 1",
223
+ use_container_width=True):
224
+ st.session_state.end_frame_helper = max(end_frame_min, st.session_state.end_frame_helper - 1)
225
+ st.rerun() # Rerun to update the UI with the new value
226
+ with btn_cols[4]:
227
+ if st.button("+1", key="end_plus_1",
228
+ help="Increase end frame by 1",
229
+ use_container_width=True):
230
+ st.session_state.end_frame_helper = min(end_frame_max, st.session_state.end_frame_helper + 1)
231
+ st.rerun()
232
+
233
+
234
+ print("Start frame helper:", st.session_state.end_frame_helper)
235
+ print("Start frame helper:", st.session_state.end_frame)
236
+
237
+
238
+
239
+ # Añadir un poco de espacio entre botones y previsualizaciones
240
+ st.markdown("<br>", unsafe_allow_html=True)
241
+
242
+ # Preview columns originales (mantener tu código existente)
243
+ preview_cols = st.columns(2)
244
+
245
+ # Start frame preview
246
+ with preview_cols[0]:
247
+
248
+ # Siempre verificar si necesitamos actualizar la previsualización
249
+ if (st.session_state.start_preview is None or
250
+ st.session_state.start_frame_helper != st.session_state.start_frame):
251
+ try:
252
+ print("Getting start frame preview for frame:", st.session_state.start_frame_helper)
253
+ st.session_state.start_preview = st.session_state.start_dic[st.session_state.start_frame_helper]
254
+ # Actualizar también el valor de referencia en session_state
255
+ st.session_state.start_frame = st.session_state.start_frame_helper
256
+ except Exception as e:
257
+ print("Error getting start frame preview:", e)
258
+ pass
259
+
260
+ if st.session_state.start_preview is not None:
261
+ print("Displaying start frame preview for frame:", st.session_state.start_frame_helper)
262
+ st.image(st.session_state.start_preview, caption=f"Start Frame: {st.session_state.start_frame_helper}", use_container_width=True)
263
+
264
+ # End frame preview
265
+ with preview_cols[1]:
266
+
267
+ # Aplicar la misma lógica para el end frame
268
+ if (st.session_state.end_preview is None or
269
+ st.session_state.end_frame_helper != st.session_state.end_frame):
270
+ try:
271
+ print("Getting end frame preview for frame:", st.session_state.end_frame_helper)
272
+ st.session_state.end_preview = st.session_state.end_dic[st.session_state.end_frame_helper]
273
+ # Actualizar también el valor de referencia en session_state
274
+ st.session_state.end_frame = st.session_state.end_frame_helper
275
+ except Exception as e:
276
+ print("Error getting end frame preview:", e)
277
+ pass
278
+ if st.session_state.end_preview is not None:
279
+ st.image(st.session_state.end_preview, caption=f"End Frame: {st.session_state.end_frame_helper}", use_container_width=True)
280
+
281
+ st.markdown("</div>", unsafe_allow_html=True)
282
+ # ...existing code...
283
+
284
+ # Display the current range information
285
+ selected_frames = st.session_state.end_frame_helper - st.session_state.start_frame_helper + 1
286
+ selected_duration = selected_frames / original_fps
287
+ estimated_selected_frames = int(selected_duration * st.session_state.fps_target)
288
+
289
+ # Create a dropdown for FPS selection
290
+ actual_fps = st.session_state.fps_target
291
+ st.session_state.fps_target = original_fps
292
+
293
+ st.info(f"Selected range: {st.session_state.start_frame_helper} to {st.session_state.end_frame_helper} ({int(selected_duration*st.session_state.fps_target)} frames, {selected_duration:.2f} seconds). "
294
+ f"At {st.session_state.fps_target} FPS")
295
+
296
+ st.markdown("")
297
+ st.markdown("")
298
+ st.markdown("")
299
+ st.markdown("")
300
+ st.markdown("")
301
+ st.markdown("")
302
+
303
+
304
+
305
+ lst_team_option = ('RedBull', 'Ferrari', 'Mclaren','Mercedes','Williams','Aston Martin','RB','Hass','Sauber', 'Alpine')
306
+
307
+ dic_masks = {
308
+ 'RedBull': ('Verstappen 2025','Tsunoda 2025'),
309
+ 'Ferrari': ('Hamilton 2025','Leclerc 2025'),
310
+ 'Mclaren': ('Piastri 2025','Norris 2025'),
311
+ 'Mercedes': ('Antonelli 2025','Russell 2025'),
312
+ 'Williams': ('Albon 2025','Sainz 2025'),
313
+ 'Alpine': ('Gasly 2025','Colapinto 2025'),
314
+ 'RB': ('Hadjar 2025','Lawson 2025'),
315
+ 'Hass': ('Bearman 2025','Ocon 2025'),
316
+ 'Sauber': ('Hulk 2025','Bortoleto 2025'),
317
+ 'Aston Martin': ('Alonso 2025','Stroll 2025')
318
+
319
+
320
+ }
321
+
322
+ #('Verstappen 2025', 'Piastri 2025','Norris 2025','Leclerc 2025','Hamilton 2025','Russell 2025', 'Antonelli 2025', 'Tsunoda 2025')
323
+
324
+
325
+ driver_crop_type = st.session_state.driver_crop_type_2
326
+
327
+ st.markdown("#### Step 3: Select Crop type 👈")
328
+ st.markdown("- Steering wheel, helmet and hands shold be visible, aim for acrop type like the example image.")
329
+ st.markdown("- Some onboards change the camera position along the season, a different team/driver crop type can match the camera position desired.")
330
+
331
+
332
+ lst_columns = st.columns(2)
333
+
334
+ with lst_columns[0]:
335
+
336
+ st.session_state.driver_crop_type_2 = st.selectbox(
337
+ "Select team",
338
+ lst_team_option,
339
+ index=None,
340
+ format_func=lambda x: f"{x}",
341
+ help="Choose recort for processing"
342
+ )
343
+ with lst_columns[1]:
344
+ if st.session_state.driver_crop_type_2 != None:
345
+ st.session_state.driver_crop_type = st.selectbox(
346
+ "Select driver",
347
+ dic_masks[st.session_state.driver_crop_type_2],
348
+ index=0,
349
+ format_func=lambda x: f"{x}",
350
+ help="Choose recort for processing"
351
+ )
352
+
353
+ if st.session_state.driver_crop_type != None:
354
+ # Update the video processor with the selected crop type
355
+
356
+ print("Crop type updated to:", st.session_state.driver_crop_type)
357
+
358
+ if st.session_state.driver_crop_type != driver_crop_type:
359
+
360
+ st.session_state.btn = False
361
+
362
+
363
+ preview_cols1 = st.columns(2)
364
+ with preview_cols1[0]:
365
+ st.markdown("##### Current Crop Type")
366
+ if st.session_state.start_preview1 is None or st.session_state.driver_crop_type != driver_crop_type:
367
+ st.session_state.start_preview1 = st.session_state.video_processor.get_frame_example(0)
368
+ st.session_state.video_processor.load_crop_variables(st.session_state.driver_crop_type)
369
+
370
+ st.session_state.start_preview1 = st.session_state.video_processor.crop_frame_example(st.session_state.start_preview1)
371
+
372
+ if st.session_state.start_preview1 is not None:
373
+ st.image(st.session_state.start_preview1, caption=f"Example",use_container_width=True)
374
+
375
+ # End frame preview
376
+ with preview_cols1[1]:
377
+ st.markdown("##### Example frame")
378
+ st.image(Path(BASE_DIR) / "img" / "example.png", caption=f"GOAL Frame:",use_container_width=True)
379
+
380
+
381
+
382
+
383
+ # Process button
384
+ st.markdown("")
385
+ st.markdown("")
386
+ st.markdown("")
387
+ st.markdown("")
388
+ st.markdown("")
389
+ st.markdown("")
390
+
391
+ st.markdown("#### Step 5: (Opcional) Postprocessing Settings")
392
+ st.markdown("- First try default mode, is the best for 90% of the cases")
393
+
394
+ #agregar opciones en radio para elegir el tipo de procesamiento
395
+
396
+ postprocessing_mode = st.radio(
397
+ "Select Postprocessing Mode",
398
+ options=["Default","Low ilumination"],
399
+ index=0,
400
+ help="Choose the postprocessing mode for the model",
401
+ horizontal=False
402
+ )
403
+ if postprocessing_mode != st.session_state.model_handler.postprocessing_mode:
404
+
405
+ st.session_state.btn = False
406
+ st.session_state.model_handler.postprocessing_mode = postprocessing_mode
407
+ #st.rerun() # Rerun to update the UI with the new value
408
+
409
+
410
+ # Process button
411
+ st.markdown("")
412
+ st.markdown("")
413
+ st.markdown("")
414
+ st.markdown("")
415
+ st.markdown("")
416
+ st.markdown("")
417
+
418
+
419
+
420
+
421
+
422
+
423
+ st.markdown("#### Step 4: Execute Model 🚀")
424
+ if st.button("Process Video Segment") or st.session_state.get('btn', True):
425
+
426
+ if not(st.session_state.get('btn', True)):
427
+ # Reset profiler before processing
428
+ profiler.reset()
429
+ #st.rerun() # Rerun to update the UI with the new value
430
+
431
+ with st.spinner("Processing frames..."):
432
+ if int(selected_duration*st.session_state.fps_target) > 500:
433
+ st.warning("⚠️ Large video segment selected, it could take some minutes to process.")
434
+
435
+
436
+ # Extract and process frames
437
+ st.session_state.video_processor.mode = postprocessing_mode
438
+ frames,crude_frames = st.session_state.video_processor.extract_frames(
439
+ st.session_state.start_frame_helper, st.session_state.end_frame_helper, fps_target=st.session_state.fps_target
440
+ )
441
+ st.session_state.model_handler.fps = original_fps
442
+ results = st.session_state.model_handler.process_frames(
443
+ frames, "F1 Steering Angle Detection"
444
+ )
445
+ try:
446
+ metrics_collection.update_one(
447
+ {"action": "descargar_app"},
448
+ {"$inc": {"count": 1}}
449
+ )
450
+ except:
451
+ st.warning("MongoDB client not connected.")
452
+ #st.session_state.processed_frames = crude_frames
453
+ #st.session_state.processed_frames1 = frames
454
+ # Convert results to DataFrame and display
455
+ df = st.session_state.model_handler.export_results(results)
456
+ st.session_state.df = df
457
+ st.session_state.video_processor.clear_cache() # Clear cache after processing
458
+ # Clear unnecessary session state variables to free memory
459
+ # Create steering angle chart using Plotly
460
+ df = st.session_state.df
461
+ #crude_frames = st.session_state.processed_frames
462
+ #frames = st.session_state.processed_frames1
463
+
464
+ st.markdown("")
465
+ st.markdown("")
466
+ st.markdown("")
467
+
468
+
469
+
470
+
471
+ st.markdown("# Results")
472
+
473
+ display_results(df)
474
+
475
+
476
+
477
+ st.markdown("")
478
+ st.subheader("Steering Line Chart 📈")
479
+ # Create a Plotly figure
480
+
481
+ create_line_chart(df)
482
+
483
+ # Add steering angle statistics using Streamlit's built-in components
484
+ st.subheader("Steering Statistics 📊")
485
+ col1, col2, col3, col4 = st.columns(4)
486
+
487
+ with col1:
488
+ st.metric("Mean Angle", f"{df['steering_angle'].mean():.2f}°")
489
+
490
+ with col2:
491
+ st.metric("Max Right Turn", f"{df['steering_angle'].max():.2f}°")
492
+
493
+ with col3:
494
+ st.metric("Max Left Turn", f"{df['steering_angle'].min():.2f}°")
495
+
496
+ with col4:
497
+ # Calculate average rate of change of steering angle
498
+ angle_changes = abs(df['steering_angle'].diff().dropna())
499
+ st.metric("Avg. Change Rate", f"{angle_changes.mean():.2f}°/frame")
500
+
501
+ st.session_state.btn = True
502
+
503
+
504
+ else:
505
+ st.session_state.btn = False
506
+ try:
507
+ st.session_state.video_processor.clean_up() # Clear cache if no video is uploaded
508
+ clear_session_state()
509
+ print("Session state cleared")
510
+ except:
511
+ print("Error clearing session state")
512
+
513
+
514
+ with tabs[1]: # Driver Behavior tab
515
+
516
+ st.info("For research/educational purposes only, its not related to F1 or any organization.")
517
+
518
+
519
+
520
+
521
+
522
+ st.markdown("""
523
+ #####
524
+ ## The Model
525
+
526
+ - The **F1 Steering Angle Prediction Model** uses a CNN based on EfficientNet-B0 to predict steering angles from a F1 onboard camera footage, trained with over 25,000 images (7000 manual labaled augmented to 25000) and YOLOv8-seg nano for helmets segmentation, allowing the model to be more robust by erasing helmet designs.
527
+
528
+ - Currentlly the model is able to predict steering angles from 180° to -180° with a 3°-5° of error on ideal contitions.
529
+
530
+ - EfficientNet-B0 and YOLOv8-seg nano are exported to ONNX format, and images are resized to 224x224 allowing it to run on low-end devices.
531
+
532
+
533
+
534
+ #####
535
+ ## How It Works
536
+
537
+ ##### Video Processing:
538
+ - From the onboard camera video, the frames selected are extracted at the FPS rate.
539
+
540
+ ##### Image Preprocessing:
541
+ - The frames are cropeed based on selected crop type to focus on the steering wheel and driver area.
542
+ - YOLOv8-seg nano is applied to the cropped images to segment the helmet, removing designs and logos.
543
+ - Convert cropped images to grayscale and apply CLAHE to enhance visibility.
544
+ - Apply adaptive Canny edge detection to extract edges, helped with preprocessing techniques like bilateralFilter and morphological transformations.
545
+
546
+ ##### Prediction:
547
+ - The CNN model processes the edge image to predict the steering angle
548
+
549
+ ##### Postprocessing
550
+ - apply local a trend-based outlier correction algorithm to detect and correct outliers
551
+
552
+ ##### Results Visualization
553
+ - Angles are displayed as a line chart with statistical analysis also a csv file with the frame number, time and the steering angle.
554
+ #####""")
555
+
556
+
557
+ coll1, coll2, coll3,coll4,coll5 = st.columns([40,23,23,23,23])
558
+
559
+ with coll1:
560
+ st.image(Path(BASE_DIR) / "img" / "verstappen_china_2025.jpg", caption="1. Original Frame", use_container_width=True)
561
+ with coll2:
562
+ # Mostrar ejemplos de preprocesamiento - necesitas agregar estas imágenes a tu carpeta img/
563
+
564
+ st.image(Path(BASE_DIR) / "img" / "verstappen_china_2025_cropped.jpg", caption="2. Crop image",use_container_width=True)
565
+
566
+
567
+ with coll3:
568
+ st.image(Path(BASE_DIR) / "img" / "verstappen_china_2025_nohelmet.jpg", caption="3. Segment Helmet with YOLO",use_container_width=True)
569
+
570
+ with coll4:
571
+ st.image(Path(BASE_DIR) / "img" / "verstappen_china_2025_clahe.jpg", caption="4. Apply clahe",use_container_width=True)
572
+
573
+ with coll5:
574
+ st.image(Path(BASE_DIR) / "img" / "verstappen_china_2025_tresh.jpg", caption="5. Edge detection",use_container_width=True)
575
+
576
+
577
+ st.markdown("""
578
+ ####
579
+ ## Limitations
580
+ - Low visibility conditions (rain, extreme shadows, extreme light).
581
+ - Not well recorded videos.
582
+ - Change of onboard camera position (different angle, height, shakiness).
583
+ """)
584
+
585
+
586
+
587
+
588
+
589
+
590
+
591
+
streamlit_app.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #streamlit run your_script.py
2
+ import streamlit as st
3
+ import os
4
+ import sys
5
+
6
+ from pathlib import Path
7
+
8
+ st.set_page_config(
9
+ page_title="F1 Video Analysis Platform",
10
+ page_icon="🏎️",
11
+ initial_sidebar_state="expanded",
12
+ layout="wide"
13
+ )
14
+ from utils.helper import BASE_DIR,metrics_page
15
+ if "visited" not in st.session_state:
16
+ st.session_state["visited"] = True
17
+ try:
18
+ metrics_page.update_one({"page": "inicio"}, {"$inc": {"visits": 1}})
19
+ except:
20
+ st.warning("MongoDB client not connected.")
21
+
22
+
23
+ hide_decoration_bar_style = '''
24
+ <style>
25
+ header {visibility: hidden;}
26
+ </style>
27
+ '''
28
+ logo_style = '''
29
+ <style>
30
+ /* Estilo para iconos de contacto - versión compacta */
31
+ .contact-icons {
32
+ display: flex;
33
+ justify-content: center;
34
+ gap: 8px;
35
+ margin-top: 10px;
36
+ flex-wrap: wrap;
37
+ }
38
+
39
+ .contact-icon {
40
+ display: flex;
41
+ align-items: center;
42
+ padding: 6px;
43
+ border-radius: 50%;
44
+ background-color: rgba(255, 255, 255, 0.1);
45
+ transition: all 0.3s ease;
46
+ text-decoration: none;
47
+ color: #ffffff;
48
+ }
49
+
50
+ .contact-icon:hover {
51
+ background-color: rgba(255, 255, 255, 0.2);
52
+ transform: translateY(-2px);
53
+ }
54
+
55
+ .contact-icon img {
56
+ width: 16px;
57
+ height: 16px;
58
+ }
59
+
60
+ /* Email button style */
61
+ .email-button {
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ padding: 8px 15px;
66
+ border-radius: 20px;
67
+ background-color: rgba(255, 255, 255, 0.1);
68
+ transition: all 0.3s ease;
69
+ text-decoration: none;
70
+ color: #ffffff;
71
+ font-size: 13px;
72
+ margin-top: 12px;
73
+ width: 100%;
74
+ }
75
+
76
+ .email-button:hover {
77
+ background-color: rgba(255, 255, 255, 0.2);
78
+ }
79
+
80
+ .email-button img {
81
+ width: 16px;
82
+ height: 16px;
83
+ margin-right: 8px;
84
+ }
85
+
86
+ /* Estilo para el separador */
87
+ .sidebar-separator {
88
+ margin: 20px 0;
89
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
90
+ }
91
+ </style>
92
+ '''
93
+ #sst.markdown(hide_decoration_bar_style, unsafe_allow_html=True)logo_style
94
+ st.markdown(logo_style, unsafe_allow_html=True)
95
+
96
+ #st.markdown("<br>",unsafe_allow_html=True)
97
+
98
+ with st.sidebar:
99
+ st.markdown("<h3 style='text-align: center; color: #fff;'>Considerations</h3>", unsafe_allow_html=True)
100
+ st.caption("""**Ouput Data**:""")
101
+ st.markdown("<p style='text-align: left; color: gray; font-size: 12px;'>The model is trained with images from -180° to 180°, for the moment may not accurately predict angles beyond 180°. Poor or high-intensity lighting may affect data accuracy.</p>", unsafe_allow_html=True)
102
+ st.caption("""**Usage**:""")
103
+ st.markdown("<p style='text-align: left; color: gray; font-size: 12px;'>Free-tier server resources are limited, so the page may be slow or crash with large files. To run it locally, feel free to fork/clone the project or download the desktop app.</p>", unsafe_allow_html=True)
104
+
105
+ st.markdown("<p style='text-align: left; color: gray; font-size: 12px;'>Any feedback is welcome.</p>", unsafe_allow_html=True)
106
+ st.markdown("", unsafe_allow_html=True)
107
+
108
+ st.markdown("<h3 style='text-align: center; color: #fff;'>Contact</h3>", unsafe_allow_html=True)
109
+ # Nueva versión más compacta de los iconos
110
+ contact_html = """
111
+ <div class="contact-icons">
112
+ <a href='https://x.com/justsaed' target="_blank" class="contact-icon" title="X">
113
+ <img src="https://static.vecteezy.com/system/resources/previews/053/986/348/non_2x/x-twitter-icon-logo-symbol-free-png.png" alt="X">
114
+ </a>
115
+ <a href="https://github.com/danielsaed/F1-steering-angle-model" target="_blank" class="contact-icon" title="GitHub">
116
+ <img src="https://cdn-icons-png.flaticon.com/512/25/25231.png" alt="GitHub">
117
+ </a>
118
+ </div>
119
+
120
+ """
121
+
122
+ st.markdown(contact_html, unsafe_allow_html=True)
123
+ st.write("")
124
+ st.write("")
125
+ st.markdown("<p style='text-align: center; color: gray; font-size: 10px;'>For research/educational purposes only</p>", unsafe_allow_html=True)
126
+
127
+ st.write("")
128
+ st.write("")
129
+ st.write("")
130
+
131
+ st.markdown("<h3 style='text-align: center; color: #fff;'>Get Desktop App</h3>", unsafe_allow_html=True)
132
+ col1,col2, col3 = st.columns([1,6,1])
133
+ with col2:
134
+
135
+ st.markdown("<p style='text-align: center; color: gray; font-size: 10px;'>Click Assets then download .exe</p>", unsafe_allow_html=True)
136
+ st.link_button("Download", "https://github.com/danielsaed/F1-steering-angle-model/releases",type="secondary",use_container_width=True)
137
+
138
+
139
+ pages = st.navigation({
140
+ "Steering Angle Model": [
141
+ st.Page(Path(BASE_DIR) / "navigation" / "steering-angle.py", title="Use Model"),
142
+ st.Page(Path(BASE_DIR) / "navigation" / "soon.py", title="Historical Steering Data Base"),
143
+ ],})
144
+
145
+ pages.run()
146
+
utils/__pycache__/helper.cpython-311.pyc ADDED
Binary file (24.8 kB). View file
 
utils/__pycache__/model_handler.cpython-311.pyc ADDED
Binary file (11.8 kB). View file
 
utils/__pycache__/ui_components.cpython-311.pyc ADDED
Binary file (6.17 kB). View file
 
utils/__pycache__/video_processor.cpython-311.pyc ADDED
Binary file (40.5 kB). View file
 
utils/helper.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from typing import Tuple
4
+ import tempfile
5
+ import os
6
+ from PIL import Image
7
+ import sys
8
+ from pymongo import MongoClient
9
+ from dotenv import load_dotenv
10
+ import os
11
+ import streamlit as st
12
+
13
+ try:
14
+ if getattr(sys, 'frozen', False):
15
+ # En el ejecutable, intentar sys._MEIPASS
16
+ BASE_DIR = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
17
+ print(f"Executable mode - Initial BASE_DIR: {BASE_DIR} (_MEIPASS: {hasattr(sys, '_MEIPASS')})")
18
+ # Verificar si BASE_DIR contiene los archivos esperados
19
+ expected_dirs = ['navigation', 'models', 'assets', 'img', 'utils']
20
+ if not any(os.path.exists(os.path.join(BASE_DIR, d)) for d in expected_dirs):
21
+ print(f"Warning: Expected directories not found in {BASE_DIR}")
22
+ # Buscar _MEI<random> en el directorio padre
23
+ temp_dir = os.path.dirname(BASE_DIR) if BASE_DIR != os.path.dirname(sys.executable) else BASE_DIR
24
+ for d in os.listdir(temp_dir):
25
+ if d.startswith('_MEI'):
26
+ candidate = os.path.join(temp_dir, d)
27
+ if any(os.path.exists(os.path.join(candidate, ed)) for ed in expected_dirs):
28
+ BASE_DIR = candidate
29
+ print(f"Adjusted BASE_DIR to _MEI directory: {BASE_DIR}")
30
+ break
31
+ else:
32
+ print(f"No _MEI directory found in {temp_dir}, using {BASE_DIR}")
33
+ else:
34
+ # En desarrollo, usar el directorio del proyecto
35
+ current_file = os.path.abspath(os.path.realpath(__file__))
36
+ print(f"Development mode - Current file: {current_file}")
37
+ BASE_DIR = os.path.dirname(os.path.dirname(current_file)) # Subir de utils/ a F1-machine-learning-webapp/
38
+ print(f"Development mode - BASE_DIR: {BASE_DIR}")
39
+ except Exception as e:
40
+ print(f"Error setting BASE_DIR: {e}")
41
+ # Fallback
42
+ BASE_DIR = os.path.dirname(os.path.abspath(os.path.realpath(__file__)))
43
+ BASE_DIR = os.path.dirname(BASE_DIR)
44
+ print(f"Fallback BASE_DIR: {BASE_DIR}")
45
+
46
+ BASE_DIR = os.path.normpath(BASE_DIR)
47
+ print(f"Final BASE_DIR: {BASE_DIR}")
48
+
49
+
50
+
51
+
52
+ #load_dotenv() # Carga las variables desde .env
53
+ #mongo_uri = os.getenv("MONGO_URI")
54
+ @st.cache_resource
55
+ def get_mongo_client():
56
+ return MongoClient(st.secrets["MONGO_URI"])
57
+ client = get_mongo_client()
58
+
59
+
60
+ def get_metrics_collections():
61
+
62
+ db = client["f1_data"]
63
+ metrics_collection = db["usage_metrics"]
64
+ metrics_page = db["visits"]
65
+ return metrics_collection, metrics_page, db
66
+
67
+ metrics_collection, metrics_page, db = get_metrics_collections()
68
+ '''if not metrics_page.find_one({"page": "inicio"}):
69
+ metrics_page.insert_one({"page": "inicio", "visits": 0})
70
+ if not metrics_collection.find_one({"action": "descargar_app"}):
71
+ metrics_collection.insert_one({"action": "descargar_app", "count": 0})'''
72
+ '''except:
73
+ print("Error loading MongoDB URI from .env file. Please check your configuration.")
74
+ client = None
75
+ metrics_collection = None
76
+ metrics_page = None
77
+ db = None'''
78
+
79
+
80
+ #-------------YOLO ONNX HELPERS-------------------
81
+
82
+ def preprocess_image_tensor(image_rgb: np.ndarray) -> np.ndarray:
83
+ """Preprocess image to match Ultralytics YOLOv8."""
84
+
85
+ '''input = np.array(image_rgb)
86
+ input = input.transpose(2, 0, 1)
87
+ input = input.reshape(1,3,224,224).astype("float32")
88
+ input = input/255.0'''
89
+
90
+ input_data = image_rgb.transpose(2, 0, 1).reshape(1, 3, 224, 224)
91
+
92
+ # Convert to float32 and normalize to [0, 1]
93
+ input_data = input_data.astype(np.float32) / 255.0
94
+
95
+ return input_data
96
+
97
+ def postprocess_outputs(outputs: list, height: int, width: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
98
+ """Process ONNX model outputs for a single-class model."""
99
+ res_size = 56
100
+ output0 = outputs[0]
101
+ output1 = outputs[1]
102
+
103
+ output0 = output0[0].transpose()
104
+ output1 = output1[0]
105
+
106
+ boxes = output0[:,0:5]
107
+ masks = output0[:,5:]
108
+
109
+ output1 = output1.reshape(32,res_size*res_size)
110
+
111
+ masks = masks @ output1
112
+
113
+ boxes = np.hstack([boxes,masks])
114
+
115
+ yolo_classes = [
116
+ "helmet"
117
+ ]
118
+
119
+ # parse and filter all boxes
120
+ objects = []
121
+ for row in boxes:
122
+ xc,yc,w,h = row[:4]
123
+ x1 = (xc-w/2)/224*width
124
+ y1 = (yc-h/2)/224*height
125
+ x2 = (xc+w/2)/224*width
126
+ y2 = (yc+h/2)/224*height
127
+ prob = row[4:5].max()
128
+ if prob < 0.2:
129
+ continue
130
+ class_id = row[4:5].argmax()
131
+ label = yolo_classes[class_id]
132
+
133
+ mask = get_mask(row[5:25684], (x1,y1,x2,y2), width, height)
134
+ try:
135
+ polygon = get_polygon(mask)
136
+ except:
137
+ continue
138
+ objects.append([x1,y1,x2,y2,label,prob,mask,polygon])
139
+
140
+
141
+
142
+ # apply non-maximum suppression
143
+ objects.sort(key=lambda x: x[5], reverse=True)
144
+ result = []
145
+ while len(objects)>0:
146
+ result.append(objects[0])
147
+ objects = [object for object in objects if iou(object,objects[0])<0.7]
148
+
149
+
150
+
151
+ return True,result
152
+
153
+ def intersection(box1,box2):
154
+ box1_x1,box1_y1,box1_x2,box1_y2 = box1[:4]
155
+ box2_x1,box2_y1,box2_x2,box2_y2 = box2[:4]
156
+ x1 = max(box1_x1,box2_x1)
157
+ y1 = max(box1_y1,box2_y1)
158
+ x2 = min(box1_x2,box2_x2)
159
+ y2 = min(box1_y2,box2_y2)
160
+ return (x2-x1)*(y2-y1)
161
+
162
+ def union(box1,box2):
163
+ box1_x1,box1_y1,box1_x2,box1_y2 = box1[:4]
164
+ box2_x1,box2_y1,box2_x2,box2_y2 = box2[:4]
165
+ box1_area = (box1_x2-box1_x1)*(box1_y2-box1_y1)
166
+ box2_area = (box2_x2-box2_x1)*(box2_y2-box2_y1)
167
+ return box1_area + box2_area - intersection(box1,box2)
168
+
169
+ def iou(box1,box2):
170
+ return intersection(box1,box2)/union(box1,box2)
171
+
172
+ def sigmoid(z):
173
+ return 1/(1 + np.exp(-z))
174
+
175
+ # parse segmentation mask
176
+ def get_mask(row, box, img_width, img_height):
177
+ # convert mask to image (matrix of pixels)
178
+ res_size = 56
179
+ mask = row.reshape(res_size,res_size)
180
+ mask = sigmoid(mask)
181
+ mask = (mask > 0.2).astype("uint8")*255
182
+ # crop the object defined by "box" from mask
183
+ x1,y1,x2,y2 = box
184
+ mask_x1 = round(x1/img_width*res_size)
185
+ mask_y1 = round(y1/img_height*res_size)
186
+ mask_x2 = round(x2/img_width*res_size)
187
+ mask_y2 = round(y2/img_height*res_size)
188
+ mask = mask[mask_y1:mask_y2,mask_x1:mask_x2]
189
+ # resize the cropped mask to the size of object
190
+ img_mask = Image.fromarray(mask,"L")
191
+ img_mask = img_mask.resize((round(x2-x1),round(y2-y1)))
192
+ mask = np.array(img_mask)
193
+ return mask
194
+
195
+
196
+
197
+ # calculate bounding polygon from mask
198
+ def get_polygon(mask):
199
+ contours = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
200
+ polygon = [[contour[0][0],contour[0][1]] for contour in contours[0][0]]
201
+ return polygon
202
+
203
+
204
+
205
+
206
+
207
+
208
+
209
+
210
+
211
+
212
+ #------------------VIDEO CONVERSION------------------
213
+
214
+ def convert_video_to_10fps(video_file):
215
+ """
216
+ Convert an uploaded video file to 10 FPS and return metadata
217
+
218
+ Args:
219
+ video_file: Streamlit uploaded file object
220
+
221
+ Returns:
222
+ Dictionary with video metadata and path to converted file
223
+ """
224
+ try:
225
+ # Create temporary file for the original upload
226
+ orig_tfile = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
227
+ orig_tfile.write(video_file.read())
228
+ orig_tfile.close()
229
+
230
+ # Open the original video to get properties
231
+ orig_cap = cv2.VideoCapture(orig_tfile.name)
232
+
233
+ if not orig_cap.isOpened():
234
+ return {"success": False, "error": "Could not open video file"}
235
+
236
+ orig_fps = orig_cap.get(cv2.CAP_PROP_FPS)
237
+ width = int(orig_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
238
+ height = int(orig_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
239
+ orig_total_frames = int(orig_cap.get(cv2.CAP_PROP_FRAME_COUNT))
240
+
241
+ # Calculate duration
242
+ duration_seconds = orig_total_frames / orig_fps
243
+ expected_frames = int(duration_seconds * 10) # 10 fps
244
+
245
+ # Create output temp file
246
+ converted_path = tempfile.mktemp(suffix='.mp4')
247
+
248
+ # Create VideoWriter
249
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
250
+ out = cv2.VideoWriter(converted_path, fourcc, 10, (width, height))
251
+
252
+ # Calculate frame sampling
253
+ if orig_fps <= 10:
254
+ # If original is slower than target, duplicate frames
255
+ step = 1
256
+ duplication = int(10 / orig_fps)
257
+ else:
258
+ # If original is faster, skip frames
259
+ step = orig_fps / 10
260
+ duplication = 1
261
+
262
+ # Convert the video
263
+ frame_count = 0
264
+ output_count = 0
265
+
266
+ while orig_cap.isOpened():
267
+ ret, frame = orig_cap.read()
268
+ if not ret:
269
+ break
270
+
271
+ # Determine if we should include this frame
272
+ if frame_count % step < 1: # Using modulo < 1 for floating point step values
273
+ # Write frame (possibly multiple times)
274
+ for _ in range(duplication):
275
+ out.write(frame)
276
+ output_count += 1
277
+
278
+ frame_count += 1
279
+
280
+ # Release resources
281
+ orig_cap.release()
282
+ out.release()
283
+ os.unlink(orig_tfile.name) # Delete original temp file
284
+
285
+ # Instead of returning a dictionary, read the file back into memory
286
+ with open(converted_path, "rb") as f:
287
+ video_data = f.read()
288
+
289
+ # Clean up the temporary file
290
+ os.unlink(converted_path)
291
+
292
+ # Return a file-like object
293
+ from io import BytesIO
294
+ video_io = BytesIO(video_data)
295
+ video_io.name = "converted_10fps.mp4"
296
+ return video_io
297
+
298
+ except Exception as e:
299
+ print(f"Error converting video: {e}")
300
+ return None
301
+
302
+ def recortar_imagen(image,starty_dic, axes_dic):
303
+ height, width, _ = image.shape
304
+ mask = np.zeros((height, width), dtype=np.uint8)
305
+ start_y = int((starty_dic-.02) * height)
306
+ cv2.rectangle(mask, (0, start_y), (width, height), 255, -1)
307
+ center = (width // 2, start_y)
308
+ axes = (width // 2, int(axes_dic * height))
309
+ cv2.ellipse(mask, center, axes, 0, 180, 360, 255, -1)
310
+ result = cv2.bitwise_and(image, image, mask=mask)
311
+ return result
312
+
313
+ def recortar_imagen_again(image,starty_dic, axes_dic):
314
+
315
+ try:
316
+ height, width,_ = image.shape
317
+ except :
318
+ height, width = image.shape
319
+
320
+ mask = np.zeros((height, width), dtype=np.uint8)
321
+
322
+ start_y = int(starty_dic * height)
323
+ cv2.rectangle(mask, (0, start_y), (width, height), 255, -1)
324
+ center = (width // 2, start_y)
325
+ axes = (width // 2, int(axes_dic * height))
326
+ cv2.ellipse(mask, center, axes, 0, 180, 360, 255, -1)
327
+ result = cv2.bitwise_and(image, image, mask=mask)
328
+ return result
329
+
330
+ def calculate_black_pixels_percentage(image):
331
+ """
332
+ Calcula el porcentaje de píxeles totalmente negros en la imagen.
333
+
334
+ Args:
335
+ image: Imagen cargada con cv2 (BGR o escala de grises).
336
+ is_grayscale: True si la imagen ya está en escala de gruises, False si es a color.
337
+
338
+ Returns:
339
+ float: Porcentaje de píxeles negros.
340
+ """
341
+ # Obtener dimensiones
342
+ '''image = cv2.imread(image_path)
343
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)'''
344
+ if image is None:
345
+ print(f"Error loading image")
346
+ return 0
347
+
348
+ if len(image.shape) == 3:
349
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
350
+ else:
351
+ image = image.copy()
352
+ h, w = image.shape[:2]
353
+ total_pixels = h * w
354
+
355
+ black_pixels = np.sum(image < 10)
356
+
357
+ # Calcular porcentaje
358
+ percentage = (black_pixels / total_pixels) * 100
359
+
360
+
361
+ percentage = (100.00 - float(percentage)) * .06
362
+
363
+
364
+ return percentage
365
+
366
+ def create_rectangular_roi(height, width, x1=0, y1=0, x2=None, y2=None):
367
+ if x2 is None:
368
+ x2 = width
369
+ if y2 is None:
370
+ y2 = height
371
+ mask = np.zeros((height, width), dtype=np.uint8)
372
+ cv2.rectangle(mask, (x1, y1), (x2, y2), 255, -1)
373
+ return mask
374
+
375
+ def preprocess_image(image, mask=None):
376
+ if len(image.shape) == 3:
377
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
378
+ else:
379
+ gray = image.copy()
380
+
381
+ denoised = cv2.bilateralFilter(gray, d=3, sigmaColor=20, sigmaSpace=10)
382
+ sharpened = cv2.addWeighted(denoised, 3.0, denoised, -2.0, 0)
383
+ normalized = cv2.normalize(sharpened, None, 0, 255, cv2.NORM_MINMAX)
384
+
385
+ if mask is not None:
386
+ return cv2.bitwise_and(normalized, normalized, mask=mask)
387
+ return normalized
388
+
389
+ def calculate_robust_rms_contrast(image, mask=None, bright_threshold=240):
390
+ if len(image.shape) == 3:
391
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
392
+
393
+ if mask is not None:
394
+ masked_image = image[mask > 0]
395
+ else:
396
+ masked_image = image.ravel()
397
+
398
+ if len(masked_image) == 0:
399
+ mean = np.mean(image)
400
+ std_dev = np.sqrt(np.mean((image - mean) ** 2))
401
+ else:
402
+ mask_bright = masked_image < bright_threshold
403
+ masked_image = masked_image[mask_bright]
404
+ if len(masked_image) == 0:
405
+ mean = np.mean(image)
406
+ std_dev = np.sqrt(np.mean((image - mean) ** 2))
407
+ else:
408
+ mean = np.mean(masked_image)
409
+ std_dev = np.sqrt(np.mean((masked_image - mean) ** 2))
410
+ return std_dev / 255.0
411
+
412
+ def adaptive_clahe_iterative(image, roi_mask, initial_clip_limit=1.0, max_clip_limit=10.0, iterations=20, target_rms_min=0.199, target_rms_max=0.5, bright_threshold=230):
413
+ if len(image.shape) == 3:
414
+ original_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
415
+ else:
416
+ original_gray = image.copy()
417
+
418
+ #preprocessed_image = preprocess_image(original_gray)
419
+
420
+ best_image = original_gray.copy()
421
+ best_rms = calculate_robust_rms_contrast(original_gray, roi_mask, bright_threshold)
422
+ clip_limit = initial_clip_limit
423
+
424
+ for i in range(iterations):
425
+ clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8))
426
+ current_image = clahe.apply(original_gray)
427
+
428
+ rms_contrast = calculate_robust_rms_contrast(current_image, roi_mask, bright_threshold)
429
+
430
+ if target_rms_min <= rms_contrast <= target_rms_max:
431
+ return current_image
432
+ if rms_contrast > best_rms:
433
+ best_rms = rms_contrast
434
+ best_image = current_image.copy()
435
+ if rms_contrast > target_rms_max:
436
+ clip_limit = min(clip_limit, 1.0)
437
+ else:
438
+ clip_limit = min(initial_clip_limit + (i * 0.5), max_clip_limit)
439
+
440
+ return best_image
441
+
442
+ def adaptive_edge_detection(imagen, min_edge_percentage=5.5, max_edge_percentage=6.5, target_percentage=6.0, max_attempts=5,mode="Default"):
443
+ """
444
+ Detecta bordes con ajuste progresivo de parámetros hasta lograr un porcentaje óptimo
445
+ de píxeles de borde en la imagen - optimizado con operaciones vectorizadas.
446
+ """
447
+ # Read image
448
+ original = imagen
449
+ if original is None:
450
+ print(f"Error loading image")
451
+ return None, None, None, None
452
+
453
+ # Convert to grayscale
454
+ gray = original
455
+
456
+ # Calculate total pixels for percentage calculation
457
+ total_pixels = gray.shape[0] * gray.shape[1]
458
+ min_edge_pixels = int((min_edge_percentage / 100) * total_pixels)
459
+ max_edge_pixels = int((max_edge_percentage / 100) * total_pixels)
460
+ target_edge_pixels = int((target_percentage / 100) * total_pixels)
461
+
462
+ # Initial parameters - ajustados para conseguir un rango alrededor del 6% de bordes
463
+ clip_limits = [1]
464
+ grid_sizes = [(2, 2)]
465
+ # Empezamos con umbrales más altos para restringir la cantidad de bordes
466
+ canny_thresholds = [(55, 170), (45, 160), (35, 150), (25, 140), (20, 130),(20, 130),(20, 130)]
467
+
468
+ best_edges = None
469
+ best_enhanced = None
470
+ best_config = None
471
+ best_edge_score = float('inf') # Inicializamos con un valor alto
472
+ edge_percentage = 0
473
+
474
+
475
+ # Try progressively more aggressive parameters
476
+ for attempt in range(max_attempts):
477
+ # Get parameters for this attempt
478
+ clip_limit = clip_limits[attempt]
479
+ grid_size = grid_sizes[attempt]
480
+ low_threshold, high_threshold = canny_thresholds[attempt]
481
+
482
+ if edge_percentage <= max_edge_percentage:
483
+ clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=grid_size)
484
+ elif edge_count > max_edge_percentage:
485
+ # Si hay demasiados bordes, aplicamos un CLAHE más fuerte
486
+ clahe = cv2.createCLAHE(clipLimit=1, tileGridSize=grid_size)
487
+
488
+ enhanced = clahe.apply(gray)
489
+
490
+
491
+ #print("denoised shape:", denoised.shape, "dtype:", denoised.dtype)
492
+ # Apply noise reduction for higher attempts
493
+ '''if attempt >= 2:
494
+ enhanced = cv2.bilateralFilter(enhanced, 5, 100, 100)'''
495
+
496
+
497
+
498
+ if mode == "Default":
499
+ denoised = cv2.bilateralFilter(enhanced, d=5, sigmaColor=200, sigmaSpace=200)
500
+ median_intensity = np.median(denoised)
501
+ low_threshold = max(20, (1.0 - .3) * median_intensity)
502
+ high_threshold = max(80, (1.0 + .8) * median_intensity)
503
+ elif mode == "Low ilumination":
504
+ denoised = cv2.bilateralFilter(enhanced, d=5, sigmaColor=200, sigmaSpace=200)
505
+ median_intensity = np.median(denoised)
506
+ low_threshold = max(20, (1.0 - .3) * median_intensity)
507
+ high_threshold = max(80, (1.0 + .8) * median_intensity)
508
+ # Edge detection
509
+
510
+ edges = cv2.Canny(denoised, low_threshold, high_threshold)
511
+ std_intensity = np.std(edges)
512
+
513
+ # Reducir ruido con operaciones morfológicas - vectorizado
514
+ kernel = np.ones((1, 1), np.uint8)
515
+ edges = cv2.morphologyEx(
516
+ edges,
517
+ cv2.MORPH_OPEN,
518
+ kernel,
519
+ iterations=0 if std_intensity < 60 else 1 # Más iteraciones si hay más ruido
520
+ )
521
+
522
+
523
+ # Count edge pixels - vectorizado usando np.count_nonzero
524
+ edge_count = np.count_nonzero(edges)
525
+ edge_percentage = (edge_count / total_pixels) * 100
526
+
527
+ # Calcular distancia al objetivo - vectorizado
528
+ edge_score = abs(edge_count - target_edge_pixels)
529
+
530
+ # Record the best attempt (closest to target percentage)
531
+ if edge_score < best_edge_score:
532
+ best_edge_score = edge_score
533
+ best_edges = edges.copy() # Hacer copia para evitar sobrescrituras
534
+ best_enhanced = enhanced.copy()
535
+ best_config = {
536
+ 'attempt': attempt + 1,
537
+ 'clip_limit': clip_limit,
538
+ 'grid_size': grid_size,
539
+ 'canny_thresholds': (low_threshold, high_threshold),
540
+ 'edge_pixels': edge_count,
541
+ 'edge_percentage': edge_percentage
542
+ }
543
+
544
+ # Salida temprana si estamos cerca del objetivo
545
+ if abs(edge_percentage - target_percentage) < 0.1: # Within 0.2% of target
546
+ break
547
+
548
+ print(f"Mejor intento: {best_config['attempt']}, porcentaje de bordes: {edge_percentage:.2f}%")
549
+ return best_enhanced, best_edges, original, best_config
utils/model_handler.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ from typing import List, Dict
4
+ from PIL import Image
5
+ import cv2
6
+ import onnxruntime as ort
7
+ from utils.helper import BASE_DIR
8
+ from pathlib import Path
9
+
10
+ def denormalize_angles(normalized_angles):
11
+ """
12
+ Convierte ángulos normalizados [-1,1] a grados [-180,180]
13
+ """
14
+ return (normalized_angles + 1) / 2 * (180 - (-180)) + (-180)
15
+
16
+ def preprocess_image_exactly_like_pytorch(image_input):
17
+ """
18
+ Preprocesa una imagen de OpenCV (como adjusted_edges)
19
+ para usarla con modelos ONNX.
20
+
21
+ Args:
22
+ image_input: Array NumPy de OpenCV (imagen de bordes, binaria, etc.)
23
+
24
+ Returns:
25
+ Array NumPy listo para inferencia con ONNX
26
+ """
27
+ # Verificar que la entrada no sea None
28
+ if image_input is None:
29
+ raise ValueError("Received None as image input")
30
+
31
+ # Asegurar que la imagen es un array NumPy
32
+ if not isinstance(image_input, np.ndarray):
33
+ raise TypeError(f"Expected NumPy array, got {type(image_input)}")
34
+
35
+ # Verificar que la imagen tiene dimensiones válidas
36
+ if len(image_input.shape) < 2:
37
+ raise ValueError(f"Invalid image shape: {image_input.shape}")
38
+
39
+ # Copia para no modificar la original
40
+ img_copy = image_input.copy()
41
+
42
+ # Si es una imagen de bordes o binaria, normalmente tiene valores 0 y 255
43
+ # o 0 y 1. Asegurarse de que está en el rango [0, 255]
44
+ if img_copy.dtype != np.uint8:
45
+ if np.max(img_copy) <= 1.0:
46
+ # Si está en rango [0, 1], convertir a [0, 255]
47
+ img_copy = (img_copy * 255).astype(np.uint8)
48
+ else:
49
+ # De otro modo, simplemente convertir a uint8
50
+ img_copy = img_copy.astype(np.uint8)
51
+
52
+ # Para imágenes de bordes o binarias, asegurar que tenemos valores claros
53
+ # (si todos los valores son muy bajos, puede que no se vea nada)
54
+ if np.mean(img_copy) < 10 and np.max(img_copy) > 0:
55
+ # Estirar el contraste para mejor visualización
56
+ img_copy = cv2.normalize(img_copy, None, 0, 255, cv2.NORM_MINMAX)
57
+
58
+ # Asegurar que la imagen es de un solo canal (escala de grises)
59
+ if len(img_copy.shape) == 3:
60
+ if img_copy.shape[2] == 3:
61
+ # Convertir imagen BGR a escala de grises
62
+ img_copy = cv2.cvtColor(img_copy, cv2.COLOR_BGR2GRAY)
63
+ else:
64
+ # Tomar solo el primer canal
65
+ img_copy = img_copy[:, :, 0]
66
+
67
+ try:
68
+ # Convertir de NumPy array a PIL Image
69
+ img_pil = Image.fromarray(img_copy)
70
+
71
+ # Redimensionar con PIL
72
+ img_resized = img_pil.resize((224, 224), Image.BILINEAR)
73
+
74
+ # Convertir a numpy array
75
+ img_np = np.array(img_resized, dtype=np.float32)
76
+
77
+ # Normalizar de [0,255] a [0,1]
78
+ img_np = img_np / 255.0
79
+
80
+ # Normalizar con mean=0.5, std=0.5 (como en PyTorch)
81
+ img_np = (img_np - 0.5) / 0.5
82
+
83
+ # Reformatear para ONNX [batch_size, channels, height, width]
84
+ img_np = np.expand_dims(img_np, axis=0) # Añadir dimensión de canal
85
+ img_np = np.expand_dims(img_np, axis=0) # Añadir dimensión de batch
86
+
87
+ return img_np
88
+ except Exception as e:
89
+ print(f"Error processing image: {e}")
90
+ print(f"Image shape: {image_input.shape}, dtype: {image_input.dtype}")
91
+ print(f"Min value: {np.min(image_input)}, Max value: {np.max(image_input)}")
92
+ raise
93
+
94
+ def correct_outlier_angles(df, window_size=5, std_threshold=3.0, max_diff_threshold=80.0):
95
+
96
+ angles = df['steering_angle'].values
97
+ corrected_angles = angles.copy()
98
+
99
+ for i in range(len(angles)):
100
+ if i < window_size // 2 or i >= len(angles) - window_size // 2: # Evitar bordes
101
+ continue
102
+
103
+ # Definir ventana local
104
+ start_idx = max(0, i - window_size // 2)
105
+ end_idx = min(len(angles), i + window_size // 2 + 1)
106
+ window = angles[start_idx:end_idx]
107
+
108
+ # Calcular estadísticas locales incluyendo el valor actual
109
+ curr_angle = angles[i]
110
+ local_mean = np.mean(window)
111
+ local_std = np.std(window) if len(window) > 1 else 0
112
+
113
+ # Calcular distancia angular mínima considerando el rango cíclico (-180° a 180°)
114
+ def angular_distance(a, b):
115
+ diff = abs(a - b)
116
+ return min(diff, 360 - diff) if diff > 180 else diff
117
+
118
+ diff_from_mean = angular_distance(curr_angle, local_mean)
119
+
120
+ # Detectar outlier
121
+ is_outlier = (diff_from_mean > std_threshold * local_std) or (diff_from_mean > max_diff_threshold)
122
+
123
+ if is_outlier:
124
+ print(i, curr_angle, local_mean, local_std, diff_from_mean)
125
+ # Excluir el valor actual del cálculo del promedio de corrección
126
+ corrected_window = np.delete(window, i - start_idx)
127
+ if len(corrected_window) > 0:
128
+ corrected_mean = np.mean(corrected_window)
129
+ # Ajustar el ángulo corregido al rango cíclico más cercano
130
+ diff_to_corrected = angular_distance(curr_angle, corrected_mean)
131
+ if diff_to_corrected > 180:
132
+ corrected_angles[i] = corrected_mean - 360 if corrected_mean > 0 else corrected_mean + 360
133
+ else:
134
+ corrected_angles[i] = corrected_mean
135
+
136
+ # Crear nuevo DataFrame
137
+ corrected_df = df.copy()
138
+ corrected_df['steering_angle'] = corrected_angles
139
+ return corrected_df
140
+
141
+ class ModelHandler:
142
+ def __init__(self):
143
+ # Placeholder for actual model loading
144
+ self.current_model = None
145
+ self.current_model_name = None
146
+ self.fps = None
147
+ self.available_models = {
148
+ "F1 Steering Angle Detection": Path(BASE_DIR) / "models" / "f1-steering-angle-model.onnx",
149
+ "Track Position Analysis": "position_model",
150
+ "Driver Behavior Analysis": "behavior_model"
151
+ }
152
+
153
+ def _load_model_if_needed(self, model_name: str):
154
+ """Load the model only if it's not already loaded or if it's different"""
155
+ if self.current_model is None or self.current_model_name != model_name:
156
+ print(f"Loading model: {model_name}") # Debugging info
157
+ self.current_model = ort.InferenceSession(self.available_models[model_name])
158
+ self.current_model_name = model_name
159
+
160
+ def process_frames(self, frames: List[np.ndarray], model_name: str) -> Dict:
161
+ """Process frames through selected model with efficient batch processing"""
162
+ if not frames:
163
+ return []
164
+
165
+ # Load model only once
166
+ self._load_model_if_needed(model_name)
167
+
168
+ # Get input name once
169
+ input_name = self.current_model.get_inputs()[0].name
170
+
171
+ results = []
172
+
173
+ # Define optimal batch size - ajusta según tu hardware
174
+ BATCH_SIZE = 16
175
+ index = 0
176
+ # Process frames in batches
177
+ for batch_start in range(0, len(frames), BATCH_SIZE):
178
+ # Get current batch
179
+ batch_end = min(batch_start + BATCH_SIZE, len(frames))
180
+ current_batch = frames[batch_start:batch_end]
181
+ batch_inputs = []
182
+
183
+ # Pre-process all frames in the current batch
184
+ for frame in current_batch:
185
+ try:
186
+ # Procesar imagen pero mantener en formato que permita agrupación
187
+
188
+ cv2.imwrite(r"img_test/"+str(index)+".jpg", frame)
189
+ index= index+1
190
+ processed_input = preprocess_image_exactly_like_pytorch(frame)
191
+ batch_inputs.append(processed_input)
192
+ except Exception as e:
193
+ print(f"Error preprocessing frame: {e}")
194
+ # Usar un tensor vacío del mismo tamaño como reemplazo
195
+ empty_tensor = np.zeros((1, 1, 224, 224), dtype=np.float32)
196
+ batch_inputs.append(empty_tensor)
197
+
198
+ try:
199
+ # Combinar todos los inputs pre-procesados en un solo lote grande
200
+ # Cada input tiene forma [1, 1, 224, 224], los concatenamos en la dimensión 0
201
+ batched_input = np.vstack(batch_inputs)
202
+
203
+ # Ejecutar inferencia sobre todo el lote a la vez
204
+ ort_inputs = {input_name: batched_input}
205
+ ort_outputs = self.current_model.run(None, ort_inputs)
206
+
207
+ # Procesar resultados por lotes
208
+ for i in range(len(current_batch)):
209
+ frame_idx = batch_start + i +1
210
+ predicted_angle_normalized = ort_outputs[0][i][0]
211
+ angle = denormalize_angles(predicted_angle_normalized)
212
+ confidence = np.random.uniform(0.7, 0.99)
213
+
214
+ results.append({
215
+ 'frame_number': frame_idx,
216
+ 'steering_angle': angle,
217
+ })
218
+
219
+ except Exception as e:
220
+ print(f"Error in batch processing: {e}")
221
+ # Si falla el procesamiento por lotes, volver a procesar individualmente
222
+ for i, frame in enumerate(current_batch):
223
+ frame_idx = batch_start + i +1
224
+ try:
225
+ input_data = preprocess_image_exactly_like_pytorch(frame)
226
+ ort_inputs = {input_name: input_data}
227
+ ort_outputs = self.current_model.run(None, ort_inputs)
228
+
229
+ predicted_angle_normalized = ort_outputs[0][0][0]
230
+ angle = denormalize_angles(predicted_angle_normalized)
231
+ confidence = np.random.uniform(0.7, 0.99)
232
+
233
+ results.append({
234
+ 'frame_number': frame_idx,
235
+ 'steering_angle': angle
236
+ })
237
+ except Exception as sub_e:
238
+ print(f"Error processing individual frame {frame_idx}: {sub_e}")
239
+ # Añadir un resultado con valores predeterminados
240
+ results.append({
241
+ 'frame_number': frame_idx,
242
+ 'steering_angle': 0.0
243
+ })
244
+
245
+ return results
246
+
247
+ def export_results(self, results: Dict) -> pd.DataFrame:
248
+ """Convert results to pandas DataFrame for export"""
249
+ df = pd.DataFrame(results)
250
+ df['time'] = round(df['frame_number'] / self.fps,3)
251
+
252
+ df = correct_outlier_angles(df, window_size=3, std_threshold=100, max_diff_threshold=15.0)
253
+ df = correct_outlier_angles(df, window_size=3, std_threshold=100, max_diff_threshold=15.0)
254
+ df = correct_outlier_angles(df, window_size=3, std_threshold=100, max_diff_threshold=15.0)
255
+ df = correct_outlier_angles(df, window_size=3, std_threshold=100, max_diff_threshold=15.0)
256
+
257
+
258
+ return df
utils/ui_components.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ from typing import Tuple
4
+ import plotly.graph_objects as go
5
+
6
+
7
+ def create_header():
8
+ """Create the application header"""
9
+ st.markdown("""
10
+ <div class='custom-header'>
11
+ F1 Video Analysis Platform
12
+ <div style='font-size: 0.5em; font-weight: 400; margin-top: 10px;'>
13
+ Precision Telemetry & Analysis
14
+ </div>
15
+ </div>
16
+ """, unsafe_allow_html=True)
17
+
18
+ def create_upload_section():
19
+ """Create the video upload section"""
20
+ st.markdown("<div class='glassmorphic-container'>", unsafe_allow_html=True)
21
+ uploaded_file = st.file_uploader(
22
+ "Upload video file",
23
+ type=['mp4', 'avi', 'mov'],
24
+ help="Upload onboard camera footage for analysis"
25
+ )
26
+ st.markdown("</div>", unsafe_allow_html=True)
27
+ return uploaded_file
28
+
29
+ def create_frame_selector(total_frames: int) -> Tuple[int, int]:
30
+ """Create frame selection controls with slider and +/- buttons"""
31
+ st.markdown("<div class='glassmorphic-container'>", unsafe_allow_html=True)
32
+
33
+
34
+ # Create a slider for frame range selection
35
+ start_frame, end_frame = st.select_slider(
36
+ "Select Frame Range",
37
+ options=range(0, total_frames),
38
+ value=(0, total_frames-1),
39
+ format_func=lambda x: f"Frame {x}"
40
+ )
41
+
42
+
43
+
44
+
45
+ st.markdown("</div>", unsafe_allow_html=True)
46
+ return start_frame, end_frame
47
+
48
+ def display_results(df: pd.DataFrame):
49
+
50
+
51
+ csv = df.to_csv(index=False)
52
+ st.markdown("")
53
+
54
+
55
+ st.markdown("#### Download Results 📥")
56
+
57
+ st.download_button(
58
+ label="Download Results (CSV)",
59
+ data=csv,
60
+ file_name="f1_analysis_results.csv",
61
+ mime="text/csv"
62
+ )
63
+ st.markdown("")
64
+
65
+ def create_line_chart(df: pd.DataFrame):
66
+ """Create a line chart with the given DataFrame"""
67
+ fig = go.Figure()
68
+
69
+ # Add the main steering angle line
70
+ fig.add_trace(go.Scatter(
71
+ x=df['time'],
72
+ y=df['steering_angle'],
73
+ mode='lines',
74
+ name='Steering Angle',
75
+ line=dict(color='white', width=1),
76
+ hovertemplate='<b>Time:</b> %{x}<br><b>Angle:</b> %{y:.2f}°<extra></extra>'
77
+ ))
78
+
79
+ # Add reference lines for straight, full right, and full left
80
+ fig.add_shape(type="line",
81
+ x0=df['time'].min(), y0=0, x1=df['time'].max(), y1=0,
82
+ line=dict(color="red", width=2, dash="solid"),
83
+ name="Straight (0°)"
84
+ )
85
+
86
+ fig.add_shape(type="line",
87
+ x0=df['time'].min(), y0=90, x1=df['time'].max(), y1=90,
88
+ line=dict(color="red", width=2, dash="dash"),
89
+ name="Full Right (90°)"
90
+ )
91
+
92
+ fig.add_shape(type="line",
93
+ x0=df['time'].min(), y0=-90, x1=df['time'].max(), y1=-90,
94
+ line=dict(color="red", width=2, dash="dash"),
95
+ name="Full Left (-90°)"
96
+ )
97
+
98
+ # Añadir etiquetas a las líneas de referencia
99
+ fig.add_annotation(x=df['time'].min(), y=0,
100
+ text="Straight (0°)",
101
+ showarrow=True,
102
+ arrowhead=1,
103
+ ax=-40,
104
+ ay=-20
105
+ )
106
+
107
+ fig.add_annotation(x=df['time'].min(), y=90,
108
+ text="Full Right (90°)",
109
+ showarrow=True,
110
+ arrowhead=1,
111
+ ax=-40,
112
+ ay=-20
113
+ )
114
+
115
+ fig.add_annotation(x=df['time'].min(), y=-90,
116
+ text="Full Left (-90°)",
117
+ showarrow=True,
118
+ arrowhead=1,
119
+ ax=-40,
120
+ ay=20
121
+ )
122
+
123
+ # Configure layout
124
+ fig.update_layout(
125
+ title="Steering Angle Over Time",
126
+ xaxis_title="Time (seconds)",
127
+ yaxis_title="Steering Angle (degrees)",
128
+ yaxis=dict(range=[-180, 180]),
129
+ hovermode="x unified",
130
+ legend_title="Legend",
131
+ template="plotly_white",
132
+ height=500,
133
+ margin=dict(l=20, r=20, t=40, b=20)
134
+ )
135
+
136
+ # Add a light gray range for "straight enough" (-10° to 10°)
137
+ fig.add_shape(type="rect",
138
+ x0=df['time'].min(), y0=-10,
139
+ x1=df['time'].max(), y1=10,
140
+ fillcolor="lightgray",
141
+ opacity=0.2,
142
+ layer="below",
143
+ line_width=0,
144
+ )
145
+
146
+ # Display the plot in Streamlit
147
+ st.plotly_chart(fig, use_container_width=True)
utils/video_processor.py ADDED
@@ -0,0 +1,1080 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from typing import List, Tuple
4
+ import tempfile
5
+ import time
6
+ import functools
7
+ from collections import defaultdict
8
+ import onnxruntime as ort
9
+ from utils.model_handler import ModelHandler
10
+ from utils.helper import (
11
+ preprocess_image_tensor,
12
+ postprocess_outputs,
13
+ recortar_imagen,
14
+ recortar_imagen_again,
15
+ calculate_black_pixels_percentage,
16
+ adaptive_edge_detection,
17
+
18
+ )
19
+ from collections import OrderedDict
20
+ from concurrent.futures import ThreadPoolExecutor
21
+ from pathlib import Path
22
+ from utils.helper import BASE_DIR
23
+
24
+ class Profiler:
25
+ """Clase para trackear el tiempo de ejecución de las funciones"""
26
+
27
+ _instance = None
28
+
29
+ def __new__(cls):
30
+ if cls._instance is None:
31
+ cls._instance = super(Profiler, cls).__new__(cls)
32
+ cls._instance.function_times = defaultdict(list)
33
+ cls._instance.call_counts = defaultdict(int)
34
+ return cls._instance
35
+
36
+ def track_time(self, func):
37
+ @functools.wraps(func)
38
+ def wrapper(*args, **kwargs):
39
+ start_time = time.time()
40
+ result = func(*args, **kwargs)
41
+ end_time = time.time()
42
+ elapsed = end_time - start_time
43
+
44
+ self.function_times[func.__name__].append(elapsed)
45
+ self.call_counts[func.__name__] += 1
46
+
47
+ return result
48
+ return wrapper
49
+
50
+ def print_stats(self):
51
+ print("\n===== FUNCIÓN TIMING STATS =====")
52
+ print(f"{'FUNCIÓN':<30} {'LLAMADAS':<10} {'TOTAL (s)':<15} {'PROMEDIO (s)':<15} {'% TIEMPO':<10}")
53
+
54
+ total_time = sum(sum(times) for times in self.function_times.values())
55
+
56
+ # Ordenar por tiempo total (descendente)
57
+ sorted_funcs = sorted(
58
+ self.function_times.items(),
59
+ key=lambda x: sum(x[1]),
60
+ reverse=True
61
+ )
62
+
63
+ for func_name, times in sorted_funcs:
64
+ total = sum(times)
65
+ avg = total / len(times) if times else 0
66
+ calls = self.call_counts[func_name]
67
+ percent = (total / total_time * 100) if total_time > 0 else 0
68
+
69
+ print(f"{func_name:<30} {calls:<10} {total:<15.4f} {avg:<15.4f} {percent:<10.2f}%")
70
+
71
+ print(f"\nTiempo total de procesamiento: {total_time:.4f} segundos")
72
+ print("================================")
73
+
74
+ def get_stats_dict(self):
75
+ """Devuelve las estadísticas como un diccionario para mostrar en Streamlit"""
76
+ stats = []
77
+ total_time = sum(sum(times) for times in self.function_times.values())
78
+
79
+ for func_name, times in self.function_times.items():
80
+ total = sum(times)
81
+ avg = total / len(times) if times else 0
82
+ calls = self.call_counts[func_name]
83
+ percent = (total / total_time * 100) if total_time > 0 else 0
84
+
85
+ stats.append({
86
+ 'función': func_name,
87
+ 'llamadas': calls,
88
+ 'tiempo_total': total,
89
+ 'tiempo_promedio': avg,
90
+ 'porcentaje': percent
91
+ })
92
+
93
+ # Ordenar por porcentaje de tiempo
94
+ stats.sort(key=lambda x: x['porcentaje'], reverse=True)
95
+ return stats, total_time
96
+
97
+ def reset(self):
98
+ """Reiniciar las estadísticas"""
99
+ self.function_times.clear()
100
+ self.call_counts.clear()
101
+
102
+ profiler = Profiler()
103
+
104
+
105
+ class VideoProcessor:
106
+ def __init__(self):
107
+ self.cap = None
108
+ self.total_frames = 0
109
+ self.fps = 0
110
+ self.target_fps = 10
111
+ self.driver_crop_type = "Verstappen 2025" # Default driver crop type
112
+ self.load_crop_variables(self.driver_crop_type)
113
+ #self.yolo_model = YOLO("models/best.pt")
114
+ self.model = ort.InferenceSession(Path(BASE_DIR) / "models" / "best-224.onnx")
115
+ self.input_shape = (224, 224) # Match imgsz=224 from your original code
116
+ self.conf_thres = 0.5 # Confidence threshold
117
+ self.iou_thres = 0.5 # IoU threshold for NMS
118
+ self.frame_count = 0
119
+ self.mode = "Default" # Default to False, can be set later
120
+
121
+
122
+ self.frame_cache = OrderedDict()
123
+ self.frame_cache_size = 50 # Reduced size to conserve memory
124
+ self.last_position = -1
125
+
126
+ self.frames_list_end = {}
127
+ self.frames_list_start = {}
128
+
129
+ def clear_cache(self):
130
+ """Clear the frame cache to free memory."""
131
+ self.frame_cache.clear()
132
+
133
+ @profiler.track_time
134
+ def load_crop_variables(self,driver_crop_type):
135
+ """
136
+ Cargar variables de recorte según el tipo de conductor
137
+ """
138
+ driver_config = {
139
+ "Albon 2024": {
140
+ "starty": 0.55,
141
+ "axes": 0.39,
142
+ "y_start": 0.53,
143
+ "x_center": 0.59
144
+ },
145
+ "Albon 2025": {
146
+ "starty": 0.67,
147
+ "axes": 0.42,
148
+ "y_start": 0.53,
149
+ "x_center": 0.59
150
+ },
151
+ "Alonso 2024": {
152
+ "starty": 0.5,
153
+ "axes": 0.29,
154
+ "y_start": 0.53,
155
+ "x_center": 0.56
156
+ },
157
+ "Alonso 2025": {
158
+ "starty": 0.8,
159
+ "axes": 0.5,
160
+ "y_start": 0.53,
161
+ "x_center": 0.572
162
+ },
163
+ "Bortoleto 2025": {
164
+ "starty": 0.6,
165
+ "axes": 0.4,
166
+ "y_start": 0.53,
167
+ "x_center": 0.572
168
+ },
169
+ "bottas": {
170
+ "starty": 0.67,
171
+ "axes": 0.43,
172
+ "y_start": 0.53,
173
+ "x_center": 0.574
174
+ },
175
+ "colapinto": {
176
+ "starty": 0.52,
177
+ "axes": 0.33,
178
+ "y_start": 0.53,
179
+ "x_center": 0.594
180
+ },
181
+ "Colapinto 2025": {
182
+ "starty": 0.54,
183
+ "axes": 0.4,
184
+ "y_start": 0.53,
185
+ "x_center": 0.58
186
+ },
187
+ "Gasly 2025": {
188
+ "starty": 0.57,
189
+ "axes": 0.35,
190
+ "y_start": 0.53,
191
+ "x_center": 0.58
192
+ },
193
+ "Hulk 2025": {
194
+ "starty": 0.73,
195
+ "axes": 0.3,
196
+ "y_start": 0.53,
197
+ "x_center": 0.548
198
+ },
199
+ "Lawson 2025": {
200
+ "starty": 0.68,
201
+ "axes": 0.42,
202
+ "y_start": 0.53,
203
+ "x_center": 0.555
204
+ },
205
+ "Ocon 2025": {
206
+ "starty": 0.65,
207
+ "axes": 0.42,
208
+ "y_start": 0.53,
209
+ "x_center": 0.585
210
+ },
211
+ "Sainz 2025": {
212
+ "starty": 0.77,
213
+ "axes": 0.42,
214
+ "y_start": 0.53,
215
+ "x_center": 0.57
216
+ },
217
+ "Stroll 2025": {
218
+ "starty": 0.6,
219
+ "axes": 0.45,
220
+ "y_start": 0.53,
221
+ "x_center": 0.565
222
+ },
223
+ "Bearman 2025": {
224
+ "starty": 0.72,
225
+ "axes": 0.45,
226
+ "y_start": 0.53,
227
+ "x_center": 0.58
228
+ },
229
+ "Hadjar 2025": {
230
+ "starty": 0.7,
231
+ "axes": 0.42,
232
+ "y_start": 0.53,
233
+ "x_center": 0.57
234
+ },
235
+ "hamilton-arabia": {
236
+ "starty": 0.908,
237
+ "axes": 0.4,
238
+ "y_start": 0.53,
239
+ "x_center": 0.554
240
+ },
241
+ "Hamilton 2025": {
242
+ "starty": 0.59,
243
+ "axes": 0.4,
244
+ "y_start": 0.53,
245
+ "x_center": 0.573
246
+ },
247
+
248
+ "hamilton-texas": {
249
+ "starty": 0.7,
250
+ "axes": 0.38,
251
+ "y_start": 0.53,
252
+ "x_center": 0.6
253
+ },
254
+ "leclerc-china": {
255
+ "starty": 0.6,
256
+ "axes": 0.36,
257
+ "y_start": 0.53,
258
+ "x_center": 0.58
259
+ },
260
+
261
+ "Leclerc 2025": {
262
+ "starty": 0.65,
263
+ "axes": 0.45,
264
+ "y_start": 0.53,
265
+ "x_center": 0.575
266
+ },
267
+ "magnussen": {
268
+ "starty": 0.6,
269
+ "axes": 0.34,
270
+ "y_start": 0.53,
271
+ "x_center": 0.58
272
+ },
273
+ "norris-arabia": {
274
+ "starty": 0.7,
275
+ "axes": 0.3,
276
+ "y_start": 0.53,
277
+ "x_center": 0.58
278
+ },
279
+ "norris-texas": {
280
+ "starty": 0.7,
281
+ "axes": 0.3,
282
+ "y_start": 0.53,
283
+ "x_center": 0.58
284
+ },
285
+ "Norris 2025": {
286
+ "starty": 0.79,
287
+ "axes": 0.6,
288
+ "y_start": 0.53,
289
+ "x_center": 0.571,
290
+ "helmet_height_ratio": 0.5
291
+ },
292
+ "ocon": {
293
+ "starty": 0.75,
294
+ "axes": 0.35,
295
+ "y_start": 0.53,
296
+ "x_center": 0.555
297
+ },
298
+ "piastri-azerbaiya": {
299
+ "starty": 0.65,
300
+ "axes": 0.34,
301
+ "y_start": 0.53,
302
+ "x_center": 0.549
303
+ },
304
+ "piastri-singapure": {
305
+ "starty": 0.65,
306
+ "axes": 0.34,
307
+ "y_start": 0.53,
308
+ "x_center": 0.549
309
+ },
310
+ 'Piastri 2025': {
311
+ "starty": 0.93,
312
+ "axes": 0.59,
313
+ "y_start": 0.53,
314
+ "x_center": 0.573,
315
+ "helmet_height_ratio": 0.3
316
+ },
317
+ "russel-singapure": {
318
+ "starty": 0.63,
319
+ "axes": 0.44,
320
+ "y_start": 0.53,
321
+ "x_center": 0.56
322
+ },
323
+ "Russell 2025": {
324
+ "starty": 0.95,
325
+ "axes": 0.65,
326
+ "y_start": 0.53,
327
+ "x_center": 0.574,
328
+ "helmet_height_ratio": 0.35
329
+ },
330
+ "sainz": {
331
+ "starty": 0.57,
332
+ "axes": 0.32,
333
+ "y_start": 0.53,
334
+ "x_center": 0.59
335
+ },
336
+
337
+
338
+ "Tsunoda 2025":{
339
+ "starty": 0.92,
340
+ "axes": 0.55,
341
+ "y_start": 0.53,
342
+ "x_center": 0.58,
343
+ "helmet_height_ratio": 0.25
344
+ },
345
+ "verstappen_china": {
346
+ "starty": 0.7,
347
+ "axes": 0.42,
348
+ "y_start": 0.53,
349
+ "x_center": 0.57
350
+ },
351
+ "Verstappen 2025": {
352
+ "starty": 0.7,
353
+ "axes": 0.42,
354
+ "y_start": 0.53,
355
+ "x_center": 0.57,
356
+ "helmet_height_ratio": 0.4
357
+ },
358
+ "vertappen": {
359
+ "starty": 0.7,
360
+ "axes": 0.42,
361
+ "y_start": 0.53,
362
+ "x_center": 0.57
363
+ },
364
+ "verstappen-arabia": {
365
+ "starty": 0.95,
366
+ "axes": 0.4,
367
+ "y_start": 0.53,
368
+ "x_center": 0.565
369
+ },
370
+ "yuki": {
371
+ "starty": 0.64,
372
+ "axes": 0.37,
373
+ "y_start": 0.53,
374
+ "x_center": 0.585
375
+ },
376
+ "Antonelli 2025":
377
+ {
378
+ "starty": 0.97,
379
+ "axes": 0.65,
380
+ "y_start": 0.53,
381
+ "x_center": 0.595,
382
+ "helmet_height_ratio": 0.5
383
+ }}
384
+
385
+ print(f"Driver crop type: {self.driver_crop_type}")
386
+ self.driver_crop_type = driver_crop_type
387
+ self.starty = driver_config[self.driver_crop_type]["starty"]
388
+ self.axes = driver_config[self.driver_crop_type]["axes"]
389
+
390
+ self.y_start = driver_config[self.driver_crop_type]["y_start"]
391
+ self.x_center = driver_config[self.driver_crop_type]["x_center"]
392
+ self.helmet_height_ratio = driver_config[self.driver_crop_type]["helmet_height_ratio"] if "helmet_height_ratio" in driver_config[self.driver_crop_type] else 0.5
393
+
394
+ def clean_up(self):
395
+ """Release video capture and clear cache."""
396
+
397
+ self.clear_cache()
398
+ self.frames_list_start = {}
399
+ self.frames_list_end = {}
400
+ self.video_path = None
401
+ self.frame_count = 0
402
+ print("VideoProcessor cleaned up.")
403
+
404
+ @profiler.track_time
405
+ def load_video(self, video_file) -> bool:
406
+ """Load video file and get basic information"""
407
+ tfile = tempfile.NamedTemporaryFile(delete=True)
408
+ tfile.write(video_file.read())
409
+
410
+ # Guardar ruta para posibles reinicios
411
+ self.video_path = tfile.name
412
+
413
+ self.cap = cv2.VideoCapture(tfile.name)
414
+ self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
415
+ self.fps = int(self.cap.get(cv2.CAP_PROP_FPS))
416
+ print(f"FPS: {self.fps}")
417
+ print(f"Total frames: {self.total_frames}")
418
+
419
+
420
+ #self.frames_list_start = [None] * self.total_frames # prealocamos
421
+ #self.frames_list_end = [None] * self.total_frames # prealocamos
422
+ self.start_frame_min = 0
423
+ self.start_frame_max = min(100,int(self.total_frames * 0.1)) # 10% del total
424
+
425
+ if self.total_frames > 500:
426
+ self.end_frame_min = int(self.total_frames-100) # 90% del total
427
+ else:
428
+ self.end_frame_min = int(self.total_frames * 0.9)
429
+ self.end_frame_max = self.total_frames - 1
430
+ i = 0
431
+ print(len(self.frames_list_start), len(self.frames_list_end))
432
+
433
+ if self.frames_list_end == {}:
434
+
435
+
436
+
437
+ current_frame_num = self.start_frame_min
438
+ cap_thread = cv2.VideoCapture(self.video_path)
439
+ cap_thread.set(cv2.CAP_PROP_POS_FRAMES, float(self.start_frame_min))
440
+
441
+ while current_frame_num <= self.start_frame_max:
442
+ ret, frame = cap_thread.read()
443
+ if not ret:
444
+ # print(f"Advertencia: No se pudo leer el frame {current_frame_num} de {video_path}.")
445
+ break
446
+
447
+ processed_frame = cv2.cvtColor(cv2.resize(frame, (256, 144), interpolation=cv2.INTER_LINEAR), cv2.COLOR_BGR2GRAY)
448
+ self.frames_list_start[current_frame_num] = processed_frame
449
+ current_frame_num += 1
450
+
451
+ cap_thread.release()
452
+
453
+
454
+ current_frame_num = self.end_frame_min
455
+ cap_thread = cv2.VideoCapture(self.video_path)
456
+ cap_thread.set(cv2.CAP_PROP_POS_FRAMES, float(self.end_frame_min))
457
+
458
+ while current_frame_num <= self.end_frame_max:
459
+ ret, frame = cap_thread.read()
460
+ if not ret:
461
+ # print(f"Advertencia: No se pudo leer el frame {current_frame_num} de {video_path}.")
462
+ break
463
+
464
+ processed_frame = cv2.cvtColor(cv2.resize(frame, (256, 144), interpolation=cv2.INTER_LINEAR), cv2.COLOR_BGR2GRAY)
465
+ self.frames_list_end[current_frame_num] = processed_frame
466
+ current_frame_num += 1
467
+
468
+ cap_thread.release()
469
+
470
+ '''while True:
471
+ ret, frame = self.cap.read()
472
+
473
+ if i >= start_frame_min and i <= start_frame_max:
474
+
475
+ self.frames_list_start[i] = cv2.cvtColor(cv2.resize(frame, (426,240), interpolation=cv2.INTER_LINEAR),cv2.COLOR_BGR2GRAY)
476
+
477
+ if i >= end_frame_min and i <= end_frame_max:
478
+ self.frames_list_end[i] = cv2.cvtColor(cv2.resize(frame, (426,240), interpolation=cv2.INTER_LINEAR),cv2.COLOR_BGR2GRAY)
479
+
480
+ if not ret or i >= self.total_frames:
481
+ break
482
+
483
+ i += 1'''
484
+
485
+ self.cap = cv2.VideoCapture(tfile.name)
486
+ return True
487
+
488
+
489
+ def load_video2(self, video_file, output_resolution=(854, 480)) -> bool:
490
+ """
491
+ Load video file, resize to 480p, and get basic information.
492
+
493
+ Args:
494
+ video_file: Input video file object
495
+ output_resolution: Tuple of (width, height) for resizing (default: 854x480 for 480p)
496
+
497
+ Returns:
498
+ bool: True if successful, False otherwise
499
+ """
500
+ try:
501
+ # Create temporary file to store the input video
502
+ tfile = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
503
+ tfile.write(video_file.read())
504
+ tfile.close() # Close the file to allow VideoCapture to access it
505
+
506
+ # Store the temporary file path
507
+ self.video_path = tfile.name
508
+
509
+ # Load the video
510
+ self.cap = cv2.VideoCapture(tfile.name)
511
+ if not self.cap.isOpened():
512
+ print("Error: Could not open video file.")
513
+ return False
514
+
515
+ # Get original video properties
516
+ self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
517
+ self.fps = int(self.cap.get(cv2.CAP_PROP_FPS))
518
+ print(f"FPS: {self.fps}")
519
+ print(f"Total frames: {self.total_frames}")
520
+
521
+ # Prepare for resizing and saving to a new temporary file
522
+ output_path = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4').name
523
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v') # Codec for MP4
524
+ out = cv2.VideoWriter(output_path, fourcc, self.fps, output_resolution)
525
+
526
+ # Process each frame
527
+ while self.cap.isOpened():
528
+ ret, frame = self.cap.read()
529
+ if not ret:
530
+ break
531
+ # Resize frame to 480p
532
+ resized_frame = cv2.resize(frame, output_resolution, interpolation=cv2.INTER_AREA)
533
+ out.write(resized_frame)
534
+
535
+ # Release resources
536
+ self.cap.release()
537
+ out.release()
538
+
539
+ # Update video path to the resized video
540
+ self.video_path = output_path
541
+ self.cap = cv2.VideoCapture(self.video_path)
542
+ if not self.cap.isOpened():
543
+ print("Error: Could not open resized video.")
544
+ return False
545
+
546
+ print(f"Video resized to {output_resolution} and saved to {output_path}")
547
+ return True
548
+
549
+ except Exception as e:
550
+ print(f"Error processing video: {str(e)}")
551
+ return False
552
+
553
+ def load_video1(self, video_file) -> bool:
554
+ """Load video file and get basic information"""
555
+ with tempfile.TemporaryFile(suffix='.mp4') as tfile:
556
+ tfile.write(video_file.read())
557
+ tfile.seek(0)
558
+ self.video_path = tfile.name # Store for reference
559
+ self.cap = cv2.VideoCapture(tfile.name)
560
+ if not self.cap.isOpened():
561
+ return False
562
+ self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
563
+ self.fps = int(self.cap.get(cv2.CAP_PROP_FPS))
564
+ return True
565
+
566
+ @profiler.track_time
567
+ def get_frame1(self, frame_number: int) -> np.ndarray:
568
+ """
569
+ Obtiene un frame específico del video con optimizaciones de rendimiento
570
+
571
+ Args:
572
+ frame_number: Número del frame a obtener
573
+
574
+ Returns:
575
+ Frame como array NumPy (formato RGB) o None si no está disponible
576
+ """
577
+ if self.cap is None:
578
+ return None
579
+
580
+ # 1. Inicializar atributos de seguimiento si no existen
581
+ if not hasattr(self, 'frame_cache'):
582
+ # Usamos un diccionario limitado para caché de frames frecuentes
583
+ self.frame_cache = {}
584
+ self.frame_cache_size = 100 # Ajustar según memoria disponible
585
+ self.last_position = -1 # Para seguimiento de posición
586
+
587
+ # 2. Consultar caché primero (mejora extrema para frames accedidos repetidamente)
588
+ if frame_number in self.frame_cache:
589
+ return self.frame_cache[frame_number]
590
+
591
+ # 3. Optimización para acceso secuencial (evita seeks innecesarios)
592
+ if hasattr(self, 'last_position') and frame_number == self.last_position + 1:
593
+ # El frame solicitado es el siguiente al último leído
594
+ ret, frame = self.cap.read()
595
+ if ret:
596
+ self.last_position = frame_number
597
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
598
+ #rgb_frame = frame
599
+
600
+ # Añadir al caché
601
+ self.frame_cache[frame_number] = rgb_frame
602
+
603
+ # Mantener tamaño del caché
604
+ if len(self.frame_cache) > self.frame_cache_size:
605
+ # Eliminar el frame más antiguo (menor número)
606
+ oldest = min(self.frame_cache.keys())
607
+ del self.frame_cache[oldest]
608
+
609
+ return rgb_frame
610
+ # Si falla la lectura, continuar con método directo
611
+
612
+ # 4. Acceso directo con mecanismo de reintento
613
+ for attempt in range(3): # Intentar hasta 3 veces si falla
614
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
615
+ ret, frame = self.cap.read()
616
+
617
+ if ret:
618
+ # Actualizar last_position para futuras optimizaciones secuenciales
619
+ self.last_position = frame_number
620
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
621
+
622
+ # Añadir al caché
623
+ self.frame_cache[frame_number] = rgb_frame
624
+
625
+ # Mantener tamaño del caché
626
+ if len(self.frame_cache) > self.frame_cache_size:
627
+ # Eliminar el frame más antiguo (menor número)
628
+ oldest = min(self.frame_cache.keys())
629
+ del self.frame_cache[oldest]
630
+
631
+ return rgb_frame
632
+
633
+ if attempt < 2: # No reintentar en el último intento
634
+ # Restaurar el objeto cap en caso de error
635
+ # Esto ayuda con formatos de video problemáticos
636
+ if hasattr(self, 'video_path') and self.video_path:
637
+ self.cap.release()
638
+ self.cap = cv2.VideoCapture(self.video_path)
639
+
640
+ # Si llegamos aquí, todos los intentos fallaron
641
+ return None
642
+
643
+ def get_frame(self, frame_number: int) -> np.ndarray:
644
+
645
+ if self.cap is None:
646
+ return None
647
+
648
+ '''if frame_number in self.frame_cache:
649
+ return self.frame_cache[frame_number]'''
650
+
651
+ if hasattr(self, 'last_position') and frame_number == self.last_position + 1:
652
+ ret, frame = self.cap.read()
653
+ if ret:
654
+ self.last_position = frame_number
655
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
656
+ self.frame_cache[frame_number] = rgb_frame
657
+ if len(self.frame_cache) > self.frame_cache_size:
658
+ self.frame_cache.popitem(last=False) # Remove oldest item
659
+ return cv2.resize(rgb_frame, (849, 477))
660
+
661
+ for attempt in range(3):
662
+
663
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
664
+ ret, frame = self.cap.read()
665
+ if ret:
666
+ self.last_position = frame_number
667
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
668
+ self.frame_cache[frame_number] = rgb_frame
669
+ if len(self.frame_cache) > self.frame_cache_size:
670
+ self.frame_cache.popitem(last=False)
671
+
672
+ return cv2.resize(rgb_frame, (854,480), interpolation=cv2.INTER_LINEAR)
673
+
674
+ if attempt < 2 and hasattr(self, 'video_path') and self.video_path:
675
+ self.cap.release()
676
+ self.cap = cv2.VideoCapture(self.video_path)
677
+
678
+ print(f"Error reading frame {frame_number}, retrying...")
679
+ return None
680
+
681
+ def get_frame_example(self, frame_number: int) -> np.ndarray:
682
+ """
683
+ Obtiene un frame específico del video con optimizaciones de rendimiento
684
+
685
+ Args:
686
+ frame_number: Número del frame a obtener
687
+
688
+ Returns:
689
+ Frame como array NumPy (formato RGB) o None si no está disponible
690
+ """
691
+ if self.cap is None:
692
+ return None
693
+ print(f"Frame number: {frame_number}")
694
+
695
+ # 1. Inicializar atributos de seguimiento si no existen
696
+ if not hasattr(self, 'frame_cache'):
697
+ # Usamos un diccionario limitado para caché de frames frecuentes
698
+ self.frame_cache = {}
699
+ self.frame_cache_size = 30 # Ajustar según memoria disponible
700
+ self.last_position = -1 # Para seguimiento de posición
701
+
702
+ # 2. Consultar caché primero (mejora extrema para frames accedidos repetidamente)
703
+ if frame_number in self.frame_cache:
704
+ return self.frame_cache[frame_number]
705
+
706
+ # 4. Acceso directo con mecanismo de reintento
707
+ for attempt in range(3): # Intentar hasta 3 veces si falla
708
+ try:
709
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
710
+ ret, frame = self.cap.read()
711
+
712
+ if ret:
713
+ # Actualizar last_position para futuras optimizaciones secuenciales
714
+ self.last_position = frame_number
715
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
716
+
717
+ # Añadir al caché
718
+ self.frame_cache[frame_number] = rgb_frame
719
+
720
+ # Mantener tamaño del caché
721
+ if len(self.frame_cache) > self.frame_cache_size:
722
+ # Eliminar el frame más antiguo (menor número)
723
+ oldest = min(self.frame_cache.keys())
724
+ del self.frame_cache[oldest]
725
+
726
+ return rgb_frame
727
+ except:
728
+ pass
729
+
730
+ if attempt < 2: # No reintentar en el último intento
731
+ # Restaurar el objeto cap en caso de error
732
+ # Esto ayuda con formatos de video problemáticos
733
+ if hasattr(self, 'video_path') and self.video_path:
734
+ self.cap.release()
735
+ self.cap = cv2.VideoCapture(self.video_path)
736
+
737
+
738
+ # Si llegamos aquí, todos los intentos fallaron
739
+ return None
740
+
741
+ @profiler.track_time
742
+ def mask_helmet_yolo(self, color_image: np.ndarray, helmet_height_ratio: float = 0.3, prev_mask: np.ndarray = None) -> Tuple[np.ndarray, np.ndarray]:
743
+ """
744
+ Usa YOLOv8 para segmentar el casco y lo pinta de verde.
745
+ Si se proporciona una máscara previa, la reutiliza.
746
+ Args:
747
+ color_image: Imagen en color (BGR).
748
+ helmet_height_ratio: Proporción de la imagen a considerar como región del casco (parte inferior).
749
+ prev_mask: Máscara previa para reutilizar (opcional).
750
+ Returns:
751
+ Tuple: (Imagen con la región del casco pintada de verde, Máscara generada o reutilizada).
752
+ """
753
+ # Copia de la imagen
754
+ result_1 = color_image.copy()
755
+ height, width = color_image.shape[:2]
756
+
757
+ # Si hay una máscara previa, reutilizarla
758
+ if prev_mask is not None:
759
+ mask_final = prev_mask
760
+ else:
761
+ # Convertir la imagen a RGB (YOLOv8 espera imágenes en RGB)
762
+ image_rgb = cv2.cvtColor(color_image, cv2.COLOR_BGR2RGB)
763
+
764
+ # Realizar la predicción con YOLOv8
765
+ results = self.yolo_model(image_rgb, conf=0.2, iou=0.5,imgsz=224) # Ajusta conf e iou según necesidad
766
+
767
+ # Inicializar máscara vacía
768
+ mask_final = np.zeros((height, width), dtype=np.uint8)
769
+
770
+ # Procesar los resultados de segmentación
771
+ if results[0].masks is not None:
772
+ for result in results:
773
+ masks = result.masks.data.cpu().numpy() # Máscaras de segmentación
774
+ boxes = result.boxes.xyxy.cpu().numpy() # Cajas delimitadoras
775
+ classes = result.boxes.cls.cpu().numpy() # Clases predichas
776
+
777
+ # Filtrar para la clase del casco (asumiendo que es la clase 0 o 'helmet')
778
+ # Si usas un modelo pre-entrenado en COCO, la clase 'helmet' no existe, usa 'person' (clase 0) y ROI
779
+ for i, cls in enumerate(classes):
780
+ # Ajusta según la clase de tu modelo. Ejemplo: clase 0 para 'helmet' en modelo personalizado
781
+ if int(cls) == 0: # Cambia según el índice de clase de tu modelo
782
+ # Obtener la máscara correspondiente
783
+ '''mask = masks[i]
784
+ # Redimensionar la máscara al tamaño de la imagen
785
+ mask = cv2.resize(mask, (width, height), interpolation=cv2.INTER_NEAREST)
786
+ mask = (mask > 0).astype(np.uint8) * 255 # Convertir a binario (0 o 255)
787
+
788
+ # Opcional: Filtrar usando la ROI inferior para enfocarse en el casco
789
+ roi_height = int(height * helmet_height_ratio)
790
+ roi_mask = np.zeros((height, width), dtype=np.uint8)
791
+ roi_mask[height - roi_height:, :] = 255 # Parte inferior
792
+ mask = cv2.bitwise_and(mask, roi_mask)
793
+
794
+
795
+
796
+ # Combinar máscaras si hay múltiples detecciones
797
+ mask_final = cv2.bitwise_or(mask_final, mask)'''
798
+
799
+ mask = masks[i]
800
+ mask = cv2.resize(mask, (width, height), interpolation=cv2.INTER_NEAREST)
801
+ mask = (mask > 0).astype(np.uint8) * 255
802
+ mask_final = cv2.bitwise_or(mask_final, mask)
803
+
804
+ # Refinar la máscara con operaciones morfológicas
805
+ kernel = np.ones((5, 5), np.uint8)
806
+ mask_final = cv2.erode(mask_final, kernel, iterations=1) # Eliminar ruido
807
+ mask_final = cv2.dilate(mask_final, kernel, iterations=3) # Expandir para cubrir el casco
808
+
809
+ else:
810
+ # Si no se detecta casco, devolver la imagen sin cambios y máscara vacía
811
+ print("No helmet detected in this frame.")
812
+ return result_1, mask_final
813
+
814
+ # Crear una imagen verde del mismo tamaño que la imagen original
815
+ green_color = np.zeros_like(color_image) # Crear una imagen vacía
816
+ green_color[:, :] = [125, 125, 125] # Color verde en BGR (0, 255, 0)
817
+
818
+ # Aplicar la máscara para pintar solo la región del casco
819
+ masked_green = cv2.bitwise_and(green_color, green_color, mask=mask_final)
820
+
821
+ # Crear máscara invertida para conservar el resto de la imagen
822
+ mask_inv = cv2.bitwise_not(mask_final)
823
+
824
+ # Combinar la región verde con el resto de la imagen original
825
+
826
+ result_original = cv2.bitwise_and(result_1, result_1, mask=mask_inv)
827
+ result = cv2.add(masked_green, result_original)
828
+
829
+ return result, mask_final
830
+
831
+ def mask_helmet(self, img):
832
+ """Mask the helmet region using SAM and paint it green."""
833
+ print("Processing frame...")
834
+
835
+ img = cv2.resize(img, (224, 224), interpolation=cv2.INTER_LINEAR)
836
+ height, width = img.shape[:2]
837
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
838
+
839
+ outputs = self.model.run(None, {"images":preprocess_image_tensor(img)})
840
+ print("test")
841
+ flag,result = postprocess_outputs(outputs, height, width)
842
+
843
+
844
+
845
+
846
+ # Procesar los resultados de segmentación
847
+
848
+ if flag is True:
849
+ result_image = img.copy()
850
+ overlay = np.zeros_like(img, dtype=np.uint8)
851
+ color = (125, 125, 125, 255) # RGBA color for the helmet
852
+ # Extract RGB and alpha from color
853
+ fill_color = color[:3] # (R, G, B) = (125, 125, 125)
854
+ alpha = color[3] / 255.0 # Normalize alpha to [0, 1]
855
+
856
+ for obj in result:
857
+ x1, y1, x2, y2, _, _, _, polygon = obj
858
+ # Translate polygon coordinates relative to (x1, y1)
859
+ polygon = [(round(x1 + point[0]), round(y1 + point[1])) for point in polygon]
860
+ # Convert polygon to format required by cv2.fillPoly
861
+ pts = np.array(polygon, dtype=np.int32).reshape((-1, 1, 2))
862
+ # Draw filled polygon on overlay
863
+ cv2.fillPoly(overlay, [pts], fill_color)
864
+
865
+ # Create alpha mask for blending
866
+ mask = np.any(overlay != 0, axis=2).astype(np.float32)
867
+ alpha_mask = mask * alpha
868
+
869
+ for c in range(3): # For each color channel
870
+ result_image[:, :, c] = (1 - alpha_mask) * result_image[:, :, c] + alpha_mask * overlay[:, :, c]
871
+
872
+ return result_image
873
+ else:
874
+ # Si no se detecta casco, devolver la imagen sin cambios y máscara vacía
875
+ print("No helmet detected in this frame.")
876
+ return img
877
+
878
+ def extract_frames1(self, start_frame: int, end_frame: int, fps_target: int = 10) -> List[np.ndarray]:
879
+ """
880
+ Extract frames con procesamiento vectorizado para mayor rendimiento, actualizando la máscara cada 10 frames.
881
+ """
882
+ frames, crude_frames = [], []
883
+
884
+ # Calculate the total number of frames in the selection
885
+ total_frames_selection = end_frame - start_frame + 1
886
+
887
+ # Calculate the duration of the selection in seconds
888
+ selection_duration = total_frames_selection / self.fps
889
+
890
+ # Calculate total frames to extract based on target fps
891
+ frames_to_extract = int(selection_duration * fps_target)
892
+ frames_to_extract = max(1, frames_to_extract)
893
+
894
+ # Vectorizar cálculo de índices
895
+ if frames_to_extract < total_frames_selection:
896
+ frame_indices = np.linspace(start_frame, end_frame, frames_to_extract, dtype=int)
897
+ else:
898
+ frame_indices = np.arange(start_frame, end_frame + 1)
899
+ counter = 0
900
+ # Procesamiento por lotes para reducir sobrecarga de función
901
+ BATCH_SIZE =150
902
+ last_mask = None # Almacenar la última máscara generada
903
+
904
+ for i in range(0, len(frame_indices), BATCH_SIZE):
905
+ batch_indices = frame_indices[i:i+BATCH_SIZE]
906
+ batch_frames = []
907
+
908
+
909
+ # Extract the frames in the current batch
910
+ for frame_num in batch_indices:
911
+ frame = self.get_frame(frame_num)
912
+ if frame is not None:
913
+ batch_frames.append((frame_num, frame))
914
+
915
+ # Process the batch of frames
916
+ if batch_frames:
917
+ for idx, (frame_num, frame) in enumerate(batch_frames):
918
+ cropped = self.crop_frame(frame)
919
+
920
+ result = self.mask_helmet(cropped)
921
+
922
+ clahe_image = self.apply_clahe(result)
923
+
924
+ threshold_image = self.apply_treshold(clahe_image)
925
+
926
+ frames.append(threshold_image)
927
+
928
+ return frames, crude_frames
929
+
930
+ def extract_frames(self, start_frame: int, end_frame: int, fps_target: int = 10) -> List[np.ndarray]:
931
+ frames, crude_frames = [], []
932
+
933
+ total_frames_selection = end_frame - start_frame + 1
934
+ selection_duration = total_frames_selection / self.fps
935
+ frames_to_extract = max(1, int(selection_duration * fps_target))
936
+ frame_indices = np.linspace(start_frame, end_frame, frames_to_extract, dtype=int) if frames_to_extract < total_frames_selection else np.arange(start_frame, end_frame + 1)
937
+
938
+ BATCH_SIZE = 64
939
+
940
+ def process_frame(frame_data):
941
+ frame_num, frame = frame_data
942
+ if frame is None:
943
+ return None
944
+ cropped = self.crop_frame(frame)
945
+ result = self.mask_helmet(cropped)
946
+ clahe_image = self.apply_clahe(result)
947
+ threshold_image = self.apply_treshold(clahe_image)
948
+ return threshold_image
949
+
950
+ for i in range(0, len(frame_indices), BATCH_SIZE):
951
+ batch_indices = frame_indices[i:i+BATCH_SIZE]
952
+ batch_frames = [(idx, self.get_frame(idx)) for idx in batch_indices]
953
+ with ThreadPoolExecutor(max_workers=2) as executor: # Adjust max_workers based on CPU cores
954
+ batch_results = list(executor.map(process_frame, [f for f in batch_frames if f[1] is not None]))
955
+ frames.extend([r for r in batch_results if r is not None])
956
+
957
+ return frames, crude_frames
958
+
959
+
960
+ @profiler.track_time
961
+ def crop_frame(self,image):
962
+
963
+
964
+ if image is None:
965
+ print(f"Error loading")
966
+ return None
967
+
968
+ height, width, _ = image.shape
969
+
970
+ # Use the bottom half of the image
971
+ #y_start = int(height * 0.53)
972
+ # 55% of the height
973
+ y_start = int(height * self.y_start) # 55% of the height
974
+ crop_height = height - y_start # height of bottom half
975
+ square_size = crop_height # base crop height
976
+
977
+ # Increase width by 30%: new_width equals 130% of square_size
978
+ new_width = square_size
979
+
980
+ # Shift the crop center 20% to the right.
981
+ # Calculate the desired center position.
982
+ #x_center = int(width * 0.57)
983
+ x_center = int(width * self.x_center)
984
+ x_start = max(0, x_center - new_width // 2)
985
+ x_end = x_start + new_width
986
+
987
+ # Adapt the crop if x_end exceeds the image width
988
+ if x_end > width:
989
+ x_end = width
990
+ x_start = max(0, width - new_width)
991
+
992
+ # Crop the image: bottom half in height and new_width in horizontal dimension
993
+ cropped_image = image[y_start:y_start+crop_height, x_start:x_end]
994
+
995
+
996
+ print(cropped_image.shape)
997
+ return cropped_image
998
+
999
+ def crop_frame_example(self,image):
1000
+
1001
+ if image is None:
1002
+ print(f"Error loading")
1003
+ return None
1004
+
1005
+ height, width, _ = image.shape
1006
+
1007
+ # Use the bottom half of the image
1008
+ #y_start = int(height * 0.53)
1009
+ # 55% of the height
1010
+ y_start = int(height * self.y_start) # 55% of the height
1011
+ crop_height = height - y_start # height of bottom half
1012
+ square_size = crop_height # base crop height
1013
+
1014
+ # Increase width by 30%: new_width equals 130% of square_size
1015
+ new_width = square_size
1016
+
1017
+ # Shift the crop center 20% to the right.
1018
+ # Calculate the desired center position.
1019
+ #x_center = int(width * 0.57)
1020
+ x_center = int(width * self.x_center)
1021
+ x_start = max(0, x_center - new_width // 2)
1022
+ x_end = x_start + new_width
1023
+
1024
+ # Adapt the crop if x_end exceeds the image width
1025
+ if x_end > width:
1026
+ x_end = width
1027
+ x_start = max(0, width - new_width)
1028
+
1029
+ # Crop the image: bottom half in height and new_width in horizontal dimension
1030
+ cropped_image = image[y_start:y_start+crop_height, x_start:x_end]
1031
+ cropped_image = recortar_imagen(cropped_image,self.starty, self.axes)
1032
+ cropped_image = recortar_imagen_again(cropped_image,self.starty, self.axes)
1033
+ #print(self.starty, self.axes, self.y_start, self.x_center)
1034
+ return cropped_image
1035
+
1036
+ @profiler.track_time
1037
+ def apply_clahe(self, image):
1038
+
1039
+ image = recortar_imagen(image,self.starty, self.axes)
1040
+ if self.mode == "Default":
1041
+ clahe_image = cv2.createCLAHE(clipLimit=5.0, tileGridSize=(3, 3)).apply(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY))
1042
+
1043
+ elif self.mode == "Low ilumination":
1044
+ clahe_image = cv2.createCLAHE(clipLimit=7.0, tileGridSize=(3, 3)).apply(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY))
1045
+ #clahe_image = cv2.equalizeHist(image)
1046
+ return clahe_image
1047
+
1048
+ @profiler.track_time
1049
+ def apply_treshold(self, image):
1050
+
1051
+ #try:
1052
+ # Process the image with adaptive edge detection (target 6% de bordes)
1053
+ '''_, edges, _, config = adaptive_edge_detection(
1054
+ image,
1055
+ min_edge_percentage=3,
1056
+ max_edge_percentage=6,
1057
+ target_percentage=5,
1058
+ max_attempts=5
1059
+ )'''
1060
+ percentage = calculate_black_pixels_percentage(image)
1061
+ _, edges, _, config = adaptive_edge_detection(
1062
+ image,
1063
+ min_edge_percentage=percentage,
1064
+ max_edge_percentage=percentage,
1065
+ target_percentage=percentage,
1066
+ max_attempts=1,
1067
+ mode = self.mode
1068
+ )
1069
+
1070
+ # Save the edge image
1071
+ if edges is not None:
1072
+ edges = recortar_imagen_again(edges,self.starty, self.axes)
1073
+ return edges
1074
+
1075
+
1076
+ def __del__(self):
1077
+ if self.cap is not None:
1078
+ self.cap.release()
1079
+ self.clear_cache() # Ensure cache is cleared on object deletion
1080
+