lerobotberto 0.1.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,7 @@
1
+ def main():
2
+ from lerobotberto.gui import App
3
+ App().mainloop()
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
lerobotberto/gui.py ADDED
@@ -0,0 +1,1167 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Interfaz gráfica para lanzar dos políticas entrenadas con LeRobot
4
+ (mediante `lerobot-rollout`):
5
+ 1) Insertar cable rojo en el portafusibles
6
+ 2) Extraer fusible del portafusibles
7
+
8
+ Incluye una vista con la imagen de TU portafusibles (ruta configurable) y una
9
+ animación superpuesta del cable y del fusible mientras se ejecuta cada tarea.
10
+
11
+ Dependencias: solo tkinter. Para JPG y para escalar bien la imagen se
12
+ recomienda Pillow: pip install pillow (sin Pillow solo se admite PNG/GIF).
13
+
14
+ Control por voz (opcional):
15
+ pip install SpeechRecognition pyaudio
16
+ sudo apt install portaudio19-dev flac espeak-ng (Ubuntu/Debian)
17
+ El reconocimiento usa el servicio de Google, por lo que necesita internet.
18
+
19
+ Uso: python lerobot_gui.py
20
+ """
21
+ import json
22
+ import math
23
+ import os
24
+ import re
25
+ import shlex
26
+ import shutil
27
+ import signal
28
+ import subprocess
29
+ import threading
30
+ import time
31
+ import unicodedata
32
+ import tkinter as tk
33
+ from tkinter import ttk, scrolledtext, messagebox, filedialog
34
+
35
+ try:
36
+ from PIL import Image, ImageTk
37
+ HAS_PIL = True
38
+ RESAMPLE = getattr(Image, "Resampling", Image).LANCZOS
39
+ except ImportError:
40
+ HAS_PIL = False
41
+
42
+ try:
43
+ import speech_recognition as sr
44
+ HAS_SR = True
45
+ except ImportError:
46
+ HAS_SR = False
47
+
48
+ CONFIG_PATH = os.path.expanduser("~/.lerobot_gui.json")
49
+ LOG_PATH = os.path.expanduser("~/lerobot_gui.log")
50
+ PERIOD = 4.0 # segundos que dura un ciclo de animación
51
+ FRAME_MS = 33 # ~30 fps
52
+
53
+ DEFAULT_CONFIG = {
54
+ "cfg_version": 4,
55
+ "robot_args": (
56
+ '--robot.type=so101_follower '
57
+ '--robot.port=/dev/ttyACM1 '
58
+ '--robot.id=follower_arm '
59
+ '--robot.cameras="{ robot: {type: opencv, index_or_path: /dev/video4, '
60
+ 'width: 320, height: 240, fps: 30}, top: {type: opencv, '
61
+ 'index_or_path: /dev/video0, width: 320, height: 240, fps: 30}}"'
62
+ ),
63
+ "episode_time_s": 120, # --duration
64
+ "device": "cpu", # --device
65
+ "conda_env": "lerobot", # solo se usa si lerobot-rollout no está en el PATH
66
+ # --- Vista del portafusibles
67
+ "image_path": "",
68
+ # Puntos en coordenadas relativas a la imagen (0..1). Se calibran con clic.
69
+ "points": {
70
+ "hole": [0.50, 0.32], # agujero de conexión (arriba)
71
+ "fuse": [0.50, 0.62], # fusible (abajo)
72
+ "cable_start": [0.14, 0.12], # de dónde sale el cable
73
+ },
74
+ "voice": {"lang": "es-ES", "mic": "", "confirm": True, "speak": True},
75
+ "anim_scale": 1.0,
76
+ "show_points": True,
77
+ "overlay_idle": True,
78
+ "tasks": {
79
+ "insertar": {
80
+ "label": "Insertar cable rojo",
81
+ "policy_path": "BertoTrujillo/act_superinscabmerged",
82
+ "task_text": "Insert the cable into its connector.",
83
+ },
84
+ "extraer": {
85
+ "label": "Extraer fusible",
86
+ "policy_path": "$HOME/lerobot_models/act_superextractfuse",
87
+ "task_text": "Extract the fuse from its holder.",
88
+ },
89
+ },
90
+ }
91
+
92
+
93
+ def load_config():
94
+ cfg = json.loads(json.dumps(DEFAULT_CONFIG))
95
+ if os.path.exists(CONFIG_PATH):
96
+ try:
97
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
98
+ data = json.load(f)
99
+ if data.get("cfg_version") != DEFAULT_CONFIG["cfg_version"]:
100
+ # Config de una versión anterior: se conservan solo imagen y opciones
101
+ for k in ("robot_args", "episode_time_s", "tasks", "points"):
102
+ data.pop(k, None)
103
+ for k, v in data.items():
104
+ if k == "tasks":
105
+ for key, val in v.items():
106
+ cfg["tasks"].setdefault(key, {}).update(val)
107
+ elif k == "points":
108
+ cfg["points"].update(v)
109
+ else:
110
+ cfg[k] = v
111
+ except Exception:
112
+ pass
113
+ return cfg
114
+
115
+
116
+ def save_config(cfg):
117
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
118
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
119
+
120
+
121
+ # ------------------------------------------------------- comandos de voz
122
+ STOP_STRONG = {"detente", "detener", "alto", "basta", "emergencia", "stop", "halt",
123
+ "pare", "paro"}
124
+ STOP_SHORT = {"para", "parar"} # solo si es una frase muy corta
125
+ YES_WORDS = {"si", "confirmo", "confirmado", "vale", "adelante", "ok", "okay", "dale",
126
+ "afirmativo", "yes", "confirm", "go"}
127
+ NO_WORDS = {"no", "cancela", "cancelar", "cancelado", "negativo", "nada", "cancel"}
128
+ INS_VERBS = {"inserta", "insertar", "inserte", "insertas", "mete", "meter", "conecta",
129
+ "conectar", "enchufa", "enchufar", "insert", "plug", "connect"}
130
+ EXT_VERBS = {"extrae", "extraer", "extraiga", "saca", "sacar", "quita", "quitar",
131
+ "retira", "retirar", "extract", "remove", "pull"}
132
+ RESET_VERBS = {"reinicia", "reiniciar", "reset", "resetea", "restablece"}
133
+
134
+
135
+ def _audio_level(audio):
136
+ """Nivel RMS (16 bits) de un AudioData; comparable con r.energy_threshold."""
137
+ try:
138
+ import array
139
+ raw = audio.get_raw_data(convert_rate=16000, convert_width=2)
140
+ a = array.array("h", raw)
141
+ if not a:
142
+ return 0
143
+ return int((sum(x * x for x in a) / len(a)) ** 0.5)
144
+ except Exception:
145
+ return -1
146
+
147
+
148
+ def _words(text):
149
+ t = unicodedata.normalize("NFD", text.lower())
150
+ t = "".join(ch for ch in t if unicodedata.category(ch) != "Mn")
151
+ return re.findall(r"[a-z0-9]+", t)
152
+
153
+
154
+ def parse_voice(text):
155
+ """Devuelve: 'stop', 'yes', 'no', 'insertar', 'extraer', 'reset' o None."""
156
+ w = _words(text)
157
+ ws = set(w)
158
+ if not w:
159
+ return None
160
+ if ws & STOP_STRONG or (len(w) <= 2 and ws & STOP_SHORT):
161
+ return "stop"
162
+ if len(w) <= 3 and ws & NO_WORDS:
163
+ return "no"
164
+ if len(w) <= 3 and ws & YES_WORDS:
165
+ return "yes"
166
+ ins = bool(ws & INS_VERBS) and bool(ws & {"cable", "cables"})
167
+ ext = bool(ws & EXT_VERBS) and bool(ws & {"fusible", "fusibles", "fuse", "fuses"})
168
+ if ins and not ext:
169
+ return "insertar"
170
+ if ext and not ins:
171
+ return "extraer"
172
+ if ws & RESET_VERBS and ws & {"vista", "view"}:
173
+ return "reset"
174
+ return None
175
+
176
+
177
+ # ------------------------------------------------------------- utilidades
178
+ def ease(t):
179
+ t = max(0.0, min(1.0, t))
180
+ return t * t * (3 - 2 * t)
181
+
182
+
183
+ def lerp(a, b, t):
184
+ return a + (b - a) * t
185
+
186
+
187
+ def lerp_pt(p, q, t):
188
+ return (lerp(p[0], q[0], t), lerp(p[1], q[1], t))
189
+
190
+
191
+ def window(t, a, b):
192
+ """0 antes de a, 1 después de b, suave entre medias."""
193
+ if t <= a:
194
+ return 0.0
195
+ if t >= b:
196
+ return 1.0
197
+ return ease((t - a) / (b - a))
198
+
199
+
200
+ class App(tk.Tk):
201
+ def __init__(self):
202
+ super().__init__()
203
+ self.title("LeRobot · Panel de control portafusibles")
204
+ self.geometry("1260x880")
205
+ self.minsize(1000, 720)
206
+
207
+ self.cfg = load_config()
208
+ self.proc = None
209
+ self.current_task = None
210
+ self.t_start = 0.0
211
+ self.t_anim0 = 0.0
212
+ self.task_vars = {}
213
+
214
+ # Estado visual de la escena
215
+ self.cable_in = False
216
+ self.fuse_out = False
217
+ self.calib_mode = None
218
+ self.raw = None
219
+ self.photo = None
220
+ self.img_rect = None
221
+ self._resize_job = None
222
+
223
+ # Estado del control por voz
224
+ self.voice_on = False
225
+ self.pending = None # (tarea, caducidad) a la espera de «sí»
226
+ self.speaking = False
227
+ self.last_speech_end = 0.0
228
+ self._speak_n = 0
229
+ self._speak_lock = threading.Lock()
230
+ self._voice_lang = "es-ES"
231
+ self._voice_mic_name = ""
232
+
233
+ self._build_style()
234
+ self._build_ui()
235
+ self.load_image()
236
+ self._tick()
237
+ self._anim_loop()
238
+ self.protocol("WM_DELETE_WINDOW", self.on_close)
239
+
240
+ # ------------------------------------------------------------------ UI
241
+ def _build_style(self):
242
+ style = ttk.Style(self)
243
+ try:
244
+ style.theme_use("clam")
245
+ except tk.TclError:
246
+ pass
247
+ style.configure("Big.TButton", font=("Helvetica", 14, "bold"), padding=14)
248
+ style.configure("Stop.TButton", font=("Helvetica", 14, "bold"), padding=14,
249
+ foreground="#b00020")
250
+ style.configure("Status.TLabel", font=("Helvetica", 13, "bold"))
251
+
252
+ def _build_ui(self):
253
+ root = ttk.Frame(self, padding=10)
254
+ root.pack(fill="both", expand=True)
255
+
256
+ # --- Estado (arriba)
257
+ top = ttk.Frame(root)
258
+ top.pack(fill="x", side="top")
259
+ self.status_dot = tk.Canvas(top, width=22, height=22, highlightthickness=0)
260
+ self.status_dot.pack(side="left")
261
+ self.dot = self.status_dot.create_oval(3, 3, 19, 19, fill="#9e9e9e", outline="")
262
+ self.status_lbl = ttk.Label(top, text="Parado", style="Status.TLabel")
263
+ self.status_lbl.pack(side="left", padx=8)
264
+ self.timer_lbl = ttk.Label(top, text="00:00", font=("Helvetica", 13))
265
+ self.timer_lbl.pack(side="right")
266
+
267
+ # --- Log (abajo)
268
+ log_frame = ttk.LabelFrame(root, text="Salida de LeRobot", padding=6)
269
+ log_frame.pack(fill="x", side="bottom", pady=(8, 0))
270
+ bar = ttk.Frame(log_frame)
271
+ bar.pack(fill="x", pady=(0, 4))
272
+ ttk.Button(bar, text="Copiar todo el log", command=self.copy_log).pack(side="left")
273
+ ttk.Button(bar, text="Limpiar", command=self.clear_log).pack(side="left", padx=6)
274
+ ttk.Label(bar, text=f"(también se guarda en {LOG_PATH})").pack(side="left", padx=6)
275
+ self.log = scrolledtext.ScrolledText(log_frame, height=9,
276
+ font=("Courier", 10),
277
+ bg="#111", fg="#d6d6d6",
278
+ insertbackground="#d6d6d6")
279
+ self.log.pack(fill="both", expand=True)
280
+ # Solo lectura, pero se puede seleccionar y copiar (Ctrl+C / Ctrl+A / clic derecho)
281
+ self.log.bind("<Key>", self._log_key)
282
+ for ev in ("<<Paste>>", "<<Cut>>"):
283
+ self.log.bind(ev, lambda e: "break")
284
+ self.log.bind("<Button-1>", lambda e: self.log.focus_set())
285
+ self.log_menu = tk.Menu(self, tearoff=0)
286
+ self.log_menu.add_command(label="Copiar selección", command=lambda: self.copy_log(False))
287
+ self.log_menu.add_command(label="Copiar todo", command=self.copy_log)
288
+ self.log.bind("<Button-3>", lambda e: self.log_menu.tk_popup(e.x_root, e.y_root))
289
+
290
+ # --- Zona central: vista + controles
291
+ mid = ttk.Frame(root)
292
+ mid.pack(fill="both", expand=True, pady=(8, 0))
293
+ mid.columnconfigure(0, weight=3)
294
+ mid.columnconfigure(1, weight=2)
295
+ mid.rowconfigure(0, weight=1)
296
+
297
+ self._build_visual(mid)
298
+ self._build_controls(mid)
299
+
300
+ def _build_visual(self, parent):
301
+ vis = ttk.LabelFrame(parent, text="Vista del portafusibles", padding=6)
302
+ vis.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
303
+
304
+ self.canvas = tk.Canvas(vis, width=640, height=440, bg="#1e1e1e",
305
+ highlightthickness=0)
306
+ self.canvas.pack(fill="both", expand=True)
307
+ self.canvas.bind("<Configure>", self._on_canvas_resize)
308
+ self.canvas.bind("<Button-1>", self._on_canvas_click)
309
+
310
+ # Ruta de imagen
311
+ row = ttk.Frame(vis)
312
+ row.pack(fill="x", pady=(6, 0))
313
+ ttk.Label(row, text="Imagen:").pack(side="left")
314
+ self.img_path_var = tk.StringVar(value=self.cfg.get("image_path", ""))
315
+ ent = ttk.Entry(row, textvariable=self.img_path_var)
316
+ ent.pack(side="left", fill="x", expand=True, padx=6)
317
+ ent.bind("<Return>", lambda e: self.load_image())
318
+ ttk.Button(row, text="Examinar…", command=self.browse_image).pack(side="left")
319
+ ttk.Button(row, text="Cargar", command=self.load_image).pack(side="left", padx=(4, 0))
320
+
321
+ # Calibración
322
+ cal = ttk.Frame(vis)
323
+ cal.pack(fill="x", pady=(6, 0))
324
+ ttk.Label(cal, text="Calibrar (clic en la imagen):").pack(side="left")
325
+ for mode, txt in (("hole", "Agujero"), ("fuse", "Fusible"),
326
+ ("cable_start", "Origen cable")):
327
+ ttk.Button(cal, text=txt, command=lambda m=mode: self.start_calib(m))\
328
+ .pack(side="left", padx=3)
329
+
330
+ opts = ttk.Frame(vis)
331
+ opts.pack(fill="x", pady=(6, 0))
332
+ self.show_points = tk.BooleanVar(value=bool(self.cfg.get("show_points", True)))
333
+ self.overlay_idle = tk.BooleanVar(value=bool(self.cfg.get("overlay_idle", True)))
334
+ ttk.Checkbutton(opts, text="Mostrar puntos", variable=self.show_points)\
335
+ .pack(side="left")
336
+ ttk.Checkbutton(opts, text="Dibujar cable/fusible en reposo",
337
+ variable=self.overlay_idle).pack(side="left", padx=10)
338
+ ttk.Label(opts, text="Tamaño:").pack(side="left")
339
+ self.scale_var = tk.DoubleVar(value=float(self.cfg.get("anim_scale", 1.0)))
340
+ ttk.Spinbox(opts, from_=0.3, to=4.0, increment=0.1, width=5,
341
+ textvariable=self.scale_var,
342
+ command=self._render_static).pack(side="left", padx=4)
343
+ ttk.Button(opts, text="Reiniciar vista", command=self.reset_view)\
344
+ .pack(side="right")
345
+
346
+ def _build_controls(self, parent):
347
+ ctrl = ttk.Frame(parent)
348
+ ctrl.grid(row=0, column=1, sticky="nsew")
349
+
350
+ actions = ttk.LabelFrame(ctrl, text="Acciones", padding=8)
351
+ actions.pack(fill="x")
352
+ actions.columnconfigure(0, weight=1)
353
+ self.buttons = {}
354
+ for r, (key, t) in enumerate(self.cfg["tasks"].items()):
355
+ b = ttk.Button(actions, text=f"▶ {t['label']}", style="Big.TButton",
356
+ command=lambda k=key: self.start_task(k))
357
+ b.grid(row=r, column=0, sticky="ew", pady=4)
358
+ self.buttons[key] = b
359
+ self.stop_btn = ttk.Button(actions, text="■ PARAR", style="Stop.TButton",
360
+ command=self.stop_task, state="disabled")
361
+ self.stop_btn.grid(row=len(self.buttons), column=0, sticky="ew", pady=4)
362
+
363
+ self._build_voice(ctrl)
364
+
365
+ nb = ttk.Notebook(ctrl)
366
+ nb.pack(fill="x", pady=8)
367
+
368
+ tab_general = ttk.Frame(nb, padding=8)
369
+ nb.add(tab_general, text="Robot")
370
+ ttk.Label(tab_general, text="Argumentos del robot (cámaras, puerto, id…):")\
371
+ .pack(anchor="w")
372
+ self.robot_txt = tk.Text(tab_general, height=6, wrap="word")
373
+ self.robot_txt.pack(fill="x", pady=4)
374
+ self.robot_txt.insert("1.0", self.cfg["robot_args"])
375
+ row = ttk.Frame(tab_general)
376
+ row.pack(fill="x")
377
+ ttk.Label(row, text="Duración (s):").pack(side="left")
378
+ self.ep_time = tk.IntVar(value=self.cfg["episode_time_s"])
379
+ ttk.Spinbox(row, from_=5, to=600, textvariable=self.ep_time, width=6)\
380
+ .pack(side="left", padx=6)
381
+ ttk.Label(row, text="Dispositivo:").pack(side="left", padx=(8, 0))
382
+ self.device_var = tk.StringVar(value=self.cfg.get("device", "cpu"))
383
+ ttk.Combobox(row, textvariable=self.device_var, width=7,
384
+ values=("cpu", "cuda", "mps")).pack(side="left", padx=6)
385
+ row2 = ttk.Frame(tab_general)
386
+ row2.pack(fill="x", pady=(6, 0))
387
+ ttk.Label(row2, text="Entorno conda (si no está activado):").pack(side="left")
388
+ self.conda_var = tk.StringVar(value=self.cfg.get("conda_env", "lerobot"))
389
+ ttk.Entry(row2, textvariable=self.conda_var, width=14).pack(side="left", padx=6)
390
+
391
+ for key, t in self.cfg["tasks"].items():
392
+ tab = ttk.Frame(nb, padding=8)
393
+ nb.add(tab, text=t["label"])
394
+ tab.columnconfigure(0, weight=1)
395
+ vars_ = {}
396
+ for r, (field, lbl) in enumerate([
397
+ ("policy_path", "Carpeta de la política (--policy.path):"),
398
+ ("task_text", "Descripción de la tarea (--task):"),
399
+ ]):
400
+ ttk.Label(tab, text=lbl).grid(row=2 * r, column=0, sticky="w", pady=(4, 0))
401
+ v = tk.StringVar(value=t[field])
402
+ ttk.Entry(tab, textvariable=v).grid(row=2 * r + 1, column=0, sticky="ew")
403
+ vars_[field] = v
404
+ self.task_vars[key] = vars_
405
+
406
+ save_row = ttk.Frame(ctrl)
407
+ save_row.pack(fill="x")
408
+ ttk.Button(save_row, text="Guardar ajustes", command=self.save_settings)\
409
+ .pack(side="left")
410
+ ttk.Button(save_row, text="Limpiar log", command=self.clear_log)\
411
+ .pack(side="left", padx=6)
412
+
413
+ # ------------------------------------------------------------- helpers
414
+ def write_log(self, text):
415
+ self.log.insert("end", text)
416
+ self.log.see("end")
417
+ try:
418
+ with open(LOG_PATH, "a", encoding="utf-8") as f:
419
+ f.write(text)
420
+ except OSError:
421
+ pass
422
+
423
+ def clear_log(self):
424
+ self.log.delete("1.0", "end")
425
+
426
+ def _log_key(self, event):
427
+ # Permite Ctrl+C, Ctrl+A y desplazamiento; bloquea cualquier edición
428
+ if event.state & 0x4 and event.keysym.lower() in ("c", "a", "insert"):
429
+ if event.keysym.lower() == "a":
430
+ self.log.tag_add("sel", "1.0", "end-1c")
431
+ return "break"
432
+ return None
433
+ if event.keysym in ("Up", "Down", "Left", "Right", "Prior", "Next",
434
+ "Home", "End"):
435
+ return None
436
+ return "break"
437
+
438
+ def copy_log(self, everything=True):
439
+ try:
440
+ text = self.log.get("sel.first", "sel.last") if not everything \
441
+ else self.log.get("1.0", "end-1c")
442
+ except tk.TclError:
443
+ text = self.log.get("1.0", "end-1c")
444
+ self.clipboard_clear()
445
+ self.clipboard_append(text)
446
+ self.update()
447
+
448
+ def set_status(self, text, color):
449
+ self.status_lbl.config(text=text)
450
+ self.status_dot.itemconfig(self.dot, fill=color)
451
+
452
+ def save_settings(self, quiet=False):
453
+ self.cfg["robot_args"] = self.robot_txt.get("1.0", "end").strip()
454
+ self.cfg["episode_time_s"] = int(self.ep_time.get())
455
+ self.cfg["device"] = self.device_var.get().strip() or "cpu"
456
+ self.cfg["conda_env"] = self.conda_var.get().strip()
457
+ self.cfg["image_path"] = self.img_path_var.get().strip()
458
+ self.cfg["show_points"] = bool(self.show_points.get())
459
+ self.cfg["overlay_idle"] = bool(self.overlay_idle.get())
460
+ try:
461
+ self.cfg["anim_scale"] = float(self.scale_var.get())
462
+ except (tk.TclError, ValueError):
463
+ pass
464
+ for key, vars_ in self.task_vars.items():
465
+ for field, v in vars_.items():
466
+ self.cfg["tasks"][key][field] = v.get().strip()
467
+ mic = self.voice_mic.get().strip()
468
+ self.cfg["voice"] = {
469
+ "lang": self.voice_lang.get().strip() or "es-ES",
470
+ "mic": "" if mic in ("", "Predeterminado") else mic,
471
+ "confirm": bool(self.voice_confirm.get()),
472
+ "speak": bool(self.voice_speak.get()),
473
+ }
474
+ save_config(self.cfg)
475
+ if not quiet:
476
+ self.write_log(f"[GUI] Ajustes guardados en {CONFIG_PATH}\n")
477
+
478
+ def build_command(self, key):
479
+ t = self.task_vars[key]
480
+ policy = os.path.expandvars(os.path.expanduser(t["policy_path"].get().strip()))
481
+ cmd = ["lerobot-rollout"]
482
+ cmd += shlex.split(self.robot_txt.get("1.0", "end").strip())
483
+ cmd += [
484
+ f"--policy.path={policy}",
485
+ f"--task={t['task_text'].get().strip()}",
486
+ f"--duration={int(self.ep_time.get())}",
487
+ f"--device={self.device_var.get().strip() or 'cpu'}",
488
+ ]
489
+ # Si la GUI no se lanzó desde el entorno activado, usar conda run
490
+ env = self.conda_var.get().strip()
491
+ if shutil.which("lerobot-rollout") is None and env and shutil.which("conda"):
492
+ cmd = ["conda", "run", "--no-capture-output", "-n", env] + cmd
493
+ return cmd
494
+
495
+ # ------------------------------------------------------ imagen / vista
496
+ def browse_image(self):
497
+ types = [("Imágenes", "*.png *.gif *.jpg *.jpeg *.bmp *.webp"),
498
+ ("Todos", "*.*")] if HAS_PIL else [("PNG / GIF", "*.png *.gif"),
499
+ ("Todos", "*.*")]
500
+ p = filedialog.askopenfilename(title="Imagen del portafusibles", filetypes=types)
501
+ if p:
502
+ self.img_path_var.set(p)
503
+ self.load_image()
504
+
505
+ def load_image(self):
506
+ path = self.img_path_var.get().strip()
507
+ self.raw = None
508
+ self.photo = None
509
+ if path:
510
+ if not os.path.isfile(path):
511
+ self.write_log(f"[GUI] Imagen no encontrada: {path}\n")
512
+ else:
513
+ try:
514
+ self.raw = Image.open(path).convert("RGB") if HAS_PIL \
515
+ else tk.PhotoImage(file=path)
516
+ except Exception as e:
517
+ self.raw = None
518
+ hint = "" if HAS_PIL else \
519
+ " (sin Pillow solo se admiten PNG/GIF: pip install pillow)"
520
+ self.write_log(f"[GUI] No se pudo abrir la imagen: {e}{hint}\n")
521
+ self.cfg["image_path"] = path
522
+ self._render_static()
523
+
524
+ def _on_canvas_resize(self, _event):
525
+ if self._resize_job is not None:
526
+ self.after_cancel(self._resize_job)
527
+ self._resize_job = self.after(120, self._render_static)
528
+
529
+ def _render_static(self):
530
+ self._resize_job = None
531
+ c = self.canvas
532
+ c.delete("bg")
533
+ cw = max(100, c.winfo_width())
534
+ ch = max(100, c.winfo_height())
535
+
536
+ if self.raw is not None:
537
+ if HAS_PIL:
538
+ w, h = self.raw.size
539
+ k = min(cw / w, ch / h)
540
+ nw, nh = max(1, int(w * k)), max(1, int(h * k))
541
+ self.photo = ImageTk.PhotoImage(self.raw.resize((nw, nh), RESAMPLE))
542
+ else:
543
+ w, h = self.raw.width(), self.raw.height()
544
+ f = max(1, math.ceil(max(w / cw, h / ch)))
545
+ self.photo = self.raw.subsample(f)
546
+ nw, nh = self.photo.width(), self.photo.height()
547
+ x0, y0 = (cw - nw) // 2, (ch - nh) // 2
548
+ self.img_rect = (x0, y0, nw, nh)
549
+ c.create_image(x0, y0, image=self.photo, anchor="nw", tags="bg")
550
+ else:
551
+ # Esquema (sin imagen): base portafusibles cuadrada,
552
+ # agujero de conexión arriba y fusible abajo
553
+ w = min(cw, ch * 4 / 3)
554
+ h = w * 3 / 4
555
+ x0, y0 = (cw - w) / 2, (ch - h) / 2
556
+ self.img_rect = (x0, y0, w, h)
557
+ c.create_rectangle(x0, y0, x0 + w, y0 + h, fill="#eceff1", outline="",
558
+ tags="bg")
559
+ s = self._units()
560
+ H, F = self._P("hole"), self._P("fuse")
561
+ cx, cy = (H[0] + F[0]) / 2, (H[1] + F[1]) / 2
562
+ half = 0.31 * h
563
+ # cuadrado = base portafusibles
564
+ c.create_rectangle(cx - half, cy - half, cx + half, cy + half,
565
+ fill="#455a64", outline="#263238", width=4, tags="bg")
566
+ c.create_rectangle(cx - half + 10, cy - half + 10, cx + half - 10,
567
+ cy + half - 10, fill="#37474f", outline="#546e7a",
568
+ width=2, tags="bg")
569
+ c.create_text(cx, cy - half - 12, text="PORTAFUSIBLES", fill="#546e7a",
570
+ font=("Helvetica", 10, "bold"), tags="bg")
571
+ # agujero de conexión (arriba)
572
+ r = 14 * s
573
+ c.create_oval(H[0] - r * 1.5, H[1] - r * 1.5, H[0] + r * 1.5, H[1] + r * 1.5,
574
+ fill="#90a4ae", outline="#cfd8dc", width=2, tags="bg")
575
+ c.create_oval(H[0] - r, H[1] - r, H[0] + r, H[1] + r,
576
+ fill="#050505", outline="", tags="bg")
577
+ # hueco donde va el fusible
578
+ c.create_rectangle(F[0] - 14 * s, F[1] - 19 * s, F[0] + 14 * s, F[1] + 19 * s,
579
+ fill="#1b262c", outline="#78909c", width=2, tags="bg")
580
+ c.create_text(x0 + w / 2, y0 + h - 12, fill="#78909c", tags="bg",
581
+ font=("Helvetica", 9),
582
+ text="Sin imagen · indica una ruta abajo y pulsa Cargar")
583
+ c.tag_lower("bg")
584
+
585
+ # ---------------------------------------------------------- calibración
586
+ def start_calib(self, mode):
587
+ self.calib_mode = mode
588
+ self.canvas.config(cursor="crosshair")
589
+ self.show_points.set(True)
590
+
591
+ def _on_canvas_click(self, ev):
592
+ if not self.calib_mode or not self.img_rect:
593
+ return
594
+ x0, y0, w, h = self.img_rect
595
+ fx = min(1.0, max(0.0, (ev.x - x0) / w))
596
+ fy = min(1.0, max(0.0, (ev.y - y0) / h))
597
+ self.cfg["points"][self.calib_mode] = [round(fx, 4), round(fy, 4)]
598
+ names = {"hole": "agujero", "fuse": "fusible", "cable_start": "origen del cable"}
599
+ self.write_log(f"[GUI] Punto «{names[self.calib_mode]}» fijado en "
600
+ f"({fx:.2f}, {fy:.2f})\n")
601
+ self.calib_mode = None
602
+ self.canvas.config(cursor="")
603
+ self.save_settings(quiet=True)
604
+ self._render_static()
605
+
606
+ def reset_view(self):
607
+ self.cable_in = False
608
+ self.fuse_out = False
609
+
610
+ # ---------------------------------------------------------- animación
611
+ def _units(self):
612
+ w = self.img_rect[2] if self.img_rect else 600
613
+ try:
614
+ k = float(self.scale_var.get())
615
+ except (tk.TclError, ValueError, AttributeError):
616
+ k = 1.0
617
+ base = (w / 600.0) * (1.7 if self.raw is None else 1.0)
618
+ return max(0.3, base * k)
619
+
620
+ def _P(self, key):
621
+ x0, y0, w, h = self.img_rect
622
+ px, py = self.cfg["points"][key]
623
+ return (x0 + px * w, y0 + py * h)
624
+
625
+ def _anim_loop(self):
626
+ try:
627
+ self._anim_frame()
628
+ finally:
629
+ self.after(FRAME_MS, self._anim_loop)
630
+
631
+ def _anim_frame(self):
632
+ c = self.canvas
633
+ c.delete("anim")
634
+ if self.img_rect is None:
635
+ return
636
+ s = self._units()
637
+ H = self._P("hole")
638
+ F = self._P("fuse")
639
+ S = self._P("cable_start")
640
+ run = self.current_task
641
+ t = ((time.time() - self.t_anim0) % PERIOD) / PERIOD if run else 0.0
642
+ has_img = self.raw is not None
643
+ draw_idle = self.overlay_idle.get()
644
+
645
+ D = 0.27 * self.img_rect[3] # recorrido de extracción (hacia abajo)
646
+ hole_in = (H[0], H[1] + 3 * s) # cable metido
647
+ cable_tip = hole_in if self.cable_in else S
648
+ fuse_dy = D if self.fuse_out else 0.0
649
+ gripper = None # (x, y, gap)
650
+ label = ""
651
+
652
+ if run == "insertar":
653
+ A = (H[0], H[1] - 60 * s)
654
+ gap_closed, gap_open = 5 * s, 13 * s
655
+ gap = gap_closed
656
+ gdy = 0.0
657
+ if t < 0.10:
658
+ cable_tip = S
659
+ label = "Agarrando el cable…"
660
+ elif t < 0.55:
661
+ u = ease((t - 0.10) / 0.45)
662
+ cable_tip = lerp_pt(S, A, u)
663
+ cable_tip = (cable_tip[0], cable_tip[1] - math.sin(math.pi * u) * 30 * s)
664
+ label = "Transportando el cable…"
665
+ elif t < 0.75:
666
+ cable_tip = lerp_pt(A, hole_in, ease((t - 0.55) / 0.20))
667
+ label = "Insertando en el agujero…"
668
+ elif t < 0.95:
669
+ cable_tip = hole_in
670
+ gap = lerp(gap_closed, gap_open, window(t, 0.75, 0.82))
671
+ gdy = -55 * s * window(t, 0.85, 0.95)
672
+ label = "Soltando el cable"
673
+ else:
674
+ cable_tip = lerp_pt(hole_in, S, ease((t - 0.95) / 0.05))
675
+ gap = gap_open
676
+ gdy = -55 * s
677
+ gripper = (cable_tip[0], cable_tip[1] - 18 * s + gdy, gap, False)
678
+
679
+ elif run == "extraer":
680
+ U = (F[0], F[1] + 140 * s) # la pinza llega desde abajo
681
+ G0 = (F[0], F[1] + 14 * s) # punto de agarre del fusible
682
+ gap_closed, gap_open = 23 * s, 34 * s
683
+ if t < 0.30:
684
+ g = lerp_pt(U, G0, ease(t / 0.30))
685
+ gap = gap_open
686
+ lift = 0.0
687
+ label = "Acercando la pinza al fusible…"
688
+ elif t < 0.40:
689
+ g = G0
690
+ gap = lerp(gap_open, gap_closed, window(t, 0.30, 0.40))
691
+ lift = 0.0
692
+ label = "Agarrando el fusible…"
693
+ elif t < 0.75:
694
+ lift = ease((t - 0.40) / 0.35)
695
+ g = (G0[0] + math.sin(lift * math.pi * 3) * 1.5 * s, G0[1] + D * lift)
696
+ gap = gap_closed
697
+ label = "Extrayendo el fusible…"
698
+ elif t < 0.88:
699
+ lift = 1.0
700
+ g = (G0[0], G0[1] + D)
701
+ gap = gap_closed
702
+ label = "Fusible quemado extraído"
703
+ else:
704
+ lift = 1.0 - ease((t - 0.88) / 0.12)
705
+ g = (G0[0], G0[1] + D * lift)
706
+ gap = gap_closed
707
+ label = "Fusible quemado extraído"
708
+ fuse_dy = D * lift
709
+ gripper = (g[0], g[1], gap, True)
710
+
711
+ # --- Marco del fusible (siempre visible)
712
+ self._draw_slot(c, F, s)
713
+
714
+ # --- Cable
715
+ show_cable = run == "insertar" or (draw_idle and (self.cable_in or not has_img)) \
716
+ or (run == "extraer" and self.cable_in) \
717
+ or (draw_idle and has_img)
718
+ if show_cable:
719
+ self._draw_cable(c, S, cable_tip, s)
720
+
721
+ # --- Fusible
722
+ moving = run == "extraer" or self.fuse_out
723
+ if moving or (draw_idle and not has_img):
724
+ self._draw_fuse(c, F[0], F[1] + fuse_dy, s)
725
+
726
+ # --- Pinza
727
+ if gripper:
728
+ self._draw_gripper(c, gripper[0], gripper[1], s, gripper[2], gripper[3])
729
+
730
+ # --- Texto de estado
731
+ if self.calib_mode:
732
+ names = {"hole": "agujero de conexión", "fuse": "fusible",
733
+ "cable_start": "origen del cable"}
734
+ label = f"Haz clic en la imagen: {names[self.calib_mode]}"
735
+ elif not run:
736
+ if self.cable_in and self.fuse_out:
737
+ label = "Cable insertado · fusible extraído"
738
+ elif self.cable_in:
739
+ label = "Cable insertado"
740
+ elif self.fuse_out:
741
+ label = "Fusible quemado extraído"
742
+ if label:
743
+ x0, y0 = self.img_rect[0], self.img_rect[1]
744
+ c.create_rectangle(x0 + 8, y0 + 8, x0 + 12 + 8.2 * len(label), y0 + 32,
745
+ fill="#000000", outline="", tags="anim")
746
+ c.create_text(x0 + 14, y0 + 20, text=label, anchor="w", fill="#ffffff",
747
+ font=("Helvetica", 11, "bold"), tags="anim")
748
+
749
+ # --- Puntos de calibración
750
+ if self.show_points.get() or self.calib_mode:
751
+ for key, col, txt in (("hole", "#ff5252", "Agujero"),
752
+ ("fuse", "#ffd740", "Fusible"),
753
+ ("cable_start", "#ff8a80", "Origen cable")):
754
+ px, py = self._P(key)
755
+ c.create_oval(px - 5, py - 5, px + 5, py + 5, outline=col, width=2,
756
+ tags="anim")
757
+ c.create_line(px - 10, py, px + 10, py, fill=col, tags="anim")
758
+ c.create_line(px, py - 10, px, py + 10, fill=col, tags="anim")
759
+ c.create_text(px + 10, py - 12, text=txt, fill=col, anchor="w",
760
+ font=("Helvetica", 9, "bold"), tags="anim")
761
+
762
+ # ----- dibujo de elementos
763
+ def _draw_slot(self, c, F, s):
764
+ c.create_rectangle(F[0] - 12 * s, F[1] - 16 * s, F[0] + 12 * s, F[1] + 16 * s,
765
+ outline="#fff59d", dash=(4, 3), width=2, tags="anim")
766
+
767
+ def _draw_cable(self, c, S, T, s):
768
+ mid = ((S[0] + T[0]) / 2, max(S[1], T[1]) + 30 * s)
769
+ pts = [S[0], S[1], mid[0], mid[1], T[0], T[1] - 12 * s]
770
+ c.create_line(*pts, smooth=True, width=9 * s, fill="#7f0000",
771
+ capstyle="round", tags="anim")
772
+ c.create_line(*pts, smooth=True, width=6 * s, fill="#e53935",
773
+ capstyle="round", tags="anim")
774
+ # terminal metálico
775
+ c.create_rectangle(T[0] - 4 * s, T[1] - 14 * s, T[0] + 4 * s, T[1],
776
+ fill="#c98a4b", outline="#5d4037", tags="anim")
777
+
778
+ def _draw_fuse(self, c, cx, cy, s):
779
+ w, h = 11 * s, 15 * s
780
+ # patillas
781
+ for dx in (-5 * s, 5 * s):
782
+ c.create_rectangle(cx + dx - 2 * s, cy + h, cx + dx + 2 * s, cy + h + 8 * s,
783
+ fill="#b0bec5", outline="#37474f", tags="anim")
784
+ # cuerpo
785
+ c.create_rectangle(cx - w, cy - h, cx + w, cy + h, fill="#fbc02d",
786
+ outline="#6d4c00", width=2, tags="anim")
787
+ # ventana ennegrecida (fusible quemado)
788
+ c.create_rectangle(cx - 8 * s, cy - 12 * s, cx + 8 * s, cy + 5 * s,
789
+ fill="#212121", outline="#8d6e00", tags="anim")
790
+ # rayo
791
+ bolt = [(1.5, -9), (-5, 0.5), (-1, 0.5), (-2.5, 8), (5, -2), (1, -2)]
792
+ k = 0.85 * s
793
+ pts = []
794
+ for bx, by in bolt:
795
+ pts += [cx + bx * k, cy - 3.5 * s + by * k]
796
+ c.create_polygon(*pts, fill="#ffeb3b", outline="#ff6f00", tags="anim")
797
+
798
+ def _draw_gripper(self, c, x, y, s, gap, flip=False):
799
+ """flip=False: muñeca arriba y mordazas hacia abajo; flip=True: al revés."""
800
+ k = -1 if flip else 1
801
+ jaw_w = 5 * s
802
+ # muñeca
803
+ c.create_rectangle(x - gap / 2 - jaw_w - 1 * s, y - k * 42 * s,
804
+ x + gap / 2 + jaw_w + 1 * s, y - k * 24 * s,
805
+ fill="#455a64", outline="#102027", tags="anim")
806
+ # mordazas
807
+ for sign in (-1, 1):
808
+ xi = x + sign * gap / 2
809
+ xo = x + sign * (gap / 2 + jaw_w)
810
+ c.create_rectangle(min(xi, xo), y - k * 26 * s, max(xi, xo), y + k * 6 * s,
811
+ fill="#78909c", outline="#102027", tags="anim")
812
+
813
+ # ------------------------------------------------------------------ voz
814
+ VOICE_HELP = ("Di: «inserta el cable» · «extrae el fusible» · «para» · "
815
+ "«reinicia la vista». Para confirmar: «sí» o «no».")
816
+
817
+ def _build_voice(self, parent):
818
+ vc = self.cfg.get("voice", {})
819
+ box = ttk.LabelFrame(parent, text="Control por voz", padding=8)
820
+ box.pack(fill="x", pady=(8, 0))
821
+
822
+ top = ttk.Frame(box)
823
+ top.pack(fill="x")
824
+ self.voice_btn = ttk.Button(top, text="🎤 Activar voz", command=self.toggle_voice)
825
+ self.voice_btn.pack(side="left")
826
+ self.voice_state = ttk.Label(top, text="Desactivada")
827
+ self.voice_state.pack(side="left", padx=10)
828
+
829
+ row = ttk.Frame(box)
830
+ row.pack(fill="x", pady=(6, 0))
831
+ ttk.Label(row, text="Idioma:").pack(side="left")
832
+ self.voice_lang = tk.StringVar(value=vc.get("lang", "es-ES"))
833
+ ttk.Combobox(row, textvariable=self.voice_lang, width=7,
834
+ values=("es-ES", "es-MX", "en-US", "en-GB")).pack(side="left", padx=4)
835
+ ttk.Label(row, text="Micrófono:").pack(side="left", padx=(8, 0))
836
+ self.voice_mic = tk.StringVar(value=vc.get("mic") or "Predeterminado")
837
+ self.mic_combo = ttk.Combobox(row, textvariable=self.voice_mic, width=22,
838
+ values=("Predeterminado",),
839
+ postcommand=self._refresh_mics)
840
+ self.mic_combo.pack(side="left", padx=4, fill="x", expand=True)
841
+
842
+ opts = ttk.Frame(box)
843
+ opts.pack(fill="x", pady=(6, 0))
844
+ self.voice_confirm = tk.BooleanVar(value=bool(vc.get("confirm", True)))
845
+ self.voice_speak = tk.BooleanVar(value=bool(vc.get("speak", True)))
846
+ ttk.Checkbutton(opts, text="Pedir confirmación («sí»)",
847
+ variable=self.voice_confirm).pack(side="left")
848
+ ttk.Checkbutton(opts, text="Respuesta hablada",
849
+ variable=self.voice_speak).pack(side="left", padx=10)
850
+
851
+ self.voice_heard = ttk.Label(box, text="Oído: —", wraplength=420)
852
+ self.voice_heard.pack(fill="x", pady=(6, 0))
853
+ ttk.Label(box, text=self.VOICE_HELP, wraplength=420,
854
+ foreground="#666666").pack(fill="x", pady=(2, 0))
855
+
856
+ def _refresh_mics(self):
857
+ if not HAS_SR:
858
+ return
859
+ try:
860
+ names = sr.Microphone.list_microphone_names()
861
+ self.mic_combo.configure(values=("Predeterminado", *names))
862
+ except Exception:
863
+ pass
864
+
865
+ def toggle_voice(self):
866
+ if self.voice_on:
867
+ self._voice_stop()
868
+ else:
869
+ self._voice_start()
870
+
871
+ def _voice_start(self):
872
+ if not HAS_SR:
873
+ messagebox.showerror(
874
+ "Falta SpeechRecognition",
875
+ "Para usar la voz instala, en el entorno donde ejecutas la GUI:\n\n"
876
+ " pip install SpeechRecognition pyaudio\n\n"
877
+ "En Ubuntu puede hacer falta antes:\n"
878
+ " sudo apt install portaudio19-dev flac espeak-ng")
879
+ return
880
+ self._voice_lang = self.voice_lang.get().strip() or "es-ES"
881
+ mic = self.voice_mic.get().strip()
882
+ self._voice_mic_name = "" if mic in ("", "Predeterminado") else mic
883
+ self.voice_on = True
884
+ self.voice_btn.config(text="🎤 Desactivar voz")
885
+ self.voice_state.config(text="Iniciando micrófono…")
886
+ self.save_settings(quiet=True)
887
+ threading.Thread(target=self._voice_worker, daemon=True).start()
888
+
889
+ def _voice_stop(self):
890
+ self.voice_on = False
891
+ self.pending = None
892
+ self.voice_btn.config(text="🎤 Activar voz")
893
+ self.voice_state.config(text="Desactivada")
894
+
895
+ def _voice_set_state(self, text):
896
+ if self.voice_on:
897
+ self.voice_state.config(text=text)
898
+
899
+ def _voice_failed(self, msg):
900
+ self.voice_on = False
901
+ self.pending = None
902
+ self.voice_btn.config(text="🎤 Activar voz")
903
+ self.voice_state.config(text="Error de micrófono")
904
+ self.write_log(f"[VOZ] No se pudo usar el micrófono: {msg}\n"
905
+ "[VOZ] Comprueba que PyAudio está instalado y que el micrófono "
906
+ "no está silenciado.\n")
907
+
908
+ def _voice_worker(self):
909
+ lang = self._voice_lang
910
+ mic_name = self._voice_mic_name
911
+ r = sr.Recognizer()
912
+ r.dynamic_energy_threshold = True
913
+ try:
914
+ idx = None
915
+ if mic_name:
916
+ names = sr.Microphone.list_microphone_names()
917
+ if mic_name in names:
918
+ idx = names.index(mic_name)
919
+ with sr.Microphone(device_index=idx) as source:
920
+ r.adjust_for_ambient_noise(source, duration=1.0)
921
+ self.after(0, self._voice_set_state, "🎤 Escuchando…")
922
+ self.after(0, self.write_log,
923
+ f"[VOZ] Micrófono listo ({mic_name or 'predeterminado'}), "
924
+ f"umbral de ruido {r.energy_threshold:.0f}\n")
925
+ silent = 0
926
+ while self.voice_on:
927
+ t0 = time.time()
928
+ try:
929
+ audio = r.listen(source, timeout=1, phrase_time_limit=6)
930
+ except sr.WaitTimeoutError:
931
+ silent += 1
932
+ if silent >= 15: # ~15 s sin detectar voz
933
+ silent = 0
934
+ self.after(0, self.write_log,
935
+ "[VOZ] No detecto voz: el nivel no supera el umbral "
936
+ f"({r.energy_threshold:.0f}). Habla más cerca, sube el "
937
+ "volumen de captura o prueba otro micrófono.\n")
938
+ continue
939
+ silent = 0
940
+ # Descartar audio que coincide con la voz del propio programa
941
+ if self.speaking or self.last_speech_end > t0:
942
+ continue
943
+ try:
944
+ text = r.recognize_google(audio, language=lang)
945
+ except sr.UnknownValueError:
946
+ dur = len(audio.frame_data) / (audio.sample_rate * audio.sample_width)
947
+ self.after(0, self.write_log,
948
+ f"[VOZ] Audio recibido pero no entendido "
949
+ f"({dur:.1f} s, nivel {_audio_level(audio)}, "
950
+ f"umbral {r.energy_threshold:.0f})\n")
951
+ continue
952
+ except sr.RequestError as e:
953
+ self.after(0, self.write_log,
954
+ f"[VOZ] Error de reconocimiento (¿sin internet?): {e}\n")
955
+ time.sleep(2)
956
+ continue
957
+ self.after(0, self._on_voice_text, text)
958
+ except Exception as e: # micrófono no disponible, PyAudio ausente, etc.
959
+ self.after(0, self._voice_failed, str(e))
960
+
961
+ def _on_voice_text(self, text):
962
+ if not self.voice_on:
963
+ return
964
+ self.voice_heard.config(text=f"Oído: «{text}»")
965
+ cmd = parse_voice(text)
966
+ self.write_log(f"[VOZ] «{text}»" + (f" → {cmd}" if cmd else " (sin comando)") + "\n")
967
+ if cmd is None:
968
+ return
969
+
970
+ if cmd == "stop":
971
+ self.pending = None
972
+ if self.proc is not None:
973
+ self.stop_task()
974
+ self._say("Parando")
975
+ else:
976
+ self._say("No hay ninguna tarea en marcha")
977
+ return
978
+
979
+ if cmd in ("yes", "no"):
980
+ if not self.pending:
981
+ return
982
+ key, deadline = self.pending
983
+ self.pending = None
984
+ self._voice_set_state("🎤 Escuchando…")
985
+ if cmd == "yes" and time.time() <= deadline and self.proc is None:
986
+ self._say("De acuerdo")
987
+ self.start_task(key, confirmed=True)
988
+ else:
989
+ self._say("Cancelado")
990
+ return
991
+
992
+ if cmd == "reset":
993
+ self.reset_view()
994
+ self._say("Vista reiniciada")
995
+ return
996
+
997
+ if cmd in self.cfg["tasks"]: # insertar / extraer
998
+ if self.proc is not None:
999
+ self._say("Ya hay una tarea en marcha")
1000
+ return
1001
+ label = self.cfg["tasks"][cmd]["label"]
1002
+ if self.voice_confirm.get():
1003
+ self.pending = (cmd, time.time() + 10)
1004
+ self.voice_state.config(text=f"¿Confirmas «{label}»? Di sí o no")
1005
+ self._say(f"¿Confirmas {label}? Di sí o no")
1006
+ else:
1007
+ self._say(label)
1008
+ self.start_task(cmd, confirmed=True)
1009
+
1010
+ def _voice_tick(self):
1011
+ if self.pending and time.time() > self.pending[1]:
1012
+ self.pending = None
1013
+ self.write_log("[VOZ] Confirmación caducada\n")
1014
+ self._voice_set_state("🎤 Escuchando…")
1015
+
1016
+ def _say(self, text):
1017
+ """Registra la respuesta y, si hay un sintetizador instalado, la dice en voz alta."""
1018
+ self.write_log(f"[VOZ] ← {text}\n")
1019
+ if not self.voice_speak.get():
1020
+ return
1021
+ short = (self.voice_lang.get().strip() or "es-ES")[:2]
1022
+ if shutil.which("espeak-ng"):
1023
+ cmd = ["espeak-ng", "-v", short, text]
1024
+ elif shutil.which("espeak"):
1025
+ cmd = ["espeak", "-v", short, text]
1026
+ elif shutil.which("spd-say"):
1027
+ cmd = ["spd-say", "-w", "-l", short, text]
1028
+ else:
1029
+ return
1030
+
1031
+ def run():
1032
+ with self._speak_lock:
1033
+ self._speak_n += 1
1034
+ self.speaking = True
1035
+ try:
1036
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
1037
+ timeout=20)
1038
+ except Exception:
1039
+ pass
1040
+ finally:
1041
+ with self._speak_lock:
1042
+ self._speak_n -= 1
1043
+ if self._speak_n == 0:
1044
+ self.speaking = False
1045
+ self.last_speech_end = time.time()
1046
+
1047
+ threading.Thread(target=run, daemon=True).start()
1048
+
1049
+ # ------------------------------------------------------------- acciones
1050
+ def start_task(self, key, confirmed=False):
1051
+ if self.proc is not None:
1052
+ return
1053
+ self.save_settings(quiet=True)
1054
+ label = self.cfg["tasks"][key]["label"]
1055
+ try:
1056
+ cmd = self.build_command(key)
1057
+ except ValueError as e:
1058
+ messagebox.showerror("Error en los argumentos", str(e))
1059
+ return
1060
+
1061
+ if not confirmed and not messagebox.askokcancel(
1062
+ "Confirmar",
1063
+ f"Se va a ejecutar «{label}».\n\n"
1064
+ "Comprueba que la zona del brazo está despejada y "
1065
+ "ten a mano el botón PARAR."):
1066
+ return
1067
+
1068
+ self.write_log(f"\n[GUI] >>> {' '.join(shlex.quote(c) for c in cmd)}\n")
1069
+ try:
1070
+ kwargs = {}
1071
+ if os.name == "nt":
1072
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
1073
+ env = dict(os.environ, PYTHONUNBUFFERED="1")
1074
+ self.proc = subprocess.Popen(
1075
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
1076
+ text=True, bufsize=1, env=env, **kwargs)
1077
+ except FileNotFoundError:
1078
+ messagebox.showerror(
1079
+ "No se encuentra lerobot-rollout",
1080
+ "Lanza esta interfaz desde la terminal con el entorno activado "
1081
+ "(conda activate lerobot) o indica el nombre del entorno conda "
1082
+ "en la pestaña Robot.")
1083
+ self.proc = None
1084
+ return
1085
+
1086
+ # Al empezar, la tarea parte del estado inicial de su elemento
1087
+ if key == "insertar":
1088
+ self.cable_in = False
1089
+ elif key == "extraer":
1090
+ self.fuse_out = False
1091
+
1092
+ self.current_task = key
1093
+ self.t_start = time.time()
1094
+ self.t_anim0 = time.time()
1095
+ for b in self.buttons.values():
1096
+ b.config(state="disabled")
1097
+ self.stop_btn.config(state="normal")
1098
+ self.set_status(f"Ejecutando: {label}", "#2e7d32")
1099
+ threading.Thread(target=self._reader, daemon=True).start()
1100
+
1101
+ def _reader(self):
1102
+ proc = self.proc
1103
+ for line in proc.stdout:
1104
+ self.after(0, self.write_log, line)
1105
+ code = proc.wait()
1106
+ self.after(0, self._finished, code)
1107
+
1108
+ def _finished(self, code):
1109
+ key = self.current_task
1110
+ label = self.cfg["tasks"][key]["label"]
1111
+ self.write_log(f"[GUI] Proceso terminado (código {code})\n")
1112
+ if code == 0:
1113
+ if key == "insertar":
1114
+ self.cable_in = True
1115
+ elif key == "extraer":
1116
+ self.fuse_out = True
1117
+ self.proc = None
1118
+ self.current_task = None
1119
+ for b in self.buttons.values():
1120
+ b.config(state="normal")
1121
+ self.stop_btn.config(state="disabled")
1122
+ if code == 0:
1123
+ self.set_status(f"Terminado: {label}", "#1565c0")
1124
+ else:
1125
+ self.set_status(f"Detenido / error: {label}", "#c62828")
1126
+
1127
+ def stop_task(self):
1128
+ if self.proc is None:
1129
+ return
1130
+ self.write_log("[GUI] Deteniendo…\n")
1131
+ try:
1132
+ if os.name == "nt":
1133
+ self.proc.send_signal(signal.CTRL_BREAK_EVENT)
1134
+ else:
1135
+ self.proc.send_signal(signal.SIGINT) # parada limpia
1136
+ except Exception:
1137
+ self.proc.terminate()
1138
+ self.after(5000, self._force_kill)
1139
+
1140
+ def _force_kill(self):
1141
+ if self.proc is not None and self.proc.poll() is None:
1142
+ self.write_log("[GUI] Forzando cierre del proceso\n")
1143
+ self.proc.kill()
1144
+
1145
+ def _tick(self):
1146
+ if self.proc is not None:
1147
+ sec = int(time.time() - self.t_start)
1148
+ self.timer_lbl.config(text=f"{sec // 60:02d}:{sec % 60:02d}")
1149
+ self._voice_tick()
1150
+ self.after(500, self._tick)
1151
+
1152
+ def on_close(self):
1153
+ if self.proc is not None:
1154
+ if not messagebox.askyesno("Salir", "Hay una tarea en marcha. ¿Pararla y salir?"):
1155
+ return
1156
+ self.stop_task()
1157
+ time.sleep(1.0)
1158
+ self.voice_on = False
1159
+ try:
1160
+ self.save_settings(quiet=True)
1161
+ except Exception:
1162
+ pass
1163
+ self.destroy()
1164
+
1165
+
1166
+ if __name__ == "__main__":
1167
+ App().mainloop()
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: lerobotberto
3
+ Version: 0.1.0
4
+ Summary: Interfaz gráfica para ejecutar tareas entrenadas con LeRobot
5
+ License-Expression: LicenseRef-LerobotBerto-Proprietary
6
+ Requires-Python: <3.13,>=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE.txt
9
+ Provides-Extra: imagenes
10
+ Requires-Dist: Pillow==12.3.0; extra == "imagenes"
11
+ Provides-Extra: voz
12
+ Requires-Dist: SpeechRecognition==3.17.0; extra == "voz"
13
+ Requires-Dist: PyAudio==0.2.14; extra == "voz"
14
+ Provides-Extra: completo
15
+ Requires-Dist: Pillow==12.3.0; extra == "completo"
16
+ Requires-Dist: SpeechRecognition==3.17.0; extra == "completo"
17
+ Requires-Dist: PyAudio==0.2.14; extra == "completo"
18
+ Dynamic: license-file
19
+
20
+ # LeRobotBerto
21
+
22
+ Interfaz gráfica para ejecutar políticas entrenadas con LeRobot.
23
+
24
+ ## Requisitos
25
+
26
+ - Python 3.12.
27
+ - Linux o un sistema compatible con Tkinter.
28
+ - LeRobot instalado en un entorno compatible.
29
+ - Robot y cámaras configurados para el equipo correspondiente.
30
+
31
+ Pillow permite mostrar imágenes adicionales. SpeechRecognition y PyAudio
32
+ son opcionales y se utilizan para el control por voz.
33
+
34
+ ## Ejecutar la interfaz
35
+
36
+ Una vez instalado el paquete:
37
+
38
+ lerobotberto
39
+
40
+ También se puede ejecutar como módulo:
41
+
42
+ python -m lerobotberto
43
+
44
+ ## Dependencias de LeRobot
45
+
46
+ Los archivos de `environments/` y `requirements/` contienen las referencias
47
+ de dependencias exportadas desde el entorno Linux de desarrollo.
48
+
49
+ No se deben instalar automáticamente en cualquier sistema operativo:
50
+ algunas dependencias son específicas de Linux, del hardware o de CUDA.
51
+
52
+ Antes de ejecutar tareas reales, hay que configurar los puertos, las cámaras,
53
+ las políticas y los parámetros del robot en la interfaz.
@@ -0,0 +1,9 @@
1
+ lerobotberto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ lerobotberto/__main__.py,sha256=cuOke-HtgFcKIDHo1hRlTJsPc6y-CrtULHDBqayYREU,110
3
+ lerobotberto/gui.py,sha256=bsYgdgMjibgeB02GnsqH1hCjvjNvfTfMu5kV8ly2gGc,48604
4
+ lerobotberto-0.1.0.dist-info/licenses/LICENSE.txt,sha256=gupReBd-ERKxRqiOUQRyREfsNvVh3jQqUpijfSBZPIg,1748
5
+ lerobotberto-0.1.0.dist-info/METADATA,sha256=x_SBr4kYer86kholKFutkZ2sIlfzvAUzLjKKL4ClmeI,1686
6
+ lerobotberto-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ lerobotberto-0.1.0.dist-info/entry_points.txt,sha256=gddRylDSIaCGnlTEjvbRrGbvnRKVQpaK6ClsMenskIw,60
8
+ lerobotberto-0.1.0.dist-info/top_level.txt,sha256=N8u58qQWs1YL_lbhCDiGcQDPxG9h5fpifM3YFmJa-dM,13
9
+ lerobotberto-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lerobotberto = lerobotberto.__main__:main
@@ -0,0 +1,46 @@
1
+ LEROBERTO PROPRIETARY LICENSE
2
+ Copyright (c) 2026 Alberto Trujillo Sanz - FUSGEMENT S.L.U.
3
+ All rights reserved.
4
+
5
+ 1. COPYRIGHT
6
+ The software known as LeRobotBerto, including its source code,
7
+ documentation and associated materials, is protected by copyright.
8
+ All rights not expressly granted in this license are reserved by
9
+ the copyright holder.
10
+
11
+ 2. FREE LICENSE TO USE
12
+ The copyright holder grants each recipient a non-exclusive,
13
+ worldwide, royalty-free license to download, install and use the
14
+ software for personal, educational, research or commercial purposes.
15
+
16
+ 3. RESTRICTIONS
17
+ Unless the copyright holder gives prior written permission, recipients
18
+ may not:
19
+ - Sell, sublicense or commercially redistribute the software itself.
20
+ - Publish or distribute modified or derivative versions of the software.
21
+ - Redistribute copies of the software outside the distribution channels
22
+ authorized by the copyright holder.
23
+
24
+ 4. OWNERSHIP
25
+ This license does not transfer ownership or copyright in the software.
26
+ All rights not expressly granted remain with the copyright holder.
27
+
28
+ 5. FUTURE DISTRIBUTION
29
+ The copyright holder may stop distributing the software, change the
30
+ terms for future releases, or discontinue this license for future
31
+ downloads and releases.
32
+
33
+ Termination or withdrawal of distribution does not, by itself,
34
+ automatically revoke permissions previously granted to recipients.
35
+ Any termination of an existing recipient's license is subject to
36
+ the applicable terms and law.
37
+
38
+ 6. NO WARRANTY
39
+ The software is provided "AS IS", without warranty of any kind,
40
+ to the fullest extent permitted by applicable law.
41
+
42
+ 7. APPLICABLE LAW
43
+ The interpretation and enforceability of this license are subject
44
+ to applicable law.
45
+
46
+ 8. You have to ACCEPT that BERTO is cool.
@@ -0,0 +1 @@
1
+ lerobotberto