artifacts2025 commited on
Commit
a7ad806
·
verified ·
1 Parent(s): f4ab04f

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +44 -0
  2. README.md +4 -3
  3. app.py +658 -0
  4. requirements.txt +7 -0
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # 安装系统依赖(包括 wget, ffmpeg, OpenCV 运行所需依赖)
4
+ RUN apt-get update && apt-get install -y \
5
+ libglib2.0-0 \
6
+ libgl1-mesa-glx \
7
+ libsm6 \
8
+ libxext6 \
9
+ wget \
10
+ curl \
11
+ xz-utils \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+
15
+ RUN wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
16
+ && tar -xJf ffmpeg-release-amd64-static.tar.xz \
17
+ && cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \
18
+ && cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \
19
+ && rm -rf ffmpeg-release-amd64-static.tar.xz ffmpeg-*-amd64-static
20
+
21
+ # 下载并解压 OpenVSCode Server
22
+ RUN wget https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v1.101.2/openvscode-server-v1.101.2-linux-x64.tar.gz -O /tmp/openvscode-server.tar.gz \
23
+ && tar -xzf /tmp/openvscode-server.tar.gz -C /opt \
24
+ && rm /tmp/openvscode-server.tar.gz \
25
+ && mv /opt/openvscode-server-v1.101.2-linux-x64 /opt/openvscode-server \
26
+ && chown -R 1000:1000 /opt/openvscode-server
27
+
28
+
29
+ # 设置工作目录
30
+ WORKDIR /app
31
+ RUN mkdir -p /app/downloaded_videos && chmod 777 /app/downloaded_videos
32
+
33
+ # 将当前目录下的所有文件复制到容器中
34
+ COPY . /app
35
+
36
+ # 安装 Python 依赖
37
+ RUN pip install --no-cache-dir -r requirements.txt
38
+
39
+ # 设置环境变量,指定容器监听的端口
40
+ ENV PORT=7860
41
+ ENV HOST=0.0.0.0
42
+
43
+ # 启动 Dash 应用(你也可以使用 gunicorn 等其他方式)
44
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,11 @@
1
  ---
2
  title: Keyframe
3
- emoji: 🏆
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: Keyframe
3
+ emoji: 🦀
4
+ colorFrom: green
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ license: apache-2.0
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ------------------ Import Libraries ------------------
2
+ import dash
3
+ from dash import dcc, html, Input, Output, State, no_update
4
+ import plotly.graph_objects as go
5
+ import pandas as pd
6
+ import numpy as np
7
+ import cv2
8
+ import base64
9
+ from scipy.ndimage import gaussian_filter1d
10
+ import requests
11
+ import json
12
+ import tempfile
13
+ import os
14
+ from urllib.parse import urljoin
15
+ import subprocess
16
+
17
+ # ------------------ Data Download and Processing ------------------
18
+ class RemoteDatasetLoader:
19
+ def __init__(self, repo_id: str, timeout: int = 30):
20
+ self.repo_id = repo_id
21
+ self.timeout = timeout
22
+ self.base_url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/"
23
+
24
+ def _get_dataset_info(self) -> dict:
25
+ info_url = urljoin(self.base_url, "meta/info.json")
26
+ response = requests.get(info_url, timeout=self.timeout)
27
+ response.raise_for_status()
28
+ return response.json()
29
+
30
+ def _get_episode_info(self, episode_id: int) -> dict:
31
+ episodes_url = urljoin(self.base_url, "meta/episodes.jsonl")
32
+ response = requests.get(episodes_url, timeout=self.timeout)
33
+ response.raise_for_status()
34
+ episodes = [json.loads(line) for line in response.text.splitlines() if line.strip()]
35
+ for episode in episodes:
36
+ if episode.get("episode_index") == episode_id:
37
+ return episode
38
+ raise ValueError(f"Episode {episode_id} not found")
39
+
40
+ def _is_valid_mp4(self, file_path):
41
+ if not os.path.exists(file_path) or os.path.getsize(file_path) < 1024 * 100:
42
+ return False
43
+ # Use ffprobe to check if it is a valid mp4
44
+ try:
45
+ result = subprocess.run([
46
+ 'ffprobe', '-v', 'error', '-select_streams', 'v:0',
47
+ '-show_entries', 'stream=codec_name', '-of', 'default=noprint_wrappers=1:nokey=1', file_path
48
+ ], capture_output=True, text=True, timeout=10)
49
+ if result.returncode == 0 and '264' in result.stdout:
50
+ return True
51
+ except Exception as e:
52
+ print(f"ffprobe video check failed: {e}")
53
+ return False
54
+
55
+ def _download_video(self, video_url: str, save_path: str) -> str:
56
+ response = requests.get(video_url, timeout=self.timeout, stream=True)
57
+ response.raise_for_status()
58
+ # Check Content-Type
59
+ if 'video' not in response.headers.get('Content-Type', ''):
60
+ raise ValueError(f"URL {video_url} does not return video content, Content-Type: {response.headers.get('Content-Type')}")
61
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
62
+ with open(save_path, 'wb') as f:
63
+ for chunk in response.iter_content(chunk_size=8192):
64
+ f.write(chunk)
65
+ return save_path
66
+
67
+ def load_episode_data(self, episode_id: int,
68
+ video_keys=None,
69
+ download_dir=None):
70
+ dataset_info = self._get_dataset_info()
71
+ self._get_episode_info(episode_id) # Check if episode exists
72
+
73
+ if download_dir is None:
74
+ download_dir = tempfile.mkdtemp(prefix="lerobot_videos_")
75
+
76
+ if video_keys is None:
77
+ video_keys = [key for key, feature in dataset_info["features"].items()
78
+ if feature["dtype"] == "video"]
79
+
80
+ video_keys = video_keys[:2]
81
+ video_paths = []
82
+ chunks_size = dataset_info.get("chunks_size", 1000)
83
+
84
+ # Create repo-specific subdirectory
85
+ repo_name = self.repo_id.replace('/', '_') # Replace / with _ to avoid path issues
86
+ repo_dir = os.path.join(download_dir, repo_name)
87
+ os.makedirs(repo_dir, exist_ok=True)
88
+
89
+ for i, video_key in enumerate(video_keys):
90
+ video_url = self.base_url + dataset_info["video_path"].format(
91
+ episode_chunk=episode_id // chunks_size,
92
+ video_key=video_key,
93
+ episode_index=episode_id
94
+ )
95
+ video_filename = f"episode_{episode_id}_{video_key}.mp4"
96
+ local_path = os.path.join(repo_dir, video_filename)
97
+ # Prefer loading local valid mp4
98
+ if self._is_valid_mp4(local_path):
99
+ print(f"Local valid video found: {local_path}")
100
+ video_paths.append(local_path)
101
+ continue
102
+ try:
103
+ downloaded_path = self._download_video(video_url, local_path)
104
+ video_paths.append(downloaded_path)
105
+ except Exception as e:
106
+ print(f"Failed to download video {video_key}: {e}")
107
+ video_paths.append(video_url)
108
+
109
+ data_url = self.base_url + dataset_info["data_path"].format(
110
+ episode_chunk=episode_id // chunks_size,
111
+ episode_index=episode_id
112
+ )
113
+ try:
114
+ df = pd.read_parquet(data_url)
115
+ except Exception as e:
116
+ print(f"Failed to load data: {e}")
117
+ df = pd.DataFrame()
118
+
119
+ return video_paths, df
120
+
121
+ def check_ffmpeg_available():
122
+ try:
123
+ result = subprocess.run(['ffmpeg', '-version'],
124
+ capture_output=True, text=True, timeout=5)
125
+ return result.returncode == 0
126
+ except (subprocess.TimeoutExpired, FileNotFoundError):
127
+ return False
128
+
129
+ def get_video_codec_info(video_path):
130
+ try:
131
+ result = subprocess.run([
132
+ 'ffprobe', '-v', 'quiet', '-print_format', 'json',
133
+ '-show_streams', video_path
134
+ ], capture_output=True, text=True, timeout=10)
135
+ if result.returncode == 0:
136
+ info = json.loads(result.stdout)
137
+ for stream in info.get('streams', []):
138
+ if stream.get('codec_type') == 'video':
139
+ return stream.get('codec_name', 'unknown')
140
+ except Exception as e:
141
+ print(f"Failed to get video codec info: {e}")
142
+ return 'unknown'
143
+
144
+ def reencode_video_to_h264(input_path, output_path=None, quality='medium'):
145
+ if output_path is None:
146
+ base_name = os.path.splitext(input_path)[0]
147
+ output_path = f"{base_name}_h264.mp4"
148
+ quality_params = {
149
+ 'fast': ['-preset', 'ultrafast', '-crf', '28'],
150
+ 'medium': ['-preset', 'medium', '-crf', '23'],
151
+ 'high': ['-preset', 'slow', '-crf', '18']
152
+ }
153
+ params = quality_params.get(quality, quality_params['medium'])
154
+ try:
155
+ cmd = [
156
+ 'ffmpeg', '-i', input_path,
157
+ '-c:v', 'libx264',
158
+ '-c:a', 'aac',
159
+ '-movflags', '+faststart',
160
+ '-y',
161
+ ] + params + [output_path]
162
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
163
+ if result.returncode == 0:
164
+ return output_path
165
+ else:
166
+ print(f"Re-encoding failed: {result.stderr}")
167
+ return input_path
168
+ except subprocess.TimeoutExpired:
169
+ print("Re-encoding timeout")
170
+ return input_path
171
+ except Exception as e:
172
+ print(f"Re-encoding exception: {e}")
173
+ return input_path
174
+
175
+ def process_video_for_compatibility(video_path):
176
+ if not os.path.exists(video_path):
177
+ print(f"Video file does not exist: {video_path}")
178
+ return video_path
179
+ if not check_ffmpeg_available():
180
+ print("ffmpeg not available, skipping re-encoding")
181
+ return video_path
182
+ codec = get_video_codec_info(video_path)
183
+ if codec in ['av01', 'av1', 'vp9', 'vp8'] or codec == 'unknown':
184
+ reencoded_path = reencode_video_to_h264(video_path, quality='fast')
185
+ if os.path.exists(reencoded_path) and os.path.getsize(reencoded_path) > 1024:
186
+ return reencoded_path
187
+ else:
188
+ print("Re-encoding failed, using original file")
189
+ return video_path
190
+ else:
191
+ return video_path
192
+
193
+ def load_remote_dataset(repo_id: str,
194
+ episode_id: int = 0,
195
+ video_keys=None,
196
+ download_dir=None):
197
+ loader = RemoteDatasetLoader(repo_id)
198
+ video_paths, df = loader.load_episode_data(episode_id, video_keys, download_dir)
199
+ processed_video_paths = []
200
+ for video_path in video_paths:
201
+ processed_path = process_video_for_compatibility(video_path)
202
+ processed_video_paths.append(processed_path)
203
+ return processed_video_paths, df
204
+
205
+ # ------------------ Dash Initialization ------------------
206
+ app = dash.Dash(__name__, suppress_callback_exceptions=True)
207
+ server = app.server
208
+
209
+ # ------------------ Page Layout ------------------
210
+ app.layout = html.Div([
211
+ # Header with gradient background
212
+ html.Div([
213
+ html.H1("Keyframe Identification",
214
+ style={
215
+ "textAlign": "center",
216
+ "marginBottom": "10px",
217
+ "color": "white",
218
+ "fontSize": "2.5rem",
219
+ "fontWeight": "300",
220
+ "textShadow": "2px 2px 4px rgba(0,0,0,0.3)"
221
+ }),
222
+ html.P("Interactive Joint Analysis with Video Synchronization",
223
+ style={
224
+ "textAlign": "center",
225
+ "color": "rgba(255,255,255,0.9)",
226
+ "fontSize": "1.1rem",
227
+ "marginBottom": "0"
228
+ })
229
+ ], style={
230
+ "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
231
+ "padding": "30px 20px",
232
+ "marginBottom": "30px",
233
+ "borderRadius": "0 0 15px 15px",
234
+ "boxShadow": "0 4px 20px rgba(0,0,0,0.1)"
235
+ }),
236
+
237
+ # Control Panel
238
+ html.Div([
239
+ html.Div([
240
+ html.Label("Repository ID:",
241
+ style={
242
+ "fontWeight": "600",
243
+ "color": "#333",
244
+ "marginRight": "10px",
245
+ "fontSize": "1rem"
246
+ }),
247
+ dcc.Input(
248
+ id="input-repo-id",
249
+ type="text",
250
+ value="zijian2022/sortingtest",
251
+ style={
252
+ "width": "350px",
253
+ "padding": "12px 15px",
254
+ "border": "2px solid #e1e5e9",
255
+ "borderRadius": "8px",
256
+ "fontSize": "14px",
257
+ "transition": "border-color 0.3s ease",
258
+ "outline": "none"
259
+ },
260
+ placeholder="Enter HuggingFace dataset repository ID"
261
+ ),
262
+ ], style={"marginBottom": "15px"}),
263
+
264
+ html.Div([
265
+ html.Label("Episode ID:",
266
+ style={
267
+ "fontWeight": "600",
268
+ "color": "#333",
269
+ "marginRight": "10px",
270
+ "fontSize": "1rem"
271
+ }),
272
+ dcc.Input(
273
+ id="input-episode-id",
274
+ type="number",
275
+ value=0,
276
+ min=0,
277
+ style={
278
+ "width": "120px",
279
+ "padding": "12px 15px",
280
+ "border": "2px solid #e1e5e9",
281
+ "borderRadius": "8px",
282
+ "fontSize": "14px",
283
+ "transition": "border-color 0.3s ease",
284
+ "outline": "none"
285
+ }
286
+ ),
287
+ html.Button(
288
+ "Load Data",
289
+ id="btn-load",
290
+ n_clicks=0,
291
+ style={
292
+ "marginLeft": "20px",
293
+ "padding": "12px 25px",
294
+ "backgroundColor": "#667eea",
295
+ "color": "white",
296
+ "border": "none",
297
+ "borderRadius": "8px",
298
+ "fontSize": "14px",
299
+ "fontWeight": "600",
300
+ "cursor": "pointer",
301
+ "transition": "all 0.3s ease",
302
+ "boxShadow": "0 2px 10px rgba(102, 126, 234, 0.3)"
303
+ }
304
+ ),
305
+ ]),
306
+ ], style={
307
+ "textAlign": "center",
308
+ "marginBottom": "40px",
309
+ "padding": "25px",
310
+ "backgroundColor": "white",
311
+ "borderRadius": "12px",
312
+ "boxShadow": "0 4px 20px rgba(0,0,0,0.08)",
313
+ "border": "1px solid #f0f0f0"
314
+ }),
315
+
316
+ # Loading and Data Store
317
+ dcc.Loading(
318
+ id="loading",
319
+ type="circle",
320
+ style={"margin": "20px auto"},
321
+ children=dcc.Store(id="store-data")
322
+ ),
323
+
324
+ # Main Content Area
325
+ html.Div(
326
+ id="main-content",
327
+ style={
328
+ "backgroundColor": "#f8f9fa",
329
+ "minHeight": "400px",
330
+ "borderRadius": "12px",
331
+ "padding": "20px"
332
+ }
333
+ ),
334
+
335
+
336
+ ], style={
337
+ "fontFamily": "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
338
+ "backgroundColor": "#f5f7fa",
339
+ "minHeight": "100vh",
340
+ "padding": "0"
341
+ })
342
+
343
+ # ------------------ Data Loading Callback ------------------
344
+ @app.callback(
345
+ Output("store-data", "data"),
346
+ Input("btn-load", "n_clicks"),
347
+ State("input-repo-id", "value"),
348
+ State("input-episode-id", "value"),
349
+ prevent_initial_call=True
350
+ )
351
+ def load_data_callback(n_clicks, repo_id, episode_id):
352
+ try:
353
+ video_paths, data_df = load_remote_dataset(
354
+ repo_id=repo_id,
355
+ episode_id=int(episode_id),
356
+ download_dir="./downloaded_videos"
357
+ )
358
+ if data_df is None or data_df.empty:
359
+ return {}
360
+ return {
361
+ "video_paths": video_paths,
362
+ "data_df": data_df.to_dict("records"),
363
+ "columns": ["shoulder_pan", "shoulder_pitch", "elbow", "wrist_pitch", "wrist_roll", "gripper"],
364
+ "timestamps": data_df["timestamp"].tolist()
365
+ }
366
+ except Exception as e:
367
+ print(f"Data loading error: {e}")
368
+ return {}
369
+
370
+ # ------------------ Main Content Rendering Callback ------------------
371
+ @app.callback(
372
+ Output("main-content", "children"),
373
+ Input("store-data", "data")
374
+ )
375
+ def update_main_content(data):
376
+ if not data or "data_df" not in data or len(data["data_df"]) == 0:
377
+ return html.Div([
378
+ html.Div("📊", style={"fontSize": "3rem", "marginBottom": "20px"}),
379
+ html.H3("No Data Available", style={"color": "#666", "marginBottom": "10px"}),
380
+ html.P("Please click the 'Load Data' button above to get data.",
381
+ style={"color": "#888", "fontSize": "1rem"})
382
+ ], style={
383
+ "textAlign": "center",
384
+ "padding": "60px 20px",
385
+ "color": "#666"
386
+ })
387
+
388
+ columns = data["columns"]
389
+ rows = []
390
+ for i, joint in enumerate(columns):
391
+ rows.append(html.Div([
392
+ # Joint Graph - Left 50%
393
+ html.Div([
394
+ dcc.Graph(id=f"graph-{i}")
395
+ ], style={
396
+ "flex": "0 0 50%",
397
+ "backgroundColor": "white",
398
+ "borderRadius": "8px",
399
+ "padding": "8px",
400
+ "boxShadow": "0 2px 10px rgba(0,0,0,0.05)",
401
+ "border": "1px solid #e9ecef",
402
+ "marginRight": "2%"
403
+ }),
404
+ # Video Area - Right 48%
405
+ html.Div([
406
+ html.Img(id=f"video1-{i}", style={
407
+ "width": "49%",
408
+ "height": "180px",
409
+ "objectFit": "contain",
410
+ "display": "inline-block",
411
+ "borderRadius": "6px",
412
+ "border": "2px solid #e9ecef"
413
+ }),
414
+ html.Img(id=f"video2-{i}", style={
415
+ "width": "49%",
416
+ "height": "180px",
417
+ "objectFit": "contain",
418
+ "display": "inline-block",
419
+ "borderRadius": "6px",
420
+ "border": "2px solid #e9ecef"
421
+ })
422
+ ], style={
423
+ "flex": "0 0 48%"
424
+ })
425
+ ], style={
426
+ "marginBottom": "25px",
427
+ "backgroundColor": "white",
428
+ "borderRadius": "12px",
429
+ "padding": "12px",
430
+ "boxShadow": "0 4px 15px rgba(0,0,0,0.08)",
431
+ "border": "1px solid #f0f0f0",
432
+ "display": "flex",
433
+ "alignItems": "flex-start",
434
+ "minHeight": "250px"
435
+ }))
436
+ return html.Div(rows)
437
+
438
+ # ------------------ Shadow and Highlight Utility Functions ------------------
439
+ def find_intervals(mask):
440
+ intervals = []
441
+ start = None
442
+ for i, val in enumerate(mask):
443
+ if val and start is None:
444
+ start = i
445
+ elif not val and start is not None:
446
+ intervals.append((start, i - 1))
447
+ start = None
448
+ if start is not None:
449
+ intervals.append((start, len(mask) - 1))
450
+ return intervals
451
+
452
+ def get_shadow_info(joint_name, action_df, delta_t, time_for_plot):
453
+ angles = action_df[joint_name].values
454
+ velocity = np.diff(angles) / delta_t
455
+ smoothed_velocity = gaussian_filter1d(velocity, sigma=1)
456
+ smoothed_angle = gaussian_filter1d(angles[1:], sigma=1)
457
+ vel_threshold = 0.5
458
+ highlight_width = 1
459
+ k = 2
460
+ shadows = []
461
+ low_speed_mask = np.abs(smoothed_velocity) < vel_threshold
462
+ low_speed_intervals = find_intervals(low_speed_mask)
463
+ for start, end in low_speed_intervals:
464
+ if end - start + 1 <= k:
465
+ shadows.append({
466
+ 'type': 'low_speed',
467
+ 'start_time': time_for_plot[start],
468
+ 'end_time': time_for_plot[end],
469
+ 'start_idx': start,
470
+ 'end_idx': end
471
+ })
472
+ max_idx = np.argmax(smoothed_angle)
473
+ s_max = max(0, max_idx - highlight_width)
474
+ e_max = min(len(time_for_plot) - 1, max_idx + highlight_width)
475
+ shadows.append({
476
+ 'type': 'max_value',
477
+ 'start_time': time_for_plot[s_max],
478
+ 'end_time': time_for_plot[e_max],
479
+ 'start_idx': s_max,
480
+ 'end_idx': e_max
481
+ })
482
+ min_idx = np.argmin(smoothed_angle)
483
+ s_min = max(0, min_idx - highlight_width)
484
+ e_min = min(len(time_for_plot) - 1, min_idx + highlight_width)
485
+ shadows.append({
486
+ 'type': 'min_value',
487
+ 'start_time': time_for_plot[s_min],
488
+ 'end_time': time_for_plot[e_min],
489
+ 'start_idx': s_min,
490
+ 'end_idx': e_min
491
+ })
492
+ return shadows
493
+
494
+
495
+
496
+ def generate_joint_graph(joint_name, idx, action_df, delta_t, time_for_plot, all_shadows):
497
+ angles = action_df[joint_name].values
498
+ velocity = np.diff(angles) / delta_t
499
+ smoothed_velocity = gaussian_filter1d(velocity, sigma=1)
500
+ smoothed_angle = gaussian_filter1d(angles[1:], sigma=1)
501
+ shapes = []
502
+ current_shadows = all_shadows[joint_name]
503
+ for shadow in current_shadows:
504
+ shapes.append({
505
+ "type": "rect",
506
+ "xref": "x",
507
+ "yref": "paper",
508
+ "x0": shadow['start_time'],
509
+ "x1": shadow['end_time'],
510
+ "y0": 0,
511
+ "y1": 1,
512
+ "fillcolor": "#ef4444", # Fixed red
513
+ "opacity": 0.4,
514
+ "line": {"width": 0}
515
+ })
516
+ return {
517
+ "data": [
518
+ go.Scatter(
519
+ x=time_for_plot,
520
+ y=smoothed_angle,
521
+ name="Joint Angle",
522
+ line=dict(color='#f59e0b', width=2),
523
+ hovertemplate='<b>Time:</b> %{x:.2f}s<br><b>Angle:</b> %{y:.2f}°<extra></extra>'
524
+ )
525
+ ],
526
+ "layout": go.Layout(
527
+ title={
528
+ 'text': joint_name.replace('_', ' ').title(),
529
+ 'font': {'size': 16, 'color': '#374151'}
530
+ },
531
+ xaxis={
532
+ "title": "Time (seconds)",
533
+ "titlefont": {"color": "#6b7280"},
534
+ "tickfont": {"color": "#6b7280"},
535
+ "gridcolor": "#f3f4f6",
536
+ "zerolinecolor": "#e5e7eb"
537
+ },
538
+ yaxis={
539
+ "title": "Angle (degrees)",
540
+ "titlefont": {"color": "#6b7280"},
541
+ "tickfont": {"color": "#6b7280"},
542
+ "gridcolor": "#f3f4f6",
543
+ "zerolinecolor": "#e5e7eb"
544
+ },
545
+ shapes=shapes,
546
+ hovermode="x unified",
547
+ height=220,
548
+ margin=dict(t=30, b=30, l=50, r=30),
549
+ showlegend=False,
550
+ plot_bgcolor='white',
551
+ paper_bgcolor='white',
552
+ font={'family': "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif"},
553
+ hoverlabel=dict(
554
+ bgcolor="white",
555
+ font_size=12,
556
+ font_family="'Segoe UI', Tahoma, Geneva, Verdana, sans-serif"
557
+ )
558
+ )
559
+ }
560
+
561
+ # ------------------ Chart Update Callback ------------------
562
+ @app.callback(
563
+ [Output(f"graph-{i}", "figure") for i in range(6)],
564
+ [Input("store-data", "data")],
565
+ prevent_initial_call=True
566
+ )
567
+ def update_all_graphs(data):
568
+ if not data or "data_df" not in data or len(data["data_df"]) == 0:
569
+ return [no_update] * 6
570
+
571
+ columns = data["columns"]
572
+ df = pd.DataFrame.from_records(data["data_df"])
573
+ action_df = pd.DataFrame(df["action"].tolist(), columns=columns)
574
+ timestamps = df["timestamp"].values
575
+ delta_t = np.diff(timestamps)
576
+ time_for_plot = timestamps[1:]
577
+ all_shadows = {}
578
+ for joint in columns:
579
+ all_shadows[joint] = get_shadow_info(joint, action_df, delta_t, time_for_plot)
580
+
581
+ # Generate all charts, no highlight logic
582
+ return [
583
+ generate_joint_graph(joint, i, action_df, delta_t, time_for_plot, all_shadows)
584
+ for i, joint in enumerate(columns)
585
+ ]
586
+
587
+ # ------------------ Video Frame Extraction Function ------------------
588
+ def get_video_frame(video_path, time_in_seconds):
589
+ try:
590
+ cap = cv2.VideoCapture(video_path)
591
+ if not cap.isOpened():
592
+ print(f"❌ Cannot open video: {video_path}")
593
+ return None
594
+ fps = cap.get(cv2.CAP_PROP_FPS)
595
+ if fps <= 0:
596
+ cap.release()
597
+ return None
598
+ frame_num = int(time_in_seconds * fps)
599
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
600
+ success, frame = cap.read()
601
+ cap.release()
602
+ if success and frame is not None:
603
+ height, width = frame.shape[:2]
604
+ if width > 640:
605
+ new_width = 640
606
+ new_height = int(height * (new_width / width))
607
+ frame = cv2.resize(frame, (new_width, new_height))
608
+ encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
609
+ _, buffer = cv2.imencode('.jpg', frame, encode_param)
610
+ encoded = base64.b64encode(buffer).decode('utf-8')
611
+ return f"data:image/jpeg;base64,{encoded}"
612
+ else:
613
+ return None
614
+ except Exception as e:
615
+ print(f"❌ Exception extracting video frame: {e}")
616
+ return None
617
+
618
+ # ------------------ Video Frame Callback ------------------
619
+ for i in range(6):
620
+ @app.callback(
621
+ Output(f"video1-{i}", "src"),
622
+ Output(f"video2-{i}", "src"),
623
+ Input("store-data", "data"),
624
+ Input(f"graph-{i}", "hoverData"),
625
+ prevent_initial_call=True
626
+ )
627
+ def update_video_frames(data, hover_data, idx=i):
628
+ if not data or "data_df" not in data or len(data["data_df"]) == 0:
629
+ return no_update, no_update
630
+ columns = data["columns"]
631
+ df = pd.DataFrame.from_records(data["data_df"])
632
+ timestamps = df["timestamp"].values
633
+ time_for_plot = timestamps[1:]
634
+ video_paths = data["video_paths"]
635
+
636
+ # Determine the time point to display
637
+ display_time = 0.0 # Default to start time
638
+ if hover_data and "points" in hover_data and len(hover_data["points"]) > 0:
639
+ # If there is hover data, use hover time
640
+ display_time = float(hover_data["points"][0]["x"])
641
+ elif len(time_for_plot) > 0:
642
+ # If no hover data, use the start time of the timeline
643
+ display_time = time_for_plot[0]
644
+
645
+ try:
646
+ frame1 = get_video_frame(video_paths[0], display_time)
647
+ frame2 = get_video_frame(video_paths[1], display_time)
648
+ if frame1 and frame2:
649
+ return frame1, frame2
650
+ else:
651
+ return no_update, no_update
652
+ except Exception as e:
653
+ print(f"update_video_frames callback error: {e}")
654
+ return no_update, no_update
655
+
656
+ # ------------------ Start Application ------------------
657
+ if __name__ == "__main__":
658
+ app.run(debug=True, host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ dash==3.1.1
2
+ numpy==1.26.4
3
+ opencv_python==4.11.0.86
4
+ pandas==2.3.1
5
+ plotly==5.24.1
6
+ scipy==1.16.0
7
+ pyarrow