any4robot 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- any4robot-0.1.0/PKG-INFO +81 -0
- any4robot-0.1.0/any4robot/__init__.py +11 -0
- any4robot-0.1.0/any4robot/combine/__init__.py +33 -0
- any4robot-0.1.0/any4robot/conversion/__init__.py +17 -0
- any4robot-0.1.0/any4robot/editing/__init__.py +5 -0
- any4robot-0.1.0/any4robot/formats/lerobot_v2_1.py +108 -0
- any4robot-0.1.0/any4robot/gui/__init__.py +40 -0
- any4robot-0.1.0/any4robot/gui/constants.py +55 -0
- any4robot-0.1.0/any4robot/gui/controls.py +265 -0
- any4robot-0.1.0/any4robot/gui/data_handler.py +152 -0
- any4robot-0.1.0/any4robot/gui/plot_component.py +166 -0
- any4robot-0.1.0/any4robot/gui/video_component.py +143 -0
- any4robot-0.1.0/any4robot/gui/viewer.py +349 -0
- any4robot-0.1.0/any4robot/lerobot.py +7 -0
- any4robot-0.1.0/any4robot/lerobot_editor/__init__.py +20 -0
- any4robot-0.1.0/any4robot/lerobot_editor/cli.py +529 -0
- any4robot-0.1.0/any4robot/lerobot_editor/constants.py +167 -0
- any4robot-0.1.0/any4robot/lerobot_editor/core.py +213 -0
- any4robot-0.1.0/any4robot/lerobot_editor/display.py +320 -0
- any4robot-0.1.0/any4robot/lerobot_editor/file_utils.py +222 -0
- any4robot-0.1.0/any4robot/lerobot_editor/metadata.py +267 -0
- any4robot-0.1.0/any4robot/lerobot_editor/operations.py +757 -0
- any4robot-0.1.0/any4robot/qualitycheck/__init__.py +5 -0
- any4robot-0.1.0/any4robot/version.py +1 -0
- any4robot-0.1.0/any4robot.egg-info/PKG-INFO +81 -0
- any4robot-0.1.0/any4robot.egg-info/SOURCES.txt +32 -0
- any4robot-0.1.0/any4robot.egg-info/dependency_links.txt +1 -0
- any4robot-0.1.0/any4robot.egg-info/requires.txt +10 -0
- any4robot-0.1.0/any4robot.egg-info/top_level.txt +1 -0
- any4robot-0.1.0/pyproject.toml +46 -0
- any4robot-0.1.0/readme.md +51 -0
- any4robot-0.1.0/setup.cfg +4 -0
- any4robot-0.1.0/tests/test_lerobot_edit.py +14 -0
- any4robot-0.1.0/vendor/lero/LICENSE +186 -0
any4robot-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: any4robot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Foundation dataset tooling for VLA model training
|
|
5
|
+
Author: Any4Robot Contributors
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: robotics,dataset,lerobot,vla,data-tools
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: vendor/lero/LICENSE
|
|
21
|
+
Requires-Dist: pandas>=1.5.0
|
|
22
|
+
Requires-Dist: pyarrow>=10.0.0
|
|
23
|
+
Provides-Extra: gui
|
|
24
|
+
Requires-Dist: matplotlib>=3.5.0; extra == "gui"
|
|
25
|
+
Requires-Dist: opencv-python>=4.5.0; extra == "gui"
|
|
26
|
+
Requires-Dist: Pillow>=8.0.0; extra == "gui"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Any4Robot
|
|
32
|
+
|
|
33
|
+
`any4robot` provides dataset conversion and management utilities for training VLA / WM / VAM robot control models. Data preparation is crucial for robot model development. Any4Robot provides a standardized, verified way to produce the right data so you can focus on model design.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install any4robot
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start (LeRobot editor)
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from any4robot import LeRobotDatasetEditor
|
|
45
|
+
|
|
46
|
+
editor = LeRobotDatasetEditor("/path/to/lerobot-dataset")
|
|
47
|
+
editor.list_episodes(0, 5)
|
|
48
|
+
editor.delete_episode(12, dry_run=True)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Combine datasets
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from any4robot.combine import merge_lerobot_datasets
|
|
55
|
+
|
|
56
|
+
merge_lerobot_datasets(
|
|
57
|
+
["/path/to/dataset_a", "/path/to/dataset_b"],
|
|
58
|
+
"/path/to/output_dataset",
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quality check (LeRobot v2.1 layout)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from any4robot.qualitycheck import validate_lerobot_v2_1
|
|
66
|
+
|
|
67
|
+
result = validate_lerobot_v2_1("/path/to/lerobot-dataset")
|
|
68
|
+
print(result)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Modules
|
|
72
|
+
|
|
73
|
+
- `any4robot.editing`: LeRobot dataset editing helpers (delete episode, copy episode, list episodes)
|
|
74
|
+
- `any4robot.combine`: Merge multiple LeRobot datasets into a single dataset
|
|
75
|
+
- `any4robot.qualitycheck`: Validate datasets (LeRobot v2.1 layout)
|
|
76
|
+
- `any4robot.conversion`: Placeholder for future dataset conversion pipelines
|
|
77
|
+
|
|
78
|
+
## Notes
|
|
79
|
+
|
|
80
|
+
- The LeRobot editor code is copied from `vendor/lero` and organized under `any4robot/lerobot_editor`.
|
|
81
|
+
- Optional GUI dependencies are available with `pip install any4robot[gui]`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Any4Robot: dataset tooling foundation for VLA training workflows."""
|
|
2
|
+
|
|
3
|
+
from .lerobot import LeRobotDatasetEditor, DatasetOperations, MetadataManager
|
|
4
|
+
from .version import __version__
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"LeRobotDatasetEditor",
|
|
8
|
+
"DatasetOperations",
|
|
9
|
+
"MetadataManager",
|
|
10
|
+
"__version__",
|
|
11
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Dataset combination utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, Iterable, Optional
|
|
7
|
+
|
|
8
|
+
from any4robot.lerobot import DatasetOperations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def merge_lerobot_datasets(
|
|
12
|
+
source_datasets: Iterable[str | Path],
|
|
13
|
+
output_path: str | Path,
|
|
14
|
+
task_mapping: Optional[Dict[str, str]] = None,
|
|
15
|
+
dry_run: bool = False,
|
|
16
|
+
) -> bool:
|
|
17
|
+
"""
|
|
18
|
+
Merge multiple LeRobot datasets into a single dataset.
|
|
19
|
+
|
|
20
|
+
This delegates to the LERO DatasetOperations merge implementation.
|
|
21
|
+
"""
|
|
22
|
+
source_paths = [Path(p) for p in source_datasets]
|
|
23
|
+
output = Path(output_path)
|
|
24
|
+
|
|
25
|
+
if not source_paths:
|
|
26
|
+
raise ValueError("source_datasets must contain at least one dataset path")
|
|
27
|
+
|
|
28
|
+
# Use the first dataset path to bootstrap operations for merging.
|
|
29
|
+
ops = DatasetOperations(source_paths[0])
|
|
30
|
+
return ops.merge_datasets(source_paths, output, task_mapping=task_mapping, dry_run=dry_run)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["merge_lerobot_datasets"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Conversion utilities (placeholder for future formats)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def convert_to_lerobot_v2_1(source_path: str | Path, output_path: str | Path, dry_run: bool = False) -> None:
|
|
9
|
+
"""
|
|
10
|
+
Placeholder for converting datasets into LeRobot v2.1 format.
|
|
11
|
+
|
|
12
|
+
This function intentionally raises until a concrete converter is implemented.
|
|
13
|
+
"""
|
|
14
|
+
raise NotImplementedError("Conversion pipeline is not yet implemented")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["convert_to_lerobot_v2_1"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""LeRobot dataset v2.1 format validation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from any4robot.lerobot_editor.constants import (
|
|
10
|
+
DATA_DIR,
|
|
11
|
+
EPISODES_FILE,
|
|
12
|
+
INFO_FILE,
|
|
13
|
+
META_DIR,
|
|
14
|
+
TASKS_FILE,
|
|
15
|
+
)
|
|
16
|
+
from any4robot.lerobot_editor.file_utils import FileSystemManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
REQUIRED_META_FILES = (INFO_FILE, EPISODES_FILE, TASKS_FILE)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_json(path: Path) -> Dict[str, Any]:
|
|
23
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
24
|
+
return json.load(handle)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_lerobot_v2_1(dataset_path: str | Path, max_episodes: Optional[int] = None) -> Dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Validate a dataset against the LeRobot v2.1 directory layout.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
dataset_path: Dataset root directory.
|
|
33
|
+
max_episodes: Optional cap on number of episodes to validate.
|
|
34
|
+
"""
|
|
35
|
+
root = Path(dataset_path)
|
|
36
|
+
result: Dict[str, Any] = {
|
|
37
|
+
"valid": True,
|
|
38
|
+
"errors": [],
|
|
39
|
+
"warnings": [],
|
|
40
|
+
"checked_episodes": 0,
|
|
41
|
+
"dataset_path": str(root),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if not root.exists():
|
|
45
|
+
result["valid"] = False
|
|
46
|
+
result["errors"].append(f"Dataset path does not exist: {root}")
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
meta_path = root / META_DIR
|
|
50
|
+
data_path = root / DATA_DIR
|
|
51
|
+
|
|
52
|
+
if not meta_path.exists():
|
|
53
|
+
result["valid"] = False
|
|
54
|
+
result["errors"].append(f"Missing meta directory: {meta_path}")
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
missing_meta = [name for name in REQUIRED_META_FILES if not (meta_path / name).exists()]
|
|
58
|
+
if missing_meta:
|
|
59
|
+
result["valid"] = False
|
|
60
|
+
for name in missing_meta:
|
|
61
|
+
result["errors"].append(f"Missing meta file: {meta_path / name}")
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
if not data_path.exists():
|
|
65
|
+
result["valid"] = False
|
|
66
|
+
result["errors"].append(f"Missing data directory: {data_path}")
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
info = _read_json(meta_path / INFO_FILE)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
result["valid"] = False
|
|
73
|
+
result["errors"].append(f"Failed to read info.json: {exc}")
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
if "features" not in info:
|
|
77
|
+
result["warnings"].append("info.json missing 'features' field")
|
|
78
|
+
|
|
79
|
+
manager = FileSystemManager(root)
|
|
80
|
+
video_features = info.get("features", {})
|
|
81
|
+
episodes_path = meta_path / EPISODES_FILE
|
|
82
|
+
|
|
83
|
+
with episodes_path.open("r", encoding="utf-8") as handle:
|
|
84
|
+
for line in handle:
|
|
85
|
+
if not line.strip():
|
|
86
|
+
continue
|
|
87
|
+
episode = json.loads(line)
|
|
88
|
+
episode_index = episode.get("episode_index")
|
|
89
|
+
|
|
90
|
+
if episode_index is None:
|
|
91
|
+
result["valid"] = False
|
|
92
|
+
result["errors"].append("Episode entry missing episode_index")
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
paths = manager.get_episode_file_paths(int(episode_index), video_features)
|
|
96
|
+
if not paths["data"].exists():
|
|
97
|
+
result["valid"] = False
|
|
98
|
+
result["errors"].append(f"Missing data file for episode {episode_index}: {paths['data']}")
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
result["checked_episodes"] += 1
|
|
102
|
+
if max_episodes and result["checked_episodes"] >= max_episodes:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["validate_lerobot_v2_1"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GUI module for LERO - LeRobot dataset Operations toolkit
|
|
3
|
+
|
|
4
|
+
This module provides GUI components for viewing and editing LeRobot datasets.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from .viewer import EpisodeGUIViewer, launch_episode_viewer
|
|
9
|
+
from . import viewer
|
|
10
|
+
from . import video_component
|
|
11
|
+
from . import plot_component
|
|
12
|
+
from . import controls
|
|
13
|
+
from . import data_handler
|
|
14
|
+
|
|
15
|
+
# Create aliases for backward compatibility and test expectations
|
|
16
|
+
video_component.VideoComponent = video_component.VideoDisplayComponent
|
|
17
|
+
plot_component.PlotComponent = plot_component.JointPlotComponent
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
'EpisodeGUIViewer',
|
|
21
|
+
'launch_episode_viewer',
|
|
22
|
+
'viewer',
|
|
23
|
+
'video_component',
|
|
24
|
+
'plot_component',
|
|
25
|
+
'controls',
|
|
26
|
+
'data_handler'
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
except ImportError as e:
|
|
30
|
+
# GUI dependencies not available
|
|
31
|
+
error_message = f"GUI dependencies not available: {e}"
|
|
32
|
+
|
|
33
|
+
def launch_episode_viewer(*args, **kwargs):
|
|
34
|
+
raise ImportError(error_message)
|
|
35
|
+
|
|
36
|
+
class EpisodeGUIViewer:
|
|
37
|
+
def __init__(self, *args, **kwargs):
|
|
38
|
+
raise ImportError(error_message)
|
|
39
|
+
|
|
40
|
+
__all__ = ['EpisodeGUIViewer', 'launch_episode_viewer']
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants and configuration for the GUI components.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# GUI Layout Constants
|
|
6
|
+
WINDOW_WIDTH = 1400
|
|
7
|
+
WINDOW_HEIGHT = 900
|
|
8
|
+
MAX_VIDEO_SIZE = 300
|
|
9
|
+
|
|
10
|
+
# Playback Constants
|
|
11
|
+
DEFAULT_PLAYBACK_SPEED = 1.0
|
|
12
|
+
MIN_PLAYBACK_SPEED = 0.1
|
|
13
|
+
MAX_PLAYBACK_SPEED = 10.0
|
|
14
|
+
BASE_FPS = 60 # Target FPS for playback
|
|
15
|
+
MIN_DELAY_MS = 1 # Minimum delay between frames in milliseconds
|
|
16
|
+
VIDEO_UPDATE_SPEED_THRESHOLD = 2.0 # Skip video updates above this speed
|
|
17
|
+
|
|
18
|
+
# Font Settings
|
|
19
|
+
DEFAULT_FONT_SIZE = 12
|
|
20
|
+
LABEL_FONT = ('TkDefaultFont', 12, 'bold')
|
|
21
|
+
PLOT_TITLE_FONT_SIZE = 14
|
|
22
|
+
PLOT_LABEL_FONT_SIZE = 12
|
|
23
|
+
PLOT_TICK_FONT_SIZE = 10
|
|
24
|
+
LEGEND_FONT_SIZE = 10
|
|
25
|
+
|
|
26
|
+
# Robot Joint Constants
|
|
27
|
+
SO100_JOINT_NAMES = [
|
|
28
|
+
'main_shoulder_pan',
|
|
29
|
+
'main_shoulder_lift',
|
|
30
|
+
'main_elbow_flex',
|
|
31
|
+
'main_wrist_flex',
|
|
32
|
+
'main_wrist_roll',
|
|
33
|
+
'main_gripper'
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
MAX_JOINTS_DISPLAY = 6
|
|
37
|
+
|
|
38
|
+
# Plot Colors for Joint Angles
|
|
39
|
+
JOINT_COLORS = [
|
|
40
|
+
'#1f77b4', # Blue
|
|
41
|
+
'#ff7f0e', # Orange
|
|
42
|
+
'#2ca02c', # Green
|
|
43
|
+
'#d62728', # Red
|
|
44
|
+
'#9467bd', # Purple
|
|
45
|
+
'#8c564b' # Brown
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# UI Icons/Emojis
|
|
49
|
+
ICON_CHART = "📊"
|
|
50
|
+
ICON_TARGET = "🎯"
|
|
51
|
+
ICON_FILM = "🎬"
|
|
52
|
+
|
|
53
|
+
# Data Extraction Keywords
|
|
54
|
+
JOINT_KEYWORDS = ['joint', 'position', 'angle', 'state']
|
|
55
|
+
NUMERIC_DTYPES = ['float64', 'float32', 'int64', 'int32']
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Control panel components for the GUI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import ttk
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
from .constants import (
|
|
9
|
+
MIN_PLAYBACK_SPEED,
|
|
10
|
+
MAX_PLAYBACK_SPEED,
|
|
11
|
+
DEFAULT_PLAYBACK_SPEED,
|
|
12
|
+
LABEL_FONT
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ControlPanel:
|
|
17
|
+
"""Main control panel containing episode selection and playback controls."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, parent_frame: ttk.Frame, total_episodes: int):
|
|
20
|
+
"""
|
|
21
|
+
Initialize the control panel.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
parent_frame: Parent frame to contain controls
|
|
25
|
+
total_episodes: Total number of episodes in dataset
|
|
26
|
+
"""
|
|
27
|
+
self.parent_frame = parent_frame
|
|
28
|
+
self.total_episodes = total_episodes
|
|
29
|
+
|
|
30
|
+
# Control variables
|
|
31
|
+
self.episode_var = tk.StringVar()
|
|
32
|
+
self.episode_info_var = tk.StringVar()
|
|
33
|
+
self.frame_var = tk.StringVar()
|
|
34
|
+
self.speed_var = tk.DoubleVar(value=DEFAULT_PLAYBACK_SPEED)
|
|
35
|
+
|
|
36
|
+
# Control widgets
|
|
37
|
+
self.play_button: Optional[ttk.Button] = None
|
|
38
|
+
self.speed_label: Optional[ttk.Label] = None
|
|
39
|
+
|
|
40
|
+
# Callbacks (to be set by parent)
|
|
41
|
+
self.on_load_episode: Optional[Callable] = None
|
|
42
|
+
self.on_toggle_playback: Optional[Callable] = None
|
|
43
|
+
self.on_stop_playback: Optional[Callable] = None
|
|
44
|
+
self.on_step_forward: Optional[Callable] = None
|
|
45
|
+
self.on_step_backward: Optional[Callable] = None
|
|
46
|
+
self.on_speed_change: Optional[Callable] = None
|
|
47
|
+
|
|
48
|
+
self._setup_controls()
|
|
49
|
+
|
|
50
|
+
def _setup_controls(self) -> None:
|
|
51
|
+
"""Setup all control widgets."""
|
|
52
|
+
control_frame = ttk.LabelFrame(self.parent_frame, text="Controls", padding=10)
|
|
53
|
+
control_frame.pack(fill=tk.X, pady=(0, 10))
|
|
54
|
+
|
|
55
|
+
self._setup_episode_controls(control_frame)
|
|
56
|
+
self._setup_playback_controls(control_frame)
|
|
57
|
+
|
|
58
|
+
def _setup_episode_controls(self, parent: ttk.Frame) -> None:
|
|
59
|
+
"""Setup episode selection controls."""
|
|
60
|
+
episode_frame = ttk.Frame(parent)
|
|
61
|
+
episode_frame.pack(fill=tk.X, pady=(0, 5))
|
|
62
|
+
|
|
63
|
+
# Episode selection
|
|
64
|
+
ttk.Label(episode_frame, text="Episode:").pack(side=tk.LEFT)
|
|
65
|
+
|
|
66
|
+
episode_spinbox = ttk.Spinbox(
|
|
67
|
+
episode_frame,
|
|
68
|
+
from_=0,
|
|
69
|
+
to=max(0, self.total_episodes - 1),
|
|
70
|
+
textvariable=self.episode_var,
|
|
71
|
+
width=10,
|
|
72
|
+
command=self._on_episode_spinbox_change
|
|
73
|
+
)
|
|
74
|
+
episode_spinbox.pack(side=tk.LEFT, padx=(5, 10))
|
|
75
|
+
|
|
76
|
+
ttk.Button(
|
|
77
|
+
episode_frame,
|
|
78
|
+
text="Load Episode",
|
|
79
|
+
command=self._on_load_episode_button
|
|
80
|
+
).pack(side=tk.LEFT)
|
|
81
|
+
|
|
82
|
+
# Episode info display
|
|
83
|
+
episode_info_label = ttk.Label(
|
|
84
|
+
episode_frame,
|
|
85
|
+
textvariable=self.episode_info_var,
|
|
86
|
+
font=LABEL_FONT
|
|
87
|
+
)
|
|
88
|
+
episode_info_label.pack(side=tk.LEFT, padx=(20, 0))
|
|
89
|
+
|
|
90
|
+
def _setup_playback_controls(self, parent: ttk.Frame) -> None:
|
|
91
|
+
"""Setup playback control widgets."""
|
|
92
|
+
playback_frame = ttk.Frame(parent)
|
|
93
|
+
playback_frame.pack(fill=tk.X, pady=(5, 0))
|
|
94
|
+
|
|
95
|
+
# Playback buttons
|
|
96
|
+
self.play_button = ttk.Button(
|
|
97
|
+
playback_frame,
|
|
98
|
+
text="Play",
|
|
99
|
+
command=self._on_toggle_playback_button
|
|
100
|
+
)
|
|
101
|
+
self.play_button.pack(side=tk.LEFT)
|
|
102
|
+
|
|
103
|
+
ttk.Button(
|
|
104
|
+
playback_frame,
|
|
105
|
+
text="Stop",
|
|
106
|
+
command=self._on_stop_playback_button
|
|
107
|
+
).pack(side=tk.LEFT, padx=(5, 0))
|
|
108
|
+
|
|
109
|
+
ttk.Button(
|
|
110
|
+
playback_frame,
|
|
111
|
+
text="Step Back",
|
|
112
|
+
command=self._on_step_backward_button
|
|
113
|
+
).pack(side=tk.LEFT, padx=(5, 0))
|
|
114
|
+
|
|
115
|
+
ttk.Button(
|
|
116
|
+
playback_frame,
|
|
117
|
+
text="Step Forward",
|
|
118
|
+
command=self._on_step_forward_button
|
|
119
|
+
).pack(side=tk.LEFT, padx=(5, 0))
|
|
120
|
+
|
|
121
|
+
# Speed control
|
|
122
|
+
self._setup_speed_control(playback_frame)
|
|
123
|
+
|
|
124
|
+
# Frame info display
|
|
125
|
+
frame_info_label = ttk.Label(
|
|
126
|
+
playback_frame,
|
|
127
|
+
textvariable=self.frame_var,
|
|
128
|
+
font=LABEL_FONT
|
|
129
|
+
)
|
|
130
|
+
frame_info_label.pack(side=tk.RIGHT)
|
|
131
|
+
|
|
132
|
+
def _setup_speed_control(self, parent: ttk.Frame) -> None:
|
|
133
|
+
"""Setup speed control widgets."""
|
|
134
|
+
ttk.Label(parent, text="Speed:").pack(side=tk.LEFT, padx=(20, 5))
|
|
135
|
+
|
|
136
|
+
speed_scale = ttk.Scale(
|
|
137
|
+
parent,
|
|
138
|
+
from_=MIN_PLAYBACK_SPEED,
|
|
139
|
+
to=MAX_PLAYBACK_SPEED,
|
|
140
|
+
variable=self.speed_var,
|
|
141
|
+
orient=tk.HORIZONTAL,
|
|
142
|
+
length=120,
|
|
143
|
+
command=self._on_speed_scale_change
|
|
144
|
+
)
|
|
145
|
+
speed_scale.pack(side=tk.LEFT)
|
|
146
|
+
|
|
147
|
+
self.speed_label = ttk.Label(parent, text="1.0x")
|
|
148
|
+
self.speed_label.pack(side=tk.LEFT, padx=(5, 0))
|
|
149
|
+
|
|
150
|
+
# Callback wrappers
|
|
151
|
+
def _on_episode_spinbox_change(self):
|
|
152
|
+
"""Handle episode spinbox change."""
|
|
153
|
+
pass # Called by spinbox, but we use the button instead
|
|
154
|
+
|
|
155
|
+
def _on_load_episode_button(self):
|
|
156
|
+
"""Handle load episode button click."""
|
|
157
|
+
if self.on_load_episode:
|
|
158
|
+
self.on_load_episode()
|
|
159
|
+
|
|
160
|
+
def _on_toggle_playback_button(self):
|
|
161
|
+
"""Handle play/pause button click."""
|
|
162
|
+
if self.on_toggle_playback:
|
|
163
|
+
self.on_toggle_playback()
|
|
164
|
+
|
|
165
|
+
def _on_stop_playback_button(self):
|
|
166
|
+
"""Handle stop button click."""
|
|
167
|
+
if self.on_stop_playback:
|
|
168
|
+
self.on_stop_playback()
|
|
169
|
+
|
|
170
|
+
def _on_step_forward_button(self):
|
|
171
|
+
"""Handle step forward button click."""
|
|
172
|
+
if self.on_step_forward:
|
|
173
|
+
self.on_step_forward()
|
|
174
|
+
|
|
175
|
+
def _on_step_backward_button(self):
|
|
176
|
+
"""Handle step backward button click."""
|
|
177
|
+
if self.on_step_backward:
|
|
178
|
+
self.on_step_backward()
|
|
179
|
+
|
|
180
|
+
def _on_speed_scale_change(self, value):
|
|
181
|
+
"""Handle speed scale change."""
|
|
182
|
+
if self.on_speed_change:
|
|
183
|
+
self.on_speed_change(value)
|
|
184
|
+
|
|
185
|
+
# Public methods for updating display
|
|
186
|
+
def update_play_button_text(self, text: str) -> None:
|
|
187
|
+
"""Update the play button text."""
|
|
188
|
+
if self.play_button:
|
|
189
|
+
self.play_button.configure(text=text)
|
|
190
|
+
|
|
191
|
+
def update_speed_display(self, speed_text: str) -> None:
|
|
192
|
+
"""Update the speed display label."""
|
|
193
|
+
if self.speed_label:
|
|
194
|
+
self.speed_label.configure(text=speed_text)
|
|
195
|
+
|
|
196
|
+
def update_episode_info(self, info_text: str) -> None:
|
|
197
|
+
"""Update the episode information display."""
|
|
198
|
+
self.episode_info_var.set(info_text)
|
|
199
|
+
|
|
200
|
+
def update_frame_info(self, frame_text: str) -> None:
|
|
201
|
+
"""Update the frame information display."""
|
|
202
|
+
self.frame_var.set(frame_text)
|
|
203
|
+
|
|
204
|
+
def get_episode_index(self) -> int:
|
|
205
|
+
"""Get the currently selected episode index."""
|
|
206
|
+
try:
|
|
207
|
+
return int(self.episode_var.get())
|
|
208
|
+
except ValueError:
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
def get_playback_speed(self) -> float:
|
|
212
|
+
"""Get the current playback speed."""
|
|
213
|
+
return self.speed_var.get()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TimelineComponent:
|
|
217
|
+
"""Timeline scrubber component for frame navigation."""
|
|
218
|
+
|
|
219
|
+
def __init__(self, parent_frame: ttk.Frame):
|
|
220
|
+
"""
|
|
221
|
+
Initialize the timeline component.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
parent_frame: Parent frame to contain the timeline
|
|
225
|
+
"""
|
|
226
|
+
self.parent_frame = parent_frame
|
|
227
|
+
self.timeline_scale: Optional[ttk.Scale] = None
|
|
228
|
+
self.on_timeline_change: Optional[Callable] = None
|
|
229
|
+
|
|
230
|
+
self._setup_timeline()
|
|
231
|
+
|
|
232
|
+
def _setup_timeline(self) -> None:
|
|
233
|
+
"""Setup the timeline scrubber."""
|
|
234
|
+
timeline_frame = ttk.LabelFrame(self.parent_frame, text="Timeline", padding=10)
|
|
235
|
+
timeline_frame.pack(fill=tk.X, pady=(10, 0))
|
|
236
|
+
|
|
237
|
+
self.timeline_scale = ttk.Scale(
|
|
238
|
+
timeline_frame,
|
|
239
|
+
from_=0,
|
|
240
|
+
to=100,
|
|
241
|
+
orient=tk.HORIZONTAL,
|
|
242
|
+
command=self._on_timeline_scale_change
|
|
243
|
+
)
|
|
244
|
+
self.timeline_scale.pack(fill=tk.X)
|
|
245
|
+
|
|
246
|
+
def _on_timeline_scale_change(self, value):
|
|
247
|
+
"""Handle timeline scale change."""
|
|
248
|
+
if self.on_timeline_change:
|
|
249
|
+
self.on_timeline_change(value)
|
|
250
|
+
|
|
251
|
+
def set_range(self, max_frame: int) -> None:
|
|
252
|
+
"""Set the maximum range of the timeline."""
|
|
253
|
+
if self.timeline_scale:
|
|
254
|
+
self.timeline_scale.configure(to=max_frame)
|
|
255
|
+
|
|
256
|
+
def set_position(self, frame: int) -> None:
|
|
257
|
+
"""Set the current position of the timeline."""
|
|
258
|
+
if self.timeline_scale:
|
|
259
|
+
self.timeline_scale.set(frame)
|
|
260
|
+
|
|
261
|
+
def get_position(self) -> float:
|
|
262
|
+
"""Get the current position of the timeline."""
|
|
263
|
+
if self.timeline_scale:
|
|
264
|
+
return self.timeline_scale.get()
|
|
265
|
+
return 0.0
|