Jacid23 commited on
Commit
b26fcdd
·
1 Parent(s): d29372f

Mirror Time

Browse files
pyproject.toml CHANGED
@@ -101,6 +101,7 @@ Reachy_OpenWebUI = [
101
  "sub_apps/marionette/marionette/static/*",
102
  "sub_apps/marionette/marionette/static/fonts/*",
103
  "sub_apps/marionette/marionette/assets/*",
 
104
  ".env.example",
105
  "demos/**/*.txt",
106
  "profiles/**/*.txt",
 
101
  "sub_apps/marionette/marionette/static/*",
102
  "sub_apps/marionette/marionette/static/fonts/*",
103
  "sub_apps/marionette/marionette/assets/*",
104
+ "sub_apps/reachy_mirror/static/*",
105
  ".env.example",
106
  "demos/**/*.txt",
107
  "profiles/**/*.txt",
src/Reachy_OpenWebUI/console.py CHANGED
@@ -33,6 +33,7 @@ from Reachy_OpenWebUI.audio.alsa_levels import (
33
  from Reachy_OpenWebUI.app_profiles import resolve_tool_profile_source
34
  from Reachy_OpenWebUI.sub_apps.conversation_app.chat_history import append_chat_history, clear_chat_history
35
  from Reachy_OpenWebUI.sub_apps.marionette.runtime_host import MarionetteRuntimeHost, _thread_running
 
36
  from Reachy_OpenWebUI.config import config, set_runtime_mode
37
  from Reachy_OpenWebUI.sub_apps.attitudes import available_attitudes
38
  from Reachy_OpenWebUI.tool_server import register_reachy_tool_routes
@@ -317,6 +318,12 @@ class LocalStream:
317
  data_root=Path(self._instance_path or self._stable_env_path().parent) / "marionette",
318
  reset_live_pipeline=self._reset_live_pipeline_for_mode_change,
319
  )
 
 
 
 
 
 
320
 
321
  self._init_settings_ui_if_needed()
322
  self._attach_app_dependencies()
@@ -934,6 +941,7 @@ class LocalStream:
934
  "active_profile": config.REACHY_MINI_CUSTOM_PROFILE or "",
935
  "cookaiware_active": config.REACHY_MINI_CUSTOM_PROFILE == COOKAIWARE_PROFILE,
936
  "marionette_active": self._marionette.is_running(),
 
937
  "movement_running": movement_running,
938
  "head_wobbler_running": head_wobbler_running,
939
  "reachy_idle_attitude": idle_attitude,
@@ -979,6 +987,10 @@ class LocalStream:
979
  self._settings_app.mount("/apps/marionette", self._marionette.app(), name="marionette")
980
  except Exception as exc:
981
  logger.warning("Could not mount Marionette sub-app: %s", exc)
 
 
 
 
982
 
983
  class OpenWebUISettingsPayload(BaseModel):
984
  openwebui_url: str
@@ -1075,6 +1087,16 @@ class LocalStream:
1075
  result = self._marionette.stop()
1076
  return JSONResponse({**result, **self._status_payload()})
1077
 
 
 
 
 
 
 
 
 
 
 
1078
  @self._settings_app.get("/cooking/state")
1079
  def _cooking_state() -> JSONResponse:
1080
  return JSONResponse({"ok": True, **self._cooking_state_payload()})
@@ -2160,6 +2182,7 @@ class LocalStream:
2160
  logger.info("Stopping LocalStream...")
2161
 
2162
  self._marionette.stop(restart_normal=False)
 
2163
 
2164
  # Signal async loops to stop
2165
  self._pipeline_state = "shutting_down"
 
33
  from Reachy_OpenWebUI.app_profiles import resolve_tool_profile_source
34
  from Reachy_OpenWebUI.sub_apps.conversation_app.chat_history import append_chat_history, clear_chat_history
35
  from Reachy_OpenWebUI.sub_apps.marionette.runtime_host import MarionetteRuntimeHost, _thread_running
36
+ from Reachy_OpenWebUI.sub_apps.reachy_mirror import ReachyMirrorRuntimeHost
37
  from Reachy_OpenWebUI.config import config, set_runtime_mode
38
  from Reachy_OpenWebUI.sub_apps.attitudes import available_attitudes
39
  from Reachy_OpenWebUI.tool_server import register_reachy_tool_routes
 
318
  data_root=Path(self._instance_path or self._stable_env_path().parent) / "marionette",
319
  reset_live_pipeline=self._reset_live_pipeline_for_mode_change,
320
  )
321
+ self._reachy_mirror = ReachyMirrorRuntimeHost(
322
+ robot=self._robot,
323
+ handler=self.handler,
324
+ reset_live_pipeline=self._reset_live_pipeline_for_mode_change,
325
+ restore_head_tracking=self._apply_live_head_tracking_settings,
326
+ )
327
 
328
  self._init_settings_ui_if_needed()
329
  self._attach_app_dependencies()
 
941
  "active_profile": config.REACHY_MINI_CUSTOM_PROFILE or "",
942
  "cookaiware_active": config.REACHY_MINI_CUSTOM_PROFILE == COOKAIWARE_PROFILE,
943
  "marionette_active": self._marionette.is_running(),
944
+ "reachy_mirror_active": self._reachy_mirror.is_running(),
945
  "movement_running": movement_running,
946
  "head_wobbler_running": head_wobbler_running,
947
  "reachy_idle_attitude": idle_attitude,
 
987
  self._settings_app.mount("/apps/marionette", self._marionette.app(), name="marionette")
988
  except Exception as exc:
989
  logger.warning("Could not mount Marionette sub-app: %s", exc)
990
+ try:
991
+ self._settings_app.mount("/apps/reachy-mirror", self._reachy_mirror.app(), name="reachy-mirror")
992
+ except Exception as exc:
993
+ logger.warning("Could not mount Reachy Mirror sub-app: %s", exc)
994
 
995
  class OpenWebUISettingsPayload(BaseModel):
996
  openwebui_url: str
 
1087
  result = self._marionette.stop()
1088
  return JSONResponse({**result, **self._status_payload()})
1089
 
1090
+ @self._settings_app.post("/reachy-mirror/enter")
1091
+ def _reachy_mirror_enter() -> JSONResponse:
1092
+ result = self._reachy_mirror.start()
1093
+ return JSONResponse({**result, **self._status_payload()})
1094
+
1095
+ @self._settings_app.post("/reachy-mirror/exit")
1096
+ def _reachy_mirror_exit() -> JSONResponse:
1097
+ result = self._reachy_mirror.stop()
1098
+ return JSONResponse({**result, **self._status_payload()})
1099
+
1100
  @self._settings_app.get("/cooking/state")
1101
  def _cooking_state() -> JSONResponse:
1102
  return JSONResponse({"ok": True, **self._cooking_state_payload()})
 
2182
  logger.info("Stopping LocalStream...")
2183
 
2184
  self._marionette.stop(restart_normal=False)
2185
+ self._reachy_mirror.stop(restart_normal=False)
2186
 
2187
  # Signal async loops to stop
2188
  self._pipeline_state = "shutting_down"
src/Reachy_OpenWebUI/sub_apps/cookAIware/cook_log.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+ from uuid import uuid4
7
+
8
+ from Reachy_OpenWebUI.sub_apps.cookAIware.data_store import load_json, save_json
9
+
10
+
11
+ COOK_LOG_FILE = "cook_log.json"
12
+
13
+
14
+ def _log_path(data_dir: Path) -> Path:
15
+ return data_dir / COOK_LOG_FILE
16
+
17
+
18
+ def list_entries(data_dir: Path, limit: int = 20, query: str | None = None) -> List[Dict[str, Any]]:
19
+ entries = load_json(_log_path(data_dir), [])
20
+ if not isinstance(entries, list):
21
+ entries = []
22
+
23
+ cleaned = [entry for entry in entries if isinstance(entry, dict)]
24
+ if query:
25
+ needle = query.strip().lower()
26
+ cleaned = [
27
+ entry
28
+ for entry in cleaned
29
+ if needle in " ".join(str(value).lower() for value in entry.values())
30
+ ]
31
+
32
+ cleaned.sort(key=lambda entry: str(entry.get("created_at", "")), reverse=True)
33
+ return cleaned[: max(1, int(limit or 20))]
34
+
35
+
36
+ def add_entry(
37
+ data_dir: Path,
38
+ note: str,
39
+ category: str = "note",
40
+ title: str | None = None,
41
+ tags: list[str] | None = None,
42
+ related_items: list[str] | None = None,
43
+ ) -> Dict[str, Any]:
44
+ entries = load_json(_log_path(data_dir), [])
45
+ if not isinstance(entries, list):
46
+ entries = []
47
+
48
+ clean_note = note.strip()
49
+ entry: Dict[str, Any] = {
50
+ "id": uuid4().hex,
51
+ "created_at": datetime.now(timezone.utc).isoformat(),
52
+ "category": (category or "note").strip().lower(),
53
+ "note": clean_note,
54
+ }
55
+ if title:
56
+ entry["title"] = title.strip()
57
+ if tags:
58
+ entry["tags"] = [str(tag).strip().lower() for tag in tags if str(tag).strip()]
59
+ if related_items:
60
+ entry["related_items"] = [str(item).strip().lower() for item in related_items if str(item).strip()]
61
+
62
+ entries.append(entry)
63
+ save_json(_log_path(data_dir), entries)
64
+ return entry
65
+
66
+
67
+ def clear_entries(data_dir: Path) -> None:
68
+ save_json(_log_path(data_dir), [])
src/Reachy_OpenWebUI/sub_apps/cookAIware/data/app_settings.json CHANGED
@@ -1,3 +1,4 @@
1
  {
2
- "language": "ca"
 
3
  }
 
1
  {
2
+ "language": "en",
3
+ "unit_system": "imperial"
4
  }
src/Reachy_OpenWebUI/sub_apps/cookAIware/data/inventory.json CHANGED
@@ -1,55 +1,55 @@
1
  [
2
  {
3
- "name": "arroz",
4
- "display_name": "arroz",
5
- "quantity": 600.0,
6
- "unit": "g",
7
  "expiration_date": "2027-02-07",
8
- "storage_location": "armario"
9
  },
10
  {
11
  "name": "pasta",
12
- "display_name": "pasta",
13
- "quantity": 1000.0,
14
- "unit": "g",
15
  "expiration_date": "2027-02-07",
16
- "storage_location": "armario"
17
  },
18
  {
19
- "name": "pollo",
20
- "display_name": "pollo",
21
  "quantity": 0.0,
22
- "unit": "g",
23
  "expiration_date": "2026-02-10",
24
- "storage_location": "nevera"
25
  },
26
  {
27
- "name": "tomates cherry",
28
- "display_name": "tomates cherry",
29
- "quantity": 300.0,
30
- "unit": "g",
31
  "expiration_date": "2026-02-14",
32
- "storage_location": "armario"
33
  },
34
  {
35
- "name": "mix de verduras",
36
- "display_name": "mix de verduras",
37
  "quantity": 0.0,
38
- "unit": "g",
39
  "expiration_date": "2026-02-14",
40
  "storage_location": null
41
  },
42
  {
43
  "name": "chicken",
44
- "display_name": "chicken",
45
  "quantity": 0.0,
46
- "unit": "g",
47
  "expiration_date": "2026-02-10",
48
- "storage_location": "nevera"
49
  },
50
  {
51
  "name": "corn on the cob",
52
- "display_name": "corn on the cob",
53
  "quantity": 0.0,
54
  "unit": "pcs",
55
  "expiration_date": null,
 
1
  [
2
  {
3
+ "name": "rice",
4
+ "display_name": "Rice",
5
+ "quantity": 21.16,
6
+ "unit": "oz",
7
  "expiration_date": "2027-02-07",
8
+ "storage_location": "pantry"
9
  },
10
  {
11
  "name": "pasta",
12
+ "display_name": "Pasta",
13
+ "quantity": 35.27,
14
+ "unit": "oz",
15
  "expiration_date": "2027-02-07",
16
+ "storage_location": "pantry"
17
  },
18
  {
19
+ "name": "chicken",
20
+ "display_name": "Chicken",
21
  "quantity": 0.0,
22
+ "unit": "oz",
23
  "expiration_date": "2026-02-10",
24
+ "storage_location": "fridge"
25
  },
26
  {
27
+ "name": "cherry tomatoes",
28
+ "display_name": "Cherry Tomatoes",
29
+ "quantity": 10.58,
30
+ "unit": "oz",
31
  "expiration_date": "2026-02-14",
32
+ "storage_location": "pantry"
33
  },
34
  {
35
+ "name": "mixed vegetables",
36
+ "display_name": "Mixed Vegetables",
37
  "quantity": 0.0,
38
+ "unit": "oz",
39
  "expiration_date": "2026-02-14",
40
  "storage_location": null
41
  },
42
  {
43
  "name": "chicken",
44
+ "display_name": "Chicken",
45
  "quantity": 0.0,
46
+ "unit": "oz",
47
  "expiration_date": "2026-02-10",
48
+ "storage_location": "fridge"
49
  },
50
  {
51
  "name": "corn on the cob",
52
+ "display_name": "Corn on the Cob",
53
  "quantity": 0.0,
54
  "unit": "pcs",
55
  "expiration_date": null,
src/Reachy_OpenWebUI/sub_apps/cookAIware/data/meal_plan.json CHANGED
@@ -23,57 +23,57 @@
23
  "meals": [
24
  {
25
  "meal": "dinner",
26
- "name": "Chicken with Tomates Cherry and Arroz",
27
  "servings": 5,
28
  "ingredients": [
29
  {
30
  "name": "chicken",
31
  "display_name": "Chicken",
32
- "quantity": 750.0,
33
- "unit": "g",
34
- "display_quantity": "750.00 g"
35
  },
36
  {
37
- "name": "tomates cherry",
38
- "display_name": "Tomates Cherry",
39
- "quantity": 600.0,
40
- "unit": "g",
41
- "display_quantity": "600.00 g"
42
  },
43
  {
44
- "name": "arroz",
45
- "display_name": "Arroz",
46
- "quantity": 500.0,
47
- "unit": "g",
48
- "display_quantity": "500.00 g"
49
  }
50
  ]
51
  },
52
  {
53
  "meal": "adult_lunch",
54
- "name": "Pollo with Mix De Verduras and Pasta",
55
  "servings": 1,
56
  "ingredients": [
57
  {
58
- "name": "pollo",
59
- "display_name": "Pollo",
60
- "quantity": 150.0,
61
- "unit": "g",
62
- "display_quantity": "150.00 g"
63
  },
64
  {
65
- "name": "mix de verduras",
66
- "display_name": "Mix De Verduras",
67
- "quantity": 120.0,
68
- "unit": "g",
69
- "display_quantity": "120.00 g"
70
  },
71
  {
72
  "name": "pasta",
73
  "display_name": "Pasta",
74
- "quantity": 100.0,
75
- "unit": "g",
76
- "display_quantity": "100.00 g"
77
  }
78
  ]
79
  }
@@ -85,29 +85,29 @@
85
  "meals": [
86
  {
87
  "meal": "dinner",
88
- "name": "Chicken with Tomates Cherry and Arroz",
89
  "servings": 5,
90
  "ingredients": [
91
  {
92
  "name": "chicken",
93
  "display_name": "Chicken",
94
- "quantity": 750.0,
95
- "unit": "g",
96
- "display_quantity": "750.00 g"
97
  },
98
  {
99
- "name": "tomates cherry",
100
- "display_name": "Tomates Cherry",
101
- "quantity": 600.0,
102
- "unit": "g",
103
- "display_quantity": "600.00 g"
104
  },
105
  {
106
- "name": "arroz",
107
- "display_name": "Arroz",
108
- "quantity": 500.0,
109
- "unit": "g",
110
- "display_quantity": "500.00 g"
111
  }
112
  ]
113
  }
@@ -119,57 +119,57 @@
119
  "meals": [
120
  {
121
  "meal": "dinner",
122
- "name": "Pollo with Mix De Verduras and Pasta",
123
  "servings": 5,
124
  "ingredients": [
125
  {
126
- "name": "pollo",
127
- "display_name": "Pollo",
128
- "quantity": 750.0,
129
- "unit": "g",
130
- "display_quantity": "750.00 g"
131
  },
132
  {
133
- "name": "mix de verduras",
134
- "display_name": "Mix De Verduras",
135
- "quantity": 600.0,
136
- "unit": "g",
137
- "display_quantity": "600.00 g"
138
  },
139
  {
140
  "name": "pasta",
141
  "display_name": "Pasta",
142
- "quantity": 500.0,
143
- "unit": "g",
144
- "display_quantity": "500.00 g"
145
  }
146
  ]
147
  },
148
  {
149
  "meal": "adult_lunch",
150
- "name": "Chicken with Tomates Cherry and Arroz",
151
  "servings": 1,
152
  "ingredients": [
153
  {
154
  "name": "chicken",
155
  "display_name": "Chicken",
156
- "quantity": 150.0,
157
- "unit": "g",
158
- "display_quantity": "150.00 g"
159
  },
160
  {
161
- "name": "tomates cherry",
162
- "display_name": "Tomates Cherry",
163
- "quantity": 120.0,
164
- "unit": "g",
165
- "display_quantity": "120.00 g"
166
  },
167
  {
168
- "name": "arroz",
169
- "display_name": "Arroz",
170
- "quantity": 100.0,
171
- "unit": "g",
172
- "display_quantity": "100.00 g"
173
  }
174
  ]
175
  }
@@ -181,29 +181,29 @@
181
  "meals": [
182
  {
183
  "meal": "dinner",
184
- "name": "Pollo with Mix De Verduras and Pasta",
185
  "servings": 5,
186
  "ingredients": [
187
  {
188
- "name": "pollo",
189
- "display_name": "Pollo",
190
- "quantity": 750.0,
191
- "unit": "g",
192
- "display_quantity": "750.00 g"
193
  },
194
  {
195
- "name": "mix de verduras",
196
- "display_name": "Mix De Verduras",
197
- "quantity": 600.0,
198
- "unit": "g",
199
- "display_quantity": "600.00 g"
200
  },
201
  {
202
  "name": "pasta",
203
  "display_name": "Pasta",
204
- "quantity": 500.0,
205
- "unit": "g",
206
- "display_quantity": "500.00 g"
207
  }
208
  ]
209
  }
@@ -215,57 +215,57 @@
215
  "meals": [
216
  {
217
  "meal": "dinner",
218
- "name": "Chicken with Tomates Cherry and Arroz",
219
  "servings": 5,
220
  "ingredients": [
221
  {
222
  "name": "chicken",
223
  "display_name": "Chicken",
224
- "quantity": 750.0,
225
- "unit": "g",
226
- "display_quantity": "750.00 g"
227
  },
228
  {
229
- "name": "tomates cherry",
230
- "display_name": "Tomates Cherry",
231
- "quantity": 600.0,
232
- "unit": "g",
233
- "display_quantity": "600.00 g"
234
  },
235
  {
236
- "name": "arroz",
237
- "display_name": "Arroz",
238
- "quantity": 500.0,
239
- "unit": "g",
240
- "display_quantity": "500.00 g"
241
  }
242
  ]
243
  },
244
  {
245
  "meal": "adult_lunch",
246
- "name": "Pollo with Mix De Verduras and Pasta",
247
  "servings": 1,
248
  "ingredients": [
249
  {
250
- "name": "pollo",
251
- "display_name": "Pollo",
252
- "quantity": 150.0,
253
- "unit": "g",
254
- "display_quantity": "150.00 g"
255
  },
256
  {
257
- "name": "mix de verduras",
258
- "display_name": "Mix De Verduras",
259
- "quantity": 120.0,
260
- "unit": "g",
261
- "display_quantity": "120.00 g"
262
  },
263
  {
264
  "name": "pasta",
265
  "display_name": "Pasta",
266
- "quantity": 100.0,
267
- "unit": "g",
268
- "display_quantity": "100.00 g"
269
  }
270
  ]
271
  }
@@ -277,57 +277,57 @@
277
  "meals": [
278
  {
279
  "meal": "lunch",
280
- "name": "Chicken with Tomates Cherry and Arroz",
281
  "servings": 5,
282
  "ingredients": [
283
  {
284
  "name": "chicken",
285
  "display_name": "Chicken",
286
- "quantity": 750.0,
287
- "unit": "g",
288
- "display_quantity": "750.00 g"
289
  },
290
  {
291
- "name": "tomates cherry",
292
- "display_name": "Tomates Cherry",
293
- "quantity": 600.0,
294
- "unit": "g",
295
- "display_quantity": "600.00 g"
296
  },
297
  {
298
- "name": "arroz",
299
- "display_name": "Arroz",
300
- "quantity": 500.0,
301
- "unit": "g",
302
- "display_quantity": "500.00 g"
303
  }
304
  ]
305
  },
306
  {
307
  "meal": "dinner",
308
- "name": "Pollo with Mix De Verduras and Pasta",
309
  "servings": 5,
310
  "ingredients": [
311
  {
312
- "name": "pollo",
313
- "display_name": "Pollo",
314
- "quantity": 750.0,
315
- "unit": "g",
316
- "display_quantity": "750.00 g"
317
  },
318
  {
319
- "name": "mix de verduras",
320
- "display_name": "Mix De Verduras",
321
- "quantity": 600.0,
322
- "unit": "g",
323
- "display_quantity": "600.00 g"
324
  },
325
  {
326
  "name": "pasta",
327
  "display_name": "Pasta",
328
- "quantity": 500.0,
329
- "unit": "g",
330
- "display_quantity": "500.00 g"
331
  }
332
  ]
333
  }
@@ -339,57 +339,57 @@
339
  "meals": [
340
  {
341
  "meal": "lunch",
342
- "name": "Chicken with Tomates Cherry and Arroz",
343
  "servings": 5,
344
  "ingredients": [
345
  {
346
  "name": "chicken",
347
  "display_name": "Chicken",
348
- "quantity": 750.0,
349
- "unit": "g",
350
- "display_quantity": "750.00 g"
351
  },
352
  {
353
- "name": "tomates cherry",
354
- "display_name": "Tomates Cherry",
355
- "quantity": 600.0,
356
- "unit": "g",
357
- "display_quantity": "600.00 g"
358
  },
359
  {
360
- "name": "arroz",
361
- "display_name": "Arroz",
362
- "quantity": 500.0,
363
- "unit": "g",
364
- "display_quantity": "500.00 g"
365
  }
366
  ]
367
  },
368
  {
369
  "meal": "dinner",
370
- "name": "Pollo with Mix De Verduras and Pasta",
371
  "servings": 5,
372
  "ingredients": [
373
  {
374
- "name": "pollo",
375
- "display_name": "Pollo",
376
- "quantity": 750.0,
377
- "unit": "g",
378
- "display_quantity": "750.00 g"
379
  },
380
  {
381
- "name": "mix de verduras",
382
- "display_name": "Mix De Verduras",
383
- "quantity": 600.0,
384
- "unit": "g",
385
- "display_quantity": "600.00 g"
386
  },
387
  {
388
  "name": "pasta",
389
  "display_name": "Pasta",
390
- "quantity": 500.0,
391
- "unit": "g",
392
- "display_quantity": "500.00 g"
393
  }
394
  ]
395
  }
 
23
  "meals": [
24
  {
25
  "meal": "dinner",
26
+ "name": "Chicken with Cherry Tomatoes and Rice",
27
  "servings": 5,
28
  "ingredients": [
29
  {
30
  "name": "chicken",
31
  "display_name": "Chicken",
32
+ "quantity": 26.46,
33
+ "unit": "oz",
34
+ "display_quantity": "26.46 oz"
35
  },
36
  {
37
+ "name": "cherry tomatoes",
38
+ "display_name": "Cherry Tomatoes",
39
+ "quantity": 21.16,
40
+ "unit": "oz",
41
+ "display_quantity": "21.16 oz"
42
  },
43
  {
44
+ "name": "rice",
45
+ "display_name": "Rice",
46
+ "quantity": 17.64,
47
+ "unit": "oz",
48
+ "display_quantity": "17.64 oz"
49
  }
50
  ]
51
  },
52
  {
53
  "meal": "adult_lunch",
54
+ "name": "Chicken with Mixed Vegetables and Pasta",
55
  "servings": 1,
56
  "ingredients": [
57
  {
58
+ "name": "chicken",
59
+ "display_name": "Chicken",
60
+ "quantity": 5.29,
61
+ "unit": "oz",
62
+ "display_quantity": "5.29 oz"
63
  },
64
  {
65
+ "name": "mixed vegetables",
66
+ "display_name": "Mixed Vegetables",
67
+ "quantity": 4.23,
68
+ "unit": "oz",
69
+ "display_quantity": "4.23 oz"
70
  },
71
  {
72
  "name": "pasta",
73
  "display_name": "Pasta",
74
+ "quantity": 3.53,
75
+ "unit": "oz",
76
+ "display_quantity": "3.53 oz"
77
  }
78
  ]
79
  }
 
85
  "meals": [
86
  {
87
  "meal": "dinner",
88
+ "name": "Chicken with Cherry Tomatoes and Rice",
89
  "servings": 5,
90
  "ingredients": [
91
  {
92
  "name": "chicken",
93
  "display_name": "Chicken",
94
+ "quantity": 26.46,
95
+ "unit": "oz",
96
+ "display_quantity": "26.46 oz"
97
  },
98
  {
99
+ "name": "cherry tomatoes",
100
+ "display_name": "Cherry Tomatoes",
101
+ "quantity": 21.16,
102
+ "unit": "oz",
103
+ "display_quantity": "21.16 oz"
104
  },
105
  {
106
+ "name": "rice",
107
+ "display_name": "Rice",
108
+ "quantity": 17.64,
109
+ "unit": "oz",
110
+ "display_quantity": "17.64 oz"
111
  }
112
  ]
113
  }
 
119
  "meals": [
120
  {
121
  "meal": "dinner",
122
+ "name": "Chicken with Mixed Vegetables and Pasta",
123
  "servings": 5,
124
  "ingredients": [
125
  {
126
+ "name": "chicken",
127
+ "display_name": "Chicken",
128
+ "quantity": 26.46,
129
+ "unit": "oz",
130
+ "display_quantity": "26.46 oz"
131
  },
132
  {
133
+ "name": "mixed vegetables",
134
+ "display_name": "Mixed Vegetables",
135
+ "quantity": 21.16,
136
+ "unit": "oz",
137
+ "display_quantity": "21.16 oz"
138
  },
139
  {
140
  "name": "pasta",
141
  "display_name": "Pasta",
142
+ "quantity": 17.64,
143
+ "unit": "oz",
144
+ "display_quantity": "17.64 oz"
145
  }
146
  ]
147
  },
148
  {
149
  "meal": "adult_lunch",
150
+ "name": "Chicken with Cherry Tomatoes and Rice",
151
  "servings": 1,
152
  "ingredients": [
153
  {
154
  "name": "chicken",
155
  "display_name": "Chicken",
156
+ "quantity": 5.29,
157
+ "unit": "oz",
158
+ "display_quantity": "5.29 oz"
159
  },
160
  {
161
+ "name": "cherry tomatoes",
162
+ "display_name": "Cherry Tomatoes",
163
+ "quantity": 4.23,
164
+ "unit": "oz",
165
+ "display_quantity": "4.23 oz"
166
  },
167
  {
168
+ "name": "rice",
169
+ "display_name": "Rice",
170
+ "quantity": 3.53,
171
+ "unit": "oz",
172
+ "display_quantity": "3.53 oz"
173
  }
174
  ]
175
  }
 
181
  "meals": [
182
  {
183
  "meal": "dinner",
184
+ "name": "Chicken with Mixed Vegetables and Pasta",
185
  "servings": 5,
186
  "ingredients": [
187
  {
188
+ "name": "chicken",
189
+ "display_name": "Chicken",
190
+ "quantity": 26.46,
191
+ "unit": "oz",
192
+ "display_quantity": "26.46 oz"
193
  },
194
  {
195
+ "name": "mixed vegetables",
196
+ "display_name": "Mixed Vegetables",
197
+ "quantity": 21.16,
198
+ "unit": "oz",
199
+ "display_quantity": "21.16 oz"
200
  },
201
  {
202
  "name": "pasta",
203
  "display_name": "Pasta",
204
+ "quantity": 17.64,
205
+ "unit": "oz",
206
+ "display_quantity": "17.64 oz"
207
  }
208
  ]
209
  }
 
215
  "meals": [
216
  {
217
  "meal": "dinner",
218
+ "name": "Chicken with Cherry Tomatoes and Rice",
219
  "servings": 5,
220
  "ingredients": [
221
  {
222
  "name": "chicken",
223
  "display_name": "Chicken",
224
+ "quantity": 26.46,
225
+ "unit": "oz",
226
+ "display_quantity": "26.46 oz"
227
  },
228
  {
229
+ "name": "cherry tomatoes",
230
+ "display_name": "Cherry Tomatoes",
231
+ "quantity": 21.16,
232
+ "unit": "oz",
233
+ "display_quantity": "21.16 oz"
234
  },
235
  {
236
+ "name": "rice",
237
+ "display_name": "Rice",
238
+ "quantity": 17.64,
239
+ "unit": "oz",
240
+ "display_quantity": "17.64 oz"
241
  }
242
  ]
243
  },
244
  {
245
  "meal": "adult_lunch",
246
+ "name": "Chicken with Mixed Vegetables and Pasta",
247
  "servings": 1,
248
  "ingredients": [
249
  {
250
+ "name": "chicken",
251
+ "display_name": "Chicken",
252
+ "quantity": 5.29,
253
+ "unit": "oz",
254
+ "display_quantity": "5.29 oz"
255
  },
256
  {
257
+ "name": "mixed vegetables",
258
+ "display_name": "Mixed Vegetables",
259
+ "quantity": 4.23,
260
+ "unit": "oz",
261
+ "display_quantity": "4.23 oz"
262
  },
263
  {
264
  "name": "pasta",
265
  "display_name": "Pasta",
266
+ "quantity": 3.53,
267
+ "unit": "oz",
268
+ "display_quantity": "3.53 oz"
269
  }
270
  ]
271
  }
 
277
  "meals": [
278
  {
279
  "meal": "lunch",
280
+ "name": "Chicken with Cherry Tomatoes and Rice",
281
  "servings": 5,
282
  "ingredients": [
283
  {
284
  "name": "chicken",
285
  "display_name": "Chicken",
286
+ "quantity": 26.46,
287
+ "unit": "oz",
288
+ "display_quantity": "26.46 oz"
289
  },
290
  {
291
+ "name": "cherry tomatoes",
292
+ "display_name": "Cherry Tomatoes",
293
+ "quantity": 21.16,
294
+ "unit": "oz",
295
+ "display_quantity": "21.16 oz"
296
  },
297
  {
298
+ "name": "rice",
299
+ "display_name": "Rice",
300
+ "quantity": 17.64,
301
+ "unit": "oz",
302
+ "display_quantity": "17.64 oz"
303
  }
304
  ]
305
  },
306
  {
307
  "meal": "dinner",
308
+ "name": "Chicken with Mixed Vegetables and Pasta",
309
  "servings": 5,
310
  "ingredients": [
311
  {
312
+ "name": "chicken",
313
+ "display_name": "Chicken",
314
+ "quantity": 26.46,
315
+ "unit": "oz",
316
+ "display_quantity": "26.46 oz"
317
  },
318
  {
319
+ "name": "mixed vegetables",
320
+ "display_name": "Mixed Vegetables",
321
+ "quantity": 21.16,
322
+ "unit": "oz",
323
+ "display_quantity": "21.16 oz"
324
  },
325
  {
326
  "name": "pasta",
327
  "display_name": "Pasta",
328
+ "quantity": 17.64,
329
+ "unit": "oz",
330
+ "display_quantity": "17.64 oz"
331
  }
332
  ]
333
  }
 
339
  "meals": [
340
  {
341
  "meal": "lunch",
342
+ "name": "Chicken with Cherry Tomatoes and Rice",
343
  "servings": 5,
344
  "ingredients": [
345
  {
346
  "name": "chicken",
347
  "display_name": "Chicken",
348
+ "quantity": 26.46,
349
+ "unit": "oz",
350
+ "display_quantity": "26.46 oz"
351
  },
352
  {
353
+ "name": "cherry tomatoes",
354
+ "display_name": "Cherry Tomatoes",
355
+ "quantity": 21.16,
356
+ "unit": "oz",
357
+ "display_quantity": "21.16 oz"
358
  },
359
  {
360
+ "name": "rice",
361
+ "display_name": "Rice",
362
+ "quantity": 17.64,
363
+ "unit": "oz",
364
+ "display_quantity": "17.64 oz"
365
  }
366
  ]
367
  },
368
  {
369
  "meal": "dinner",
370
+ "name": "Chicken with Mixed Vegetables and Pasta",
371
  "servings": 5,
372
  "ingredients": [
373
  {
374
+ "name": "chicken",
375
+ "display_name": "Chicken",
376
+ "quantity": 26.46,
377
+ "unit": "oz",
378
+ "display_quantity": "26.46 oz"
379
  },
380
  {
381
+ "name": "mixed vegetables",
382
+ "display_name": "Mixed Vegetables",
383
+ "quantity": 21.16,
384
+ "unit": "oz",
385
+ "display_quantity": "21.16 oz"
386
  },
387
  {
388
  "name": "pasta",
389
  "display_name": "Pasta",
390
+ "quantity": 17.64,
391
+ "unit": "oz",
392
+ "display_quantity": "17.64 oz"
393
  }
394
  ]
395
  }
src/Reachy_OpenWebUI/sub_apps/cookAIware/data/shopping_list.json CHANGED
@@ -1,27 +1,32 @@
1
  [
2
  {
3
- "name": "arroz",
4
- "quantity": 1600,
5
- "unit": "g"
 
6
  },
7
  {
8
- "name": "mix de verduras",
9
- "quantity": 2140,
10
- "unit": "g"
 
11
  },
12
  {
13
  "name": "pasta",
14
- "quantity": 200,
15
- "unit": "g"
 
16
  },
17
  {
18
- "name": "pollo",
19
- "quantity": 6700,
20
- "unit": "g"
 
21
  },
22
  {
23
- "name": "tomates cherry",
24
- "quantity": 2620,
25
- "unit": "g"
 
26
  }
27
  ]
 
1
  [
2
  {
3
+ "name": "rice",
4
+ "quantity": 56.44,
5
+ "unit": "oz",
6
+ "display_name": "Rice"
7
  },
8
  {
9
+ "name": "mixed vegetables",
10
+ "quantity": 75.49,
11
+ "unit": "oz",
12
+ "display_name": "Mixed Vegetables"
13
  },
14
  {
15
  "name": "pasta",
16
+ "quantity": 7.05,
17
+ "unit": "oz",
18
+ "display_name": "Pasta"
19
  },
20
  {
21
+ "name": "chicken",
22
+ "quantity": 236.34,
23
+ "unit": "oz",
24
+ "display_name": "Chicken"
25
  },
26
  {
27
+ "name": "cherry tomatoes",
28
+ "quantity": 92.42,
29
+ "unit": "oz",
30
+ "display_name": "Cherry Tomatoes"
31
  }
32
  ]
src/Reachy_OpenWebUI/sub_apps/cookAIware/profiles/cookaiware/cook_log_action.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+
5
+ from Reachy_OpenWebUI.tools.core_tools import Tool, ToolDependencies
6
+ from Reachy_OpenWebUI.sub_apps.cookAIware.cook_log import add_entry, clear_entries, list_entries
7
+
8
+
9
+ class CookLogActionTool(Tool):
10
+ name = "cook_log_action"
11
+ description = "Record or retrieve cooking notes, preferences, substitutions, and meal feedback."
12
+ parameters_schema = {
13
+ "type": "object",
14
+ "properties": {
15
+ "action": {"type": "string", "enum": ["add", "list", "search", "clear"]},
16
+ "note": {"type": "string"},
17
+ "title": {"type": "string"},
18
+ "category": {
19
+ "type": "string",
20
+ "enum": ["note", "preference", "meal_feedback", "substitution", "reminder"],
21
+ },
22
+ "tags": {"type": "array", "items": {"type": "string"}},
23
+ "related_items": {"type": "array", "items": {"type": "string"}},
24
+ "query": {"type": "string"},
25
+ "limit": {"type": "integer"},
26
+ },
27
+ "required": ["action"],
28
+ }
29
+
30
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
31
+ if deps.data_dir is None:
32
+ return {"status": "error", "message": "Data directory not available."}
33
+
34
+ action = str(kwargs.get("action") or "").strip().lower()
35
+
36
+ if action == "add":
37
+ note = str(kwargs.get("note") or "").strip()
38
+ if not note:
39
+ return {"status": "needs_clarification", "message": "What should I log?", "missing": ["note"]}
40
+ entry = add_entry(
41
+ deps.data_dir,
42
+ note=note,
43
+ title=kwargs.get("title"),
44
+ category=str(kwargs.get("category") or "note"),
45
+ tags=kwargs.get("tags") if isinstance(kwargs.get("tags"), list) else None,
46
+ related_items=kwargs.get("related_items") if isinstance(kwargs.get("related_items"), list) else None,
47
+ )
48
+ return {"status": "ok", "message": "Cooking note logged.", "entry": entry}
49
+
50
+ if action in {"list", "search"}:
51
+ query = str(kwargs.get("query") or "").strip() if action == "search" else None
52
+ limit = int(kwargs.get("limit") or 20)
53
+ return {
54
+ "status": "ok",
55
+ "message": "Cooking log listed.",
56
+ "entries": list_entries(deps.data_dir, limit=limit, query=query),
57
+ }
58
+
59
+ if action == "clear":
60
+ clear_entries(deps.data_dir)
61
+ return {"status": "ok", "message": "Cooking log cleared."}
62
+
63
+ return {"status": "error", "message": "Unknown action."}
src/Reachy_OpenWebUI/sub_apps/cookAIware/profiles/cookaiware/tools.txt CHANGED
@@ -3,10 +3,11 @@ inventory_action
3
  family_profile_action
4
  meal_plan_action
5
  shopping_list_action
6
- app_settings_action
7
- dance
8
- stop_dance
9
- play_emotion
 
10
  stop_emotion
11
  move_head
12
  sweep_look
 
3
  family_profile_action
4
  meal_plan_action
5
  shopping_list_action
6
+ app_settings_action
7
+ cook_log_action
8
+ dance
9
+ stop_dance
10
+ play_emotion
11
  stop_emotion
12
  move_head
13
  sweep_look
src/Reachy_OpenWebUI/sub_apps/reachy_mirror/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from Reachy_OpenWebUI.sub_apps.reachy_mirror.runtime_host import ReachyMirrorRuntimeHost
2
+
3
+ __all__ = ["ReachyMirrorRuntimeHost"]
src/Reachy_OpenWebUI/sub_apps/reachy_mirror/runtime.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import logging
5
+ import math
6
+ import threading
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from fastapi import FastAPI
12
+ from fastapi.responses import FileResponse, StreamingResponse
13
+ from pydantic import BaseModel
14
+ from starlette.staticfiles import StaticFiles
15
+
16
+ try:
17
+ import cv2
18
+ import numpy as np
19
+ except Exception: # pragma: no cover - mirror mode reports this at runtime
20
+ cv2 = None # type: ignore
21
+ np = None # type: ignore
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ VERTICAL_PITCH_CORRECTION = -5.0
27
+
28
+
29
+ class MirrorSettings(BaseModel):
30
+ frameRateDown: int | None = None
31
+ motionReduction: int | None = None
32
+ showProcessing: bool | None = None
33
+ isMirror: bool | None = None
34
+
35
+
36
+ class FrameData(BaseModel):
37
+ image: str | None = None
38
+
39
+
40
+ class ReachyMirrorRuntime:
41
+ def __init__(self) -> None:
42
+ self.ready = False
43
+ self.is_processing = False
44
+ self.last_frame: np.ndarray | None = None
45
+ self.head_angles = [0.0, 0.0, 0.0]
46
+ self.antenna_angles = [0.0, 0.0]
47
+ self.frame_rate_down = 30
48
+ self.motion_reduction = 60
49
+ self.show_processing = True
50
+ self.is_mirror = True
51
+ self.frame_count = 0
52
+ self.frame_count_total = 0
53
+ self.down_value = 0
54
+ self.down_value_avg = 0
55
+ self._face_mesh: Any | None = None
56
+ self._hands: Any | None = None
57
+ self._mp_draw: Any | None = None
58
+ self._mp_hands: Any | None = None
59
+
60
+ def app(self) -> FastAPI:
61
+ app = FastAPI(title="Reachy Mirror")
62
+ static_dir = Path(__file__).parent / "static"
63
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
64
+
65
+ @app.get("/")
66
+ def index() -> FileResponse:
67
+ return FileResponse(static_dir / "index.html")
68
+
69
+ @app.get("/ready")
70
+ def ready() -> dict[str, bool]:
71
+ return {"ready": self.ready}
72
+
73
+ @app.get("/webcam_feed")
74
+ def webcam_feed() -> StreamingResponse:
75
+ return StreamingResponse(
76
+ self._frame_generator(),
77
+ media_type="multipart/x-mixed-replace; boundary=frame",
78
+ )
79
+
80
+ @app.post("/settings")
81
+ def update_settings(state: MirrorSettings) -> dict[str, bool]:
82
+ if state.motionReduction is not None:
83
+ self.motion_reduction = max(0, min(100, int(state.motionReduction)))
84
+ if state.frameRateDown is not None:
85
+ self.frame_rate_down = max(1, min(60, int(state.frameRateDown)))
86
+ if state.showProcessing is not None:
87
+ self.show_processing = bool(state.showProcessing)
88
+ if state.isMirror is not None:
89
+ self.is_mirror = bool(state.isMirror)
90
+ return {"ok": True}
91
+
92
+ @app.post("/process_frame")
93
+ def process_frame(data: FrameData) -> dict[str, Any]:
94
+ return self.process_frame(data.image)
95
+
96
+ return app
97
+
98
+ def run(self, reachy_mini: Any, stop_event: threading.Event) -> None:
99
+ try:
100
+ self._ensure_vision()
101
+ except Exception as exc:
102
+ logger.error("Reachy Mirror vision dependencies are not available: %s", exc)
103
+ self.ready = False
104
+ while not stop_event.is_set():
105
+ time.sleep(0.5)
106
+ return
107
+ self.ready = True
108
+ t0_total = time.time()
109
+ t0_last = time.time()
110
+ t0 = time.time()
111
+
112
+ try:
113
+ from reachy_mini.utils import create_head_pose
114
+
115
+ reachy_mini.goto_target(
116
+ create_head_pose(pitch=VERTICAL_PITCH_CORRECTION),
117
+ antennas=[-0.5, 0.5],
118
+ body_yaw=0.0,
119
+ duration=1.0,
120
+ )
121
+ except Exception as exc:
122
+ logger.warning("Reachy Mirror greeting pose failed: %s", exc)
123
+
124
+ while not stop_event.is_set():
125
+ frame = self._get_robot_frame(reachy_mini)
126
+ if frame is not None:
127
+ image = cv2.resize(frame, (640, 480))
128
+ if self.is_mirror:
129
+ image = cv2.flip(image, 1)
130
+ self.last_frame = image
131
+
132
+ self._apply_robot_target(reachy_mini)
133
+ self.frame_count += 1
134
+ self.frame_count_total += 1
135
+
136
+ now = time.time()
137
+ elapsed = now - t0_last
138
+ sleep_time = max(0.0, (1.0 / max(1, self.frame_rate_down)) - elapsed)
139
+ t0_last = now
140
+ if now - t0 > 1.0:
141
+ self.down_value = math.floor(self.frame_count / (now - t0) * 10) / 10
142
+ self.down_value_avg = math.floor(self.frame_count_total / (now - t0_total) * 10) / 10
143
+ t0 = now
144
+ self.frame_count = 0
145
+ time.sleep(sleep_time)
146
+
147
+ self.ready = False
148
+
149
+ def process_frame(self, image: str | None) -> dict[str, Any]:
150
+ if not image or self.is_processing:
151
+ return {}
152
+ self.is_processing = True
153
+ try:
154
+ self._ensure_vision()
155
+ image_data = image.split(",", 1)[1] if "," in image else image
156
+ image_bytes = base64.b64decode(image_data)
157
+ frame = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR)
158
+ if frame is None:
159
+ return {"error": "Could not decode frame."}
160
+ processed = self._draw_landmarks_to_frame(frame)
161
+ if processed is not None and self.show_processing:
162
+ ok, buffer = cv2.imencode(".jpg", processed)
163
+ if not ok:
164
+ return {"error": "Could not encode frame."}
165
+ encoded = base64.b64encode(buffer).decode("utf-8")
166
+ return {
167
+ "image": f"data:image/jpeg;base64,{encoded}",
168
+ "head": [int(self.head_angles[0]), int(self.head_angles[1]), int(self.head_angles[2])],
169
+ "hands": [
170
+ int(180 * self.antenna_angles[0] / math.pi),
171
+ int(180 * self.antenna_angles[1] / math.pi),
172
+ ],
173
+ "downValue": self.down_value,
174
+ "downValueAvg": self.down_value_avg,
175
+ }
176
+ return {}
177
+ except Exception as exc:
178
+ logger.warning("Reachy Mirror frame processing failed: %s", exc)
179
+ return {"error": str(exc)}
180
+ finally:
181
+ self.is_processing = False
182
+
183
+ def _ensure_vision(self) -> None:
184
+ if self._face_mesh is not None:
185
+ return
186
+ if cv2 is None or np is None:
187
+ raise RuntimeError("OpenCV and NumPy are required for Reachy Mirror.")
188
+ import mediapipe as mp
189
+
190
+ self._mp_hands = mp.solutions.hands
191
+ self._mp_draw = mp.solutions.drawing_utils
192
+ self._face_mesh = mp.solutions.face_mesh.FaceMesh(
193
+ static_image_mode=True,
194
+ max_num_faces=1,
195
+ min_detection_confidence=0.5,
196
+ min_tracking_confidence=0.5,
197
+ )
198
+ self._hands = self._mp_hands.Hands()
199
+
200
+ def _get_robot_frame(self, reachy_mini: Any) -> np.ndarray | None:
201
+ try:
202
+ return reachy_mini.media.get_frame()
203
+ except Exception as exc:
204
+ logger.debug("Reachy Mirror robot frame unavailable: %s", exc)
205
+ return None
206
+
207
+ def _apply_robot_target(self, reachy_mini: Any) -> None:
208
+ try:
209
+ from reachy_mini.utils import create_head_pose
210
+
211
+ reduction = self.motion_reduction / 100.0
212
+ reachy_mini.set_target(
213
+ head=create_head_pose(
214
+ pitch=-self.head_angles[0] * reduction + VERTICAL_PITCH_CORRECTION,
215
+ yaw=self.head_angles[1] * reduction,
216
+ roll=-self.head_angles[2] * reduction,
217
+ ),
218
+ antennas=self.antenna_angles,
219
+ )
220
+ except Exception as exc:
221
+ logger.debug("Reachy Mirror target update failed: %s", exc)
222
+
223
+ def _frame_generator(self) -> Any:
224
+ while True:
225
+ if self.last_frame is None:
226
+ time.sleep(0.05)
227
+ continue
228
+ ok, jpeg = cv2.imencode(".jpg", self.last_frame)
229
+ if ok:
230
+ yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpeg.tobytes() + b"\r\n"
231
+ time.sleep(0.05)
232
+
233
+ @staticmethod
234
+ def _rotation_matrix_to_angles(rotation_matrix: np.ndarray) -> np.ndarray:
235
+ x = math.atan2(rotation_matrix[2, 1], rotation_matrix[2, 2])
236
+ y = math.atan2(
237
+ -rotation_matrix[2, 0],
238
+ math.sqrt(rotation_matrix[0, 0] ** 2 + rotation_matrix[1, 0] ** 2),
239
+ )
240
+ z = math.atan2(rotation_matrix[1, 0], rotation_matrix[0, 0])
241
+ return np.array([x, y, z]) * 180.0 / math.pi
242
+
243
+ def _draw_landmarks_to_frame(self, image: np.ndarray) -> np.ndarray | None:
244
+ if self.is_mirror:
245
+ image = cv2.flip(image, 1)
246
+ img_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
247
+ self._process_hands(image, img_rgb)
248
+ return self._process_head(image, img_rgb)
249
+
250
+ def _process_hands(self, image: np.ndarray, img_rgb: np.ndarray) -> None:
251
+ results = self._hands.process(img_rgb)
252
+ hand_angles: list[float] = []
253
+ hand_types: list[str] = []
254
+ if not results.multi_hand_landmarks:
255
+ self.antenna_angles = [0.0, 0.0]
256
+ return
257
+ for hand in results.multi_handedness:
258
+ hand_types.append(hand.classification[0].label)
259
+ for hand_lms in results.multi_hand_landmarks:
260
+ self._mp_draw.draw_landmarks(image, hand_lms, self._mp_hands.HAND_CONNECTIONS, None)
261
+ base = None
262
+ tip = None
263
+ h, w, _ = image.shape
264
+ for idx, lm in enumerate(hand_lms.landmark):
265
+ point = [int(lm.x * w), int(lm.y * h)]
266
+ if idx == 0:
267
+ base = point
268
+ elif idx == 8:
269
+ tip = point
270
+ if base is not None and tip is not None:
271
+ hand_angles.append(math.atan2(tip[0] - base[0], tip[1] - base[1]))
272
+
273
+ angles = [0.0, 0.0]
274
+ for index, angle in enumerate(hand_angles[:2]):
275
+ hand_type = hand_types[index] if index < len(hand_types) else ""
276
+ if hand_type == "Left":
277
+ angles[0] = -angle + math.pi
278
+ else:
279
+ angles[1] = -angle - math.pi
280
+ if abs(angles[0]) > math.pi:
281
+ angles[0] -= 2 * math.pi
282
+ if abs(angles[1]) > math.pi:
283
+ angles[1] += 2 * math.pi
284
+ self.antenna_angles = angles
285
+
286
+ def _process_head(self, image: np.ndarray, img_rgb: np.ndarray) -> np.ndarray | None:
287
+ results = self._face_mesh.process(img_rgb)
288
+ if not results.multi_face_landmarks:
289
+ return None
290
+
291
+ real_world = np.array(
292
+ [[285, 528, 200], [285, 371, 152], [197, 574, 128], [173, 425, 108], [360, 574, 128], [391, 425, 108]],
293
+ dtype=np.float64,
294
+ )
295
+ h, w, _ = image.shape
296
+ image_points = []
297
+ for face_landmarks in results.multi_face_landmarks:
298
+ for idx, lm in enumerate(face_landmarks.landmark):
299
+ x, y = int(lm.x * w), int(lm.y * h)
300
+ if self.show_processing:
301
+ cv2.circle(image, (x, y), 1, (255, 196, 69), cv2.FILLED)
302
+ if idx in [1, 9, 57, 130, 287, 359]:
303
+ image_points.append([x, y])
304
+ break
305
+
306
+ if len(image_points) != 6:
307
+ return None
308
+ image_points_np = np.array(image_points, dtype=np.float64)
309
+ focal_length = float(w)
310
+ camera_matrix = np.array([[focal_length, 0, w / 2], [0, focal_length, h / 2], [0, 0, 1]])
311
+ dist_matrix = np.zeros((4, 1), dtype=np.float64)
312
+ ok, rotation_vec, _ = cv2.solvePnP(real_world, image_points_np, camera_matrix, dist_matrix)
313
+ if not ok:
314
+ return None
315
+ rotation_matrix, _ = cv2.Rodrigues(rotation_vec)
316
+ self.head_angles = self._rotation_matrix_to_angles(rotation_matrix).tolist()
317
+ return image
src/Reachy_OpenWebUI/sub_apps/reachy_mirror/runtime_host.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import threading
5
+ from typing import Any, Callable
6
+
7
+ from Reachy_OpenWebUI.sub_apps.reachy_mirror.runtime import ReachyMirrorRuntime
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _thread_running(thread: Any) -> bool:
14
+ return bool(thread is not None and hasattr(thread, "is_alive") and thread.is_alive())
15
+
16
+
17
+ class ReachyMirrorRuntimeHost:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ robot: Any,
22
+ handler: Any,
23
+ reset_live_pipeline: Callable[[], None],
24
+ restore_head_tracking: Callable[[], None] | None = None,
25
+ ) -> None:
26
+ self._robot = robot
27
+ self._handler = handler
28
+ self._reset_live_pipeline = reset_live_pipeline
29
+ self._restore_head_tracking = restore_head_tracking
30
+ self._runtime = ReachyMirrorRuntime()
31
+ self._app = self._runtime.app()
32
+ self._thread: threading.Thread | None = None
33
+ self._stop_event = threading.Event()
34
+ self._normal_movement_was_running = False
35
+ self._head_wobbler_was_running = False
36
+
37
+ def app(self) -> Any:
38
+ return self._app
39
+
40
+ def is_running(self) -> bool:
41
+ return bool(self._thread and self._thread.is_alive())
42
+
43
+ def start(self) -> dict[str, Any]:
44
+ if self.is_running():
45
+ return {"ok": True, "message": "Reachy Mirror already running."}
46
+ self._reset_live_pipeline()
47
+ self._pause_workers()
48
+ self._stop_event.clear()
49
+
50
+ def _run() -> None:
51
+ try:
52
+ self._runtime.run(self._robot, self._stop_event)
53
+ except Exception:
54
+ logger.exception("Reachy Mirror runtime crashed")
55
+ finally:
56
+ if not self._stop_event.is_set():
57
+ self._restore_workers()
58
+
59
+ self._thread = threading.Thread(target=_run, name="reachy-mirror-runtime", daemon=True)
60
+ self._thread.start()
61
+ return {"ok": True, "message": "Reachy Mirror started."}
62
+
63
+ def stop(self, *, restart_normal: bool = True) -> dict[str, Any]:
64
+ was_running = self.is_running()
65
+ self._stop_event.set()
66
+ thread = self._thread
67
+ if thread is not None and thread.is_alive():
68
+ thread.join(timeout=5.0)
69
+ self._thread = None
70
+ if restart_normal:
71
+ self._restore_workers()
72
+ else:
73
+ self._normal_movement_was_running = False
74
+ self._head_wobbler_was_running = False
75
+ return {"ok": True, "message": "Reachy Mirror stopped." if was_running else "Reachy Mirror was not running."}
76
+
77
+ def _pause_workers(self) -> None:
78
+ deps = getattr(self._handler, "deps", None)
79
+ movement_manager = getattr(deps, "movement_manager", None)
80
+ head_wobbler = getattr(deps, "head_wobbler", None)
81
+ camera_worker = getattr(deps, "camera_worker", None)
82
+ self._normal_movement_was_running = bool(
83
+ movement_manager is not None and _thread_running(getattr(movement_manager, "_thread", None))
84
+ )
85
+ self._head_wobbler_was_running = bool(
86
+ head_wobbler is not None and _thread_running(getattr(head_wobbler, "_thread", None))
87
+ )
88
+ if camera_worker is not None and hasattr(camera_worker, "set_head_tracking_enabled"):
89
+ try:
90
+ camera_worker.set_head_tracking_enabled(False)
91
+ except Exception as exc:
92
+ logger.warning("Could not pause head tracking for Reachy Mirror: %s", exc)
93
+ if movement_manager is not None and self._normal_movement_was_running:
94
+ try:
95
+ movement_manager.stop()
96
+ except Exception as exc:
97
+ logger.warning("Could not pause normal movement for Reachy Mirror: %s", exc)
98
+ if head_wobbler is not None and self._head_wobbler_was_running:
99
+ try:
100
+ head_wobbler.stop()
101
+ except Exception as exc:
102
+ logger.warning("Could not pause speech movement for Reachy Mirror: %s", exc)
103
+
104
+ def _restore_workers(self) -> None:
105
+ deps = getattr(self._handler, "deps", None)
106
+ movement_manager = getattr(deps, "movement_manager", None)
107
+ head_wobbler = getattr(deps, "head_wobbler", None)
108
+ if movement_manager is not None and self._normal_movement_was_running:
109
+ try:
110
+ movement_manager.start()
111
+ except Exception as exc:
112
+ logger.warning("Could not restore normal movement after Reachy Mirror: %s", exc)
113
+ if head_wobbler is not None and self._head_wobbler_was_running:
114
+ try:
115
+ head_wobbler.start()
116
+ except Exception as exc:
117
+ logger.warning("Could not restore speech movement after Reachy Mirror: %s", exc)
118
+ if self._restore_head_tracking is not None:
119
+ try:
120
+ self._restore_head_tracking()
121
+ except Exception as exc:
122
+ logger.warning("Could not restore head tracking after Reachy Mirror: %s", exc)
123
+ self._normal_movement_was_running = False
124
+ self._head_wobbler_was_running = False
src/Reachy_OpenWebUI/sub_apps/reachy_mirror/static/index.html ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Reachy Mirror</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <link rel="stylesheet" href="./static/style.css" />
8
+ </head>
9
+ <body>
10
+ <h1>Reachy Mirror</h1>
11
+ <div class="container">
12
+
13
+ <div class="controls">
14
+ <div class="controls-panel panel">
15
+ <div class="setting-item">
16
+ <div class="setting-label">
17
+ <label for="motionReductionSlider">Motion</label>
18
+ </div>
19
+ <div class="slider-wrapper">
20
+ <input type="range" id="motionReductionSlider" min="20" max="100" class="slider" />
21
+ </div>
22
+ <div class="setting-value">
23
+ <span id="motionReductionValue"></span>%
24
+ </div>
25
+ </div>
26
+
27
+ <div class="setting-item">
28
+ <div class="setting-label">
29
+ <label for="frameRateUpSlider">Upload Limit</label>
30
+ </div>
31
+ <div class="slider-wrapper">
32
+ <input type="range" id="frameRateUpSlider" min="1" max="30" class="slider" />
33
+ </div>
34
+ <div class="setting-value">
35
+ <span id="frameRateUpValue"></span> FPS
36
+ </div>
37
+ </div>
38
+
39
+ <div class="setting-item">
40
+ <div class="setting-label">
41
+ <label for="frameRateDownSlider">Robot Limit</label>
42
+ </div>
43
+ <div class="slider-wrapper">
44
+ <input type="range" id="frameRateDownSlider" min="1" max="60" class="slider" />
45
+ </div>
46
+ <div class="setting-value">
47
+ <span id="frameRateDownValue"></span> FPS
48
+ </div>
49
+ </div>
50
+ </div>
51
+
52
+ <div class="controls-panel panel">
53
+ <div class="setting-item">
54
+ <div class="setting-label-right">
55
+ <label for="showProcessingToggle">Show Process</label>
56
+ </div>
57
+ <label class="switch">
58
+ <input type="checkbox" id="showProcessingToggle" checked />
59
+ <span class="switch-slider"></span>
60
+ </label>
61
+ </div>
62
+
63
+ <div class="setting-item">
64
+ <div class="setting-label-right">
65
+ <label for="isMirrorToggle">Mirror Mode</label>
66
+ </div>
67
+ <label class="switch">
68
+ <input type="checkbox" id="isMirrorToggle" checked />
69
+ <span class="switch-slider"></span>
70
+ </label>
71
+ </div>
72
+
73
+ <div class="setting-button">
74
+ <button id="resetButton">RESET</button>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <div class="video-container">
80
+ <video id="webcam" autoplay playsinline></video>
81
+ <div id="processed-wrapper" style="display: none;">
82
+ <img id="processed" />
83
+ <div id="processed-head"></div>
84
+ <div id="processed-hands"></div>
85
+ </div>
86
+ <img id="remote" style="display: none;" />
87
+ </div>
88
+
89
+ <div class="status">
90
+ <div id="spinner"></div>
91
+ <div id="status">Loading...</div>
92
+ </div>
93
+ </div>
94
+ <div class="footer">
95
+ Reachy Mirror
96
+ </div>
97
+ <script src="./static/main.js"></script>
98
+ </body>
99
+ </html>
src/Reachy_OpenWebUI/sub_apps/reachy_mirror/static/main.js ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ** INITIAL VARS
2
+ let appState
3
+ let stream
4
+ let frameRateUp = 10
5
+ let frameCount = 0
6
+ let frameCountTotal = 0
7
+ let streamInterval
8
+ let readyInterval
9
+ let ready = false
10
+ let t0 = new Date()
11
+ let t0Total = new Date()
12
+
13
+ // ** PROCESS STATUS
14
+ async function checkStatus() {
15
+ try {
16
+ const resp = await fetch('./ready')
17
+ const data = await resp.json()
18
+ if( data.ready ) {
19
+ status.textContent = 'Streaming'
20
+ remote.style.display = ''
21
+ spinner.style.display = 'none'
22
+ remote.setAttribute('src', './webcam_feed')
23
+ await startWebcam()
24
+ await setAppState()
25
+ setStreamInterval()
26
+ setReadyInterval()
27
+ }
28
+ else {
29
+ status.textContent = 'Initializing...'
30
+ processedWrapper.style.display = 'none'
31
+ spinner.style.display = 'block'
32
+ }
33
+ } catch( e ) {
34
+ status.textContent = 'Connecting...'
35
+ processedWrapper.style.display = 'none'
36
+ remote.style.display = 'none'
37
+ remote.setAttribute('src', '')
38
+ spinner.style.display = 'block'
39
+ }
40
+ }
41
+
42
+ function setReadyInterval(running) {
43
+ if( readyInterval ) {
44
+ clearInterval(readyInterval)
45
+ readyInterval = null
46
+ }
47
+ if( running ) readyInterval = setInterval(checkStatus, 1000)
48
+ }
49
+
50
+ function setStreamInterval() {
51
+ if( streamInterval ) clearInterval(streamInterval)
52
+ streamInterval = setInterval(sendFrameToBackend, 1000/appState.frameRateUp)
53
+ }
54
+
55
+ async function startWebcam() {
56
+ try {
57
+ stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 } } })
58
+ webcam.srcObject = stream
59
+ frameCount = 0
60
+ frameCountTotal = 0
61
+ t0 = new Date()
62
+ t0Total = new Date()
63
+ } catch (err) {
64
+ console.error('Video error:', err)
65
+ }
66
+ }
67
+
68
+ function stopWebcam() {
69
+ if( stream ) {
70
+ stream.getTracks().forEach(track => track.stop())
71
+ webcam.srcObject = null
72
+ stream = null
73
+ }
74
+
75
+ if( streamInterval ) {
76
+ clearInterval(streamInterval)
77
+ streamInterval = null
78
+ }
79
+
80
+ processed.src = ''
81
+ processedHead.textContent = ''
82
+ processedHands.textContent = ''
83
+ processedWrapper.style.display = 'none'
84
+ }
85
+
86
+ // ** HTML ELEMENT
87
+ const status = document.getElementById('status')
88
+ const spinner = document.getElementById('spinner')
89
+ const webcam = document.getElementById('webcam')
90
+ const remote = document.getElementById('remote')
91
+ const processed = document.getElementById('processed')
92
+ const processedWrapper = document.getElementById('processed-wrapper')
93
+ const processedHead = document.getElementById('processed-head')
94
+ const processedHands = document.getElementById('processed-hands')
95
+ const frameRateUpValue = document.getElementById('frameRateUpValue')
96
+ const frameRateDownValue = document.getElementById('frameRateDownValue')
97
+ const motionReductionValue = document.getElementById('motionReductionValue')
98
+ const showProcessingToggle = document.getElementById('showProcessingToggle')
99
+ const isMirrorToggle = document.getElementById('isMirrorToggle')
100
+
101
+ // * FPS UP
102
+ document.getElementById('frameRateUpSlider').addEventListener('input', function() {
103
+ document.getElementById('frameRateUpValue').textContent = parseInt(this.value)
104
+ })
105
+ document.getElementById('frameRateUpSlider').addEventListener('change', function() {
106
+ const value = parseInt(this.value)
107
+ appState.frameRateUp = value
108
+ setCookie('frameRateUp', value)
109
+ setStreamInterval()
110
+ })
111
+
112
+ // * FPS DOWN
113
+ document.getElementById('frameRateDownSlider').addEventListener('input', function() {
114
+ document.getElementById('frameRateDownValue').textContent = parseInt(this.value)
115
+ })
116
+ document.getElementById('frameRateDownSlider').addEventListener('change', async function() {
117
+ const value = parseInt(this.value)
118
+ appState.frameRateDown = value
119
+ setCookie('frameRateDown', value)
120
+ await fetch('./settings', {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ frameRateDown: value })
124
+ })
125
+ })
126
+
127
+ // * MOTION REDUCTION
128
+ document.getElementById('motionReductionSlider').addEventListener('input', function() {
129
+ document.getElementById('motionReductionValue').textContent = parseInt(this.value)
130
+ })
131
+ document.getElementById('motionReductionSlider').addEventListener('change', async function() {
132
+ const value = parseInt(this.value)
133
+ appState.motionReduction = value
134
+ await fetch('./settings', {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify({ motionReduction: value })
138
+ })
139
+ setCookie('motionReduction', value)
140
+ })
141
+
142
+ // * PROCESSING DISPLAY
143
+ document.getElementById('showProcessingToggle').addEventListener('change', async function() {
144
+ appState.showProcessing = this.checked
145
+ await fetch('./settings', {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ showProcessing: this.checked })
149
+ })
150
+ setCookie('showProcessing', this.checked ? 'true' : 'false')
151
+ processedWrapper.style.display = this.checked ? 'block' : 'none'
152
+ })
153
+
154
+ // * MIRROR MODE
155
+ document.getElementById('isMirrorToggle').addEventListener('change', async function() {
156
+ appState.isMirror = this.checked
157
+ await fetch('./settings', {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ isMirror: this.checked })
161
+ })
162
+ setCookie('isMirror', this.checked ? 'true' : 'false')
163
+ })
164
+
165
+ document.getElementById('resetButton').addEventListener('click', async function() {
166
+ reset()
167
+ setAppState()
168
+ })
169
+
170
+
171
+ // ** COOKIE MANAGEMENT
172
+ function setCookie(key, value) {
173
+ const expires = new Date()
174
+ expires.setTime(expires.getTime() + (1 * 24 * 60 * 60 * 1000))
175
+ document.cookie = key + '=' + value + ';expires=' + expires.toUTCString()
176
+ }
177
+
178
+ function getCookie(key) {
179
+ const keyValue = document.cookie.match('(^|;) ?' + key + '=([^;]*)(;|$)')
180
+ return keyValue ? keyValue[2] : null
181
+ }
182
+
183
+ function deleteCookie(name) {
184
+ document.cookie = name+'=; Max-Age=-99999999;';
185
+ }
186
+
187
+ // ** BACKEND HANDLER
188
+ async function sendFrameToBackend() {
189
+ if (!webcam || webcam.paused || webcam.ended) return
190
+
191
+ const canvas = document.createElement('canvas')
192
+ canvas.width = webcam.videoWidth
193
+ canvas.height = webcam.videoHeight
194
+ const ctx = canvas.getContext('2d')
195
+ ctx.drawImage(webcam, 0, 0)
196
+ const imageData = canvas.toDataURL('image/jpeg', 0.8)
197
+
198
+ try {
199
+ const resp = await fetch('./process_frame', {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ image: imageData })
203
+ })
204
+ if( appState.showProcessing ) {
205
+ const data = await resp.json()
206
+ if( data.image ) {
207
+ processed.src = data.image
208
+ frameCount++
209
+ frameCountTotal++
210
+ const now = new Date()
211
+ if( now - t0 > 1000 ) { // 1 second
212
+ status.textContent = `Streaming `
213
+ status.textContent += `• UP: ${Math.floor(frameCount/(now - t0)*10000)/10} FPS (~avg ${Math.floor(frameCountTotal/(now - t0Total)*10000)/10})`
214
+ if( data.downValue && data.downValueAvg ) status.textContent += ` • DOWN: ${data.downValue} FPS (~avg ${data.downValueAvg})`
215
+ t0 = now
216
+ frameCount = 0
217
+ }
218
+ if( data.head ) processedHead.textContent = `pitch: ${data.head[0]}\nyaw: ${data.head[1]}\nroll: ${data.head[2]}`
219
+ if( data.hands ) processedHands.textContent = `left: ${data.hands[0]}\nright: ${data.hands[1]}`
220
+ processedWrapper.style.display = 'block'
221
+ }
222
+ else if( data.error ) console.error('Backend error:', data.error)
223
+ }
224
+ }
225
+ catch(e) {
226
+ console.error('Network error:', e)
227
+ stopWebcam()
228
+ setReadyInterval(true)
229
+ }
230
+ }
231
+
232
+ function reset() {
233
+ deleteCookie('frameRateUp')
234
+ deleteCookie('frameRateDown')
235
+ deleteCookie('motionReduction')
236
+ deleteCookie('showProcessing')
237
+ deleteCookie('isMirror')
238
+ }
239
+
240
+ async function setAppState() {
241
+ appState = {}
242
+ appState.frameRateUp = frameRateUpSlider.value = frameRateUpValue.textContent = getCookie('frameRateUp') ?? 30, // 30 FPS UP
243
+ appState.frameRateDown = frameRateDownSlider.value = frameRateDownValue.textContent = getCookie('frameRateDown') ?? 60, // 60 FPS DOWN
244
+ appState.motionReduction = motionReductionSlider.value = motionReductionValue.textContent = getCookie('motionReduction') ?? 60, // 60% by default
245
+ appState.showProcessing = showProcessingToggle.checked = getCookie('showProcessing') === null ? true : getCookie('showProcessing') === 'true' ? true : false // Show processed
246
+ processedWrapper.style.display = 'none'
247
+ appState.isMirror = isMirrorToggle.checked = getCookie('isMirror') === null ? true : getCookie('isMirror') === 'true' ? true : false // Mirror mode
248
+
249
+ await fetch('./settings', {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/json' },
252
+ body: JSON.stringify({
253
+ frameRateDown: appState.frameRateDown,
254
+ motionReduction: appState.motionReduction,
255
+ showProcessing: appState.showProcessing,
256
+ isMirror: appState.isMirror
257
+ })
258
+ })
259
+ }
260
+
261
+ window.addEventListener('beforeunload', _ => {
262
+ stopWebcam()
263
+ setReadyInterval()
264
+ })
265
+
266
+ setReadyInterval(true)
src/Reachy_OpenWebUI/sub_apps/reachy_mirror/static/style.css ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #667eea;
3
+ --bg-2: #764ba2;
4
+ --panel: rgba(11, 18, 36, 0.8);
5
+ --border: rgba(255, 255, 255, 0.08);
6
+ --text: #eaf2ff;
7
+ --muted: #9fb6d7;
8
+ --ok: #4ce0b3;
9
+ --warn: #ffb547;
10
+ --error: #ff5c70;
11
+ --accent: #45c4ff;
12
+ --hover: #1a739d;
13
+ --accent-2: #5ef0c1;
14
+ --background: #e9ecef;
15
+ --shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
16
+ }
17
+
18
+ * {
19
+ margin: 0;
20
+ padding: 0;
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ body {
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
26
+ background: radial-gradient(circle at 20% 20%, rgba(69, 196, 255, 0.16), transparent 35%),
27
+ radial-gradient(circle at 80% 0%, rgba(94, 240, 193, 0.16), transparent 32%),
28
+ linear-gradient(135deg, var(--bg), var(--bg-2));
29
+ color: var(--text);
30
+ min-height: 100vh;
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ justify-content: center;
35
+ color: var(--text);
36
+ padding: 20px;
37
+ }
38
+
39
+ a,
40
+ a:hover,
41
+ &:link,
42
+ &:visited,
43
+ &:active {
44
+ color: white;
45
+ text-decoration: none;
46
+ }
47
+
48
+ .footer {
49
+ position: sticky;
50
+ bottom: 10px;
51
+ left: 50%;
52
+ transform: translateX(-50%);
53
+ }
54
+
55
+ #spinner {
56
+ width: 46px;
57
+ height: 46px;
58
+ border: 4px solid rgba(255,255,255,0.15);
59
+ border-top-color: var(--accent);
60
+ border-radius: 50%;
61
+ animation: spin 1s linear infinite;
62
+ margin-bottom: 12px;
63
+ }
64
+ @keyframes spin { to { transform: rotate(360deg); } }
65
+ #status { color: var(--text); margin: 0; letter-spacing: 0.4px; }
66
+
67
+ .panel {
68
+ background: var(--panel);
69
+ border: 1px solid var(--border);
70
+ border-radius: 14px;
71
+ padding: 16px 24px;
72
+ box-shadow: var(--shadow);
73
+ backdrop-filter: blur(10px);
74
+ }
75
+
76
+ .container {
77
+ text-align: center;
78
+ width: 100%;
79
+ padding-bottom: 20px;
80
+ }
81
+
82
+ .video-container {
83
+ justify-content: center;
84
+ display: flex;
85
+ gap: 20px;
86
+ margin-bottom: 10px;
87
+ }
88
+
89
+ h1 {
90
+ display: flex;
91
+ align-items: center;
92
+ margin-bottom: 15px;
93
+ }
94
+
95
+ .emoji {
96
+ font-size: 24px;
97
+ margin-right: 10px;
98
+ width: 32px;
99
+ height: 32px;
100
+ background-color: white;
101
+ border-radius: 50%;
102
+ display: flex;
103
+ justify-content: center;
104
+ align-items: center;
105
+ }
106
+
107
+ .status {
108
+ display: flex;
109
+ flex-direction: column;
110
+ justify-content: center;
111
+ align-items: center;
112
+ gap: 10px;
113
+ }
114
+
115
+ #processed-wrapper {
116
+ position: relative;
117
+ width: 50%;
118
+ }
119
+
120
+ #processed-head,
121
+ #processed-hands {
122
+ position: absolute;
123
+ top: 20px;
124
+ color: var(--text);
125
+ white-space: pre-wrap;
126
+ text-transform: capitalize;
127
+ font-family: 'Courier New', Courier, monospace;
128
+ text-align: left;
129
+ }
130
+
131
+ #processed-head {
132
+ left: 20px;
133
+ }
134
+
135
+ #processed-hands {
136
+ right: 20px;
137
+ }
138
+
139
+ #remote,
140
+ #webcam,
141
+ #processed {
142
+ background: white;
143
+ width: 50%;
144
+ border-radius: 1em;
145
+ padding: 4px;
146
+ box-shadow: var(--shadow);
147
+ backdrop-filter: blur(10px);
148
+ }
149
+
150
+ #processed {
151
+ width: 100%;
152
+ }
153
+
154
+ #webcam {
155
+ display: none;
156
+ visibility: hidden;
157
+ }
158
+
159
+ .controls {
160
+ border-radius: 8px;
161
+ margin: 20px 0;
162
+ display: flex;
163
+ justify-content: center;
164
+ gap: 20px;
165
+ }
166
+
167
+ .controls-panel {
168
+ display: flex;
169
+ flex-direction: column;
170
+ gap: 10px;
171
+ }
172
+
173
+ .setting-item {
174
+ display: flex;
175
+ align-items: center;
176
+ padding: 8px 0;
177
+ }
178
+
179
+ .setting-button {
180
+ display: flex;
181
+ justify-content: end;
182
+ padding: 8px 0;
183
+ }
184
+
185
+ label {
186
+ display: flex;
187
+ align-items: center;
188
+ }
189
+
190
+ .setting-label {
191
+ width: 160px;
192
+ text-align: left;
193
+ }
194
+
195
+ .setting-label-right {
196
+ width: 190px;
197
+ text-align: left;
198
+ }
199
+
200
+ .setting-value {
201
+ width: 80px;
202
+ text-align: right;
203
+ }
204
+
205
+
206
+ button {
207
+ display: inline-flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ padding: 11px 16px;
211
+ border: none;
212
+ border-radius: 10px;
213
+ background: linear-gradient(120deg, var(--accent), var(--accent-2));
214
+ color: var(--text);
215
+ cursor: pointer;
216
+ font-weight: 600;
217
+ letter-spacing: 0.2px;
218
+ box-shadow: 0 14px 40px rgba(69, 196, 255, 0.25);
219
+ transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
220
+ }
221
+ button:hover { filter: brightness(1.06); transform: translateY(-1px); }
222
+ button:active { transform: translateY(0); }
223
+ button.ghost {
224
+ background: rgba(255, 255, 255, 0.05);
225
+ color: var(--text);
226
+ box-shadow: none;
227
+ border: 1px solid var(--border);
228
+ }
229
+ button.ghost:hover { border-color: rgba(94, 240, 193, 0.4); }
230
+
231
+ .slider-container {
232
+ width: 100%;
233
+ margin: 15px 0;
234
+ }
235
+
236
+ .slider-container label {
237
+ display: block;
238
+ margin-bottom: 5px;
239
+ font-weight: 500;
240
+ color: #495057;
241
+ }
242
+
243
+ .slider-wrapper {
244
+ display: flex;
245
+ align-items: center;
246
+ gap: 10px;
247
+ }
248
+
249
+ .slider-value {
250
+ min-width: 30px;
251
+ text-align: right;
252
+ font-weight: bold;
253
+ color: var(--accent);
254
+ }
255
+
256
+ input[type="range"] {
257
+ -webkit-appearance: none;
258
+ appearance: none;
259
+ width: 100%;
260
+ height: 10px;
261
+ border-radius: 4px;
262
+ background: var(--background);
263
+ outline: none;
264
+ margin: 10px 0;
265
+ }
266
+
267
+ .switch {
268
+ position: relative;
269
+ display: inline-block;
270
+ width: 50px;
271
+ height: 24px;
272
+ }
273
+
274
+ .switch input {
275
+ opacity: 0;
276
+ width: 0;
277
+ height: 0;
278
+ }
279
+
280
+ .switch-slider {
281
+ position: absolute;
282
+ cursor: pointer;
283
+ top: 0;
284
+ left: 0;
285
+ right: 0;
286
+ bottom: 0;
287
+ background-color: var(--muted);
288
+ transition: 0.4s;
289
+ border-radius: 24px;
290
+ }
291
+
292
+ .switch-slider:before {
293
+ position: absolute;
294
+ content: "";
295
+ height: 16px;
296
+ width: 16px;
297
+ left: 4px;
298
+ bottom: 4px;
299
+ background-color: var(--text);
300
+ transition: 0.4s;
301
+ border-radius: 50%;
302
+ }
303
+
304
+ input:checked + .switch-slider {
305
+ background-color: var(--accent);
306
+ }
307
+
308
+ input:focus + .switch-slider {
309
+ box-shadow: 0 0 1px var(--accent);
310
+ }
311
+
312
+ input[type="range"]::-moz-range-track {
313
+ width: 100%;
314
+ height: 10px;
315
+ border-radius: 4px;
316
+ background: var(--background);
317
+ border: none;
318
+ }
319
+
320
+ input[type="range"]::-webkit-slider-runnable-track {
321
+ height: 10px;
322
+ border-radius: 4px;
323
+ background: var(--background);
324
+ }
325
+
326
+ input[type="range"]::-moz-range-progress {
327
+ height: 10px;
328
+ border-radius: 4px;
329
+ background-color: var(--accent);
330
+ }
331
+
332
+ input[type="range"]::-webkit-slider-thumb {
333
+ -webkit-appearance: none;
334
+ appearance: none;
335
+ width: 24px;
336
+ height: 24px;
337
+ border-radius: 50%;
338
+ background: var(--accent);
339
+ cursor: pointer;
340
+ transition: all 0.15s ease;
341
+ transform: translateY(-8px);
342
+ }
343
+
344
+ input[type="range"]::-moz-range-thumb {
345
+ width: 24px;
346
+ height: 24px;
347
+ border: none;
348
+ border-radius: 50%;
349
+ background: var(--accent);
350
+ cursor: pointer;
351
+ transition: all 0.15s ease;
352
+ }
353
+
354
+ input[type="range"]::-webkit-slider-thumb:hover {
355
+ background: var(--hover);
356
+ transform: translateY(-8px) scale(1.2);
357
+ }
358
+
359
+ input[type="range"]::-moz-range-thumb:hover {
360
+ background: var(--hover);
361
+ transform: scale(1.2);
362
+ }
363
+
364
+ input:checked + .switch-slider:before {
365
+ transform: translateX(26px);
366
+ }
367
+