Spaces:
Running
Running
Mirror Time
Browse files- pyproject.toml +1 -0
- src/Reachy_OpenWebUI/console.py +23 -0
- src/Reachy_OpenWebUI/sub_apps/cookAIware/cook_log.py +68 -0
- src/Reachy_OpenWebUI/sub_apps/cookAIware/data/app_settings.json +2 -1
- src/Reachy_OpenWebUI/sub_apps/cookAIware/data/inventory.json +25 -25
- src/Reachy_OpenWebUI/sub_apps/cookAIware/data/meal_plan.json +168 -168
- src/Reachy_OpenWebUI/sub_apps/cookAIware/data/shopping_list.json +19 -14
- src/Reachy_OpenWebUI/sub_apps/cookAIware/profiles/cookaiware/cook_log_action.py +63 -0
- src/Reachy_OpenWebUI/sub_apps/cookAIware/profiles/cookaiware/tools.txt +5 -4
- src/Reachy_OpenWebUI/sub_apps/reachy_mirror/__init__.py +3 -0
- src/Reachy_OpenWebUI/sub_apps/reachy_mirror/runtime.py +317 -0
- src/Reachy_OpenWebUI/sub_apps/reachy_mirror/runtime_host.py +124 -0
- src/Reachy_OpenWebUI/sub_apps/reachy_mirror/static/index.html +99 -0
- src/Reachy_OpenWebUI/sub_apps/reachy_mirror/static/main.js +266 -0
- src/Reachy_OpenWebUI/sub_apps/reachy_mirror/static/style.css +367 -0
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": "
|
|
|
|
| 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": "
|
| 4 |
-
"display_name": "
|
| 5 |
-
"quantity":
|
| 6 |
-
"unit": "
|
| 7 |
"expiration_date": "2027-02-07",
|
| 8 |
-
"storage_location": "
|
| 9 |
},
|
| 10 |
{
|
| 11 |
"name": "pasta",
|
| 12 |
-
"display_name": "
|
| 13 |
-
"quantity":
|
| 14 |
-
"unit": "
|
| 15 |
"expiration_date": "2027-02-07",
|
| 16 |
-
"storage_location": "
|
| 17 |
},
|
| 18 |
{
|
| 19 |
-
"name": "
|
| 20 |
-
"display_name": "
|
| 21 |
"quantity": 0.0,
|
| 22 |
-
"unit": "
|
| 23 |
"expiration_date": "2026-02-10",
|
| 24 |
-
"storage_location": "
|
| 25 |
},
|
| 26 |
{
|
| 27 |
-
"name": "
|
| 28 |
-
"display_name": "
|
| 29 |
-
"quantity":
|
| 30 |
-
"unit": "
|
| 31 |
"expiration_date": "2026-02-14",
|
| 32 |
-
"storage_location": "
|
| 33 |
},
|
| 34 |
{
|
| 35 |
-
"name": "
|
| 36 |
-
"display_name": "
|
| 37 |
"quantity": 0.0,
|
| 38 |
-
"unit": "
|
| 39 |
"expiration_date": "2026-02-14",
|
| 40 |
"storage_location": null
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"name": "chicken",
|
| 44 |
-
"display_name": "
|
| 45 |
"quantity": 0.0,
|
| 46 |
-
"unit": "
|
| 47 |
"expiration_date": "2026-02-10",
|
| 48 |
-
"storage_location": "
|
| 49 |
},
|
| 50 |
{
|
| 51 |
"name": "corn on the cob",
|
| 52 |
-
"display_name": "
|
| 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
|
| 27 |
"servings": 5,
|
| 28 |
"ingredients": [
|
| 29 |
{
|
| 30 |
"name": "chicken",
|
| 31 |
"display_name": "Chicken",
|
| 32 |
-
"quantity":
|
| 33 |
-
"unit": "
|
| 34 |
-
"display_quantity": "
|
| 35 |
},
|
| 36 |
{
|
| 37 |
-
"name": "
|
| 38 |
-
"display_name": "
|
| 39 |
-
"quantity":
|
| 40 |
-
"unit": "
|
| 41 |
-
"display_quantity": "
|
| 42 |
},
|
| 43 |
{
|
| 44 |
-
"name": "
|
| 45 |
-
"display_name": "
|
| 46 |
-
"quantity":
|
| 47 |
-
"unit": "
|
| 48 |
-
"display_quantity": "
|
| 49 |
}
|
| 50 |
]
|
| 51 |
},
|
| 52 |
{
|
| 53 |
"meal": "adult_lunch",
|
| 54 |
-
"name": "
|
| 55 |
"servings": 1,
|
| 56 |
"ingredients": [
|
| 57 |
{
|
| 58 |
-
"name": "
|
| 59 |
-
"display_name": "
|
| 60 |
-
"quantity":
|
| 61 |
-
"unit": "
|
| 62 |
-
"display_quantity": "
|
| 63 |
},
|
| 64 |
{
|
| 65 |
-
"name": "
|
| 66 |
-
"display_name": "
|
| 67 |
-
"quantity":
|
| 68 |
-
"unit": "
|
| 69 |
-
"display_quantity": "
|
| 70 |
},
|
| 71 |
{
|
| 72 |
"name": "pasta",
|
| 73 |
"display_name": "Pasta",
|
| 74 |
-
"quantity":
|
| 75 |
-
"unit": "
|
| 76 |
-
"display_quantity": "
|
| 77 |
}
|
| 78 |
]
|
| 79 |
}
|
|
@@ -85,29 +85,29 @@
|
|
| 85 |
"meals": [
|
| 86 |
{
|
| 87 |
"meal": "dinner",
|
| 88 |
-
"name": "Chicken with
|
| 89 |
"servings": 5,
|
| 90 |
"ingredients": [
|
| 91 |
{
|
| 92 |
"name": "chicken",
|
| 93 |
"display_name": "Chicken",
|
| 94 |
-
"quantity":
|
| 95 |
-
"unit": "
|
| 96 |
-
"display_quantity": "
|
| 97 |
},
|
| 98 |
{
|
| 99 |
-
"name": "
|
| 100 |
-
"display_name": "
|
| 101 |
-
"quantity":
|
| 102 |
-
"unit": "
|
| 103 |
-
"display_quantity": "
|
| 104 |
},
|
| 105 |
{
|
| 106 |
-
"name": "
|
| 107 |
-
"display_name": "
|
| 108 |
-
"quantity":
|
| 109 |
-
"unit": "
|
| 110 |
-
"display_quantity": "
|
| 111 |
}
|
| 112 |
]
|
| 113 |
}
|
|
@@ -119,57 +119,57 @@
|
|
| 119 |
"meals": [
|
| 120 |
{
|
| 121 |
"meal": "dinner",
|
| 122 |
-
"name": "
|
| 123 |
"servings": 5,
|
| 124 |
"ingredients": [
|
| 125 |
{
|
| 126 |
-
"name": "
|
| 127 |
-
"display_name": "
|
| 128 |
-
"quantity":
|
| 129 |
-
"unit": "
|
| 130 |
-
"display_quantity": "
|
| 131 |
},
|
| 132 |
{
|
| 133 |
-
"name": "
|
| 134 |
-
"display_name": "
|
| 135 |
-
"quantity":
|
| 136 |
-
"unit": "
|
| 137 |
-
"display_quantity": "
|
| 138 |
},
|
| 139 |
{
|
| 140 |
"name": "pasta",
|
| 141 |
"display_name": "Pasta",
|
| 142 |
-
"quantity":
|
| 143 |
-
"unit": "
|
| 144 |
-
"display_quantity": "
|
| 145 |
}
|
| 146 |
]
|
| 147 |
},
|
| 148 |
{
|
| 149 |
"meal": "adult_lunch",
|
| 150 |
-
"name": "Chicken with
|
| 151 |
"servings": 1,
|
| 152 |
"ingredients": [
|
| 153 |
{
|
| 154 |
"name": "chicken",
|
| 155 |
"display_name": "Chicken",
|
| 156 |
-
"quantity":
|
| 157 |
-
"unit": "
|
| 158 |
-
"display_quantity": "
|
| 159 |
},
|
| 160 |
{
|
| 161 |
-
"name": "
|
| 162 |
-
"display_name": "
|
| 163 |
-
"quantity":
|
| 164 |
-
"unit": "
|
| 165 |
-
"display_quantity": "
|
| 166 |
},
|
| 167 |
{
|
| 168 |
-
"name": "
|
| 169 |
-
"display_name": "
|
| 170 |
-
"quantity":
|
| 171 |
-
"unit": "
|
| 172 |
-
"display_quantity": "
|
| 173 |
}
|
| 174 |
]
|
| 175 |
}
|
|
@@ -181,29 +181,29 @@
|
|
| 181 |
"meals": [
|
| 182 |
{
|
| 183 |
"meal": "dinner",
|
| 184 |
-
"name": "
|
| 185 |
"servings": 5,
|
| 186 |
"ingredients": [
|
| 187 |
{
|
| 188 |
-
"name": "
|
| 189 |
-
"display_name": "
|
| 190 |
-
"quantity":
|
| 191 |
-
"unit": "
|
| 192 |
-
"display_quantity": "
|
| 193 |
},
|
| 194 |
{
|
| 195 |
-
"name": "
|
| 196 |
-
"display_name": "
|
| 197 |
-
"quantity":
|
| 198 |
-
"unit": "
|
| 199 |
-
"display_quantity": "
|
| 200 |
},
|
| 201 |
{
|
| 202 |
"name": "pasta",
|
| 203 |
"display_name": "Pasta",
|
| 204 |
-
"quantity":
|
| 205 |
-
"unit": "
|
| 206 |
-
"display_quantity": "
|
| 207 |
}
|
| 208 |
]
|
| 209 |
}
|
|
@@ -215,57 +215,57 @@
|
|
| 215 |
"meals": [
|
| 216 |
{
|
| 217 |
"meal": "dinner",
|
| 218 |
-
"name": "Chicken with
|
| 219 |
"servings": 5,
|
| 220 |
"ingredients": [
|
| 221 |
{
|
| 222 |
"name": "chicken",
|
| 223 |
"display_name": "Chicken",
|
| 224 |
-
"quantity":
|
| 225 |
-
"unit": "
|
| 226 |
-
"display_quantity": "
|
| 227 |
},
|
| 228 |
{
|
| 229 |
-
"name": "
|
| 230 |
-
"display_name": "
|
| 231 |
-
"quantity":
|
| 232 |
-
"unit": "
|
| 233 |
-
"display_quantity": "
|
| 234 |
},
|
| 235 |
{
|
| 236 |
-
"name": "
|
| 237 |
-
"display_name": "
|
| 238 |
-
"quantity":
|
| 239 |
-
"unit": "
|
| 240 |
-
"display_quantity": "
|
| 241 |
}
|
| 242 |
]
|
| 243 |
},
|
| 244 |
{
|
| 245 |
"meal": "adult_lunch",
|
| 246 |
-
"name": "
|
| 247 |
"servings": 1,
|
| 248 |
"ingredients": [
|
| 249 |
{
|
| 250 |
-
"name": "
|
| 251 |
-
"display_name": "
|
| 252 |
-
"quantity":
|
| 253 |
-
"unit": "
|
| 254 |
-
"display_quantity": "
|
| 255 |
},
|
| 256 |
{
|
| 257 |
-
"name": "
|
| 258 |
-
"display_name": "
|
| 259 |
-
"quantity":
|
| 260 |
-
"unit": "
|
| 261 |
-
"display_quantity": "
|
| 262 |
},
|
| 263 |
{
|
| 264 |
"name": "pasta",
|
| 265 |
"display_name": "Pasta",
|
| 266 |
-
"quantity":
|
| 267 |
-
"unit": "
|
| 268 |
-
"display_quantity": "
|
| 269 |
}
|
| 270 |
]
|
| 271 |
}
|
|
@@ -277,57 +277,57 @@
|
|
| 277 |
"meals": [
|
| 278 |
{
|
| 279 |
"meal": "lunch",
|
| 280 |
-
"name": "Chicken with
|
| 281 |
"servings": 5,
|
| 282 |
"ingredients": [
|
| 283 |
{
|
| 284 |
"name": "chicken",
|
| 285 |
"display_name": "Chicken",
|
| 286 |
-
"quantity":
|
| 287 |
-
"unit": "
|
| 288 |
-
"display_quantity": "
|
| 289 |
},
|
| 290 |
{
|
| 291 |
-
"name": "
|
| 292 |
-
"display_name": "
|
| 293 |
-
"quantity":
|
| 294 |
-
"unit": "
|
| 295 |
-
"display_quantity": "
|
| 296 |
},
|
| 297 |
{
|
| 298 |
-
"name": "
|
| 299 |
-
"display_name": "
|
| 300 |
-
"quantity":
|
| 301 |
-
"unit": "
|
| 302 |
-
"display_quantity": "
|
| 303 |
}
|
| 304 |
]
|
| 305 |
},
|
| 306 |
{
|
| 307 |
"meal": "dinner",
|
| 308 |
-
"name": "
|
| 309 |
"servings": 5,
|
| 310 |
"ingredients": [
|
| 311 |
{
|
| 312 |
-
"name": "
|
| 313 |
-
"display_name": "
|
| 314 |
-
"quantity":
|
| 315 |
-
"unit": "
|
| 316 |
-
"display_quantity": "
|
| 317 |
},
|
| 318 |
{
|
| 319 |
-
"name": "
|
| 320 |
-
"display_name": "
|
| 321 |
-
"quantity":
|
| 322 |
-
"unit": "
|
| 323 |
-
"display_quantity": "
|
| 324 |
},
|
| 325 |
{
|
| 326 |
"name": "pasta",
|
| 327 |
"display_name": "Pasta",
|
| 328 |
-
"quantity":
|
| 329 |
-
"unit": "
|
| 330 |
-
"display_quantity": "
|
| 331 |
}
|
| 332 |
]
|
| 333 |
}
|
|
@@ -339,57 +339,57 @@
|
|
| 339 |
"meals": [
|
| 340 |
{
|
| 341 |
"meal": "lunch",
|
| 342 |
-
"name": "Chicken with
|
| 343 |
"servings": 5,
|
| 344 |
"ingredients": [
|
| 345 |
{
|
| 346 |
"name": "chicken",
|
| 347 |
"display_name": "Chicken",
|
| 348 |
-
"quantity":
|
| 349 |
-
"unit": "
|
| 350 |
-
"display_quantity": "
|
| 351 |
},
|
| 352 |
{
|
| 353 |
-
"name": "
|
| 354 |
-
"display_name": "
|
| 355 |
-
"quantity":
|
| 356 |
-
"unit": "
|
| 357 |
-
"display_quantity": "
|
| 358 |
},
|
| 359 |
{
|
| 360 |
-
"name": "
|
| 361 |
-
"display_name": "
|
| 362 |
-
"quantity":
|
| 363 |
-
"unit": "
|
| 364 |
-
"display_quantity": "
|
| 365 |
}
|
| 366 |
]
|
| 367 |
},
|
| 368 |
{
|
| 369 |
"meal": "dinner",
|
| 370 |
-
"name": "
|
| 371 |
"servings": 5,
|
| 372 |
"ingredients": [
|
| 373 |
{
|
| 374 |
-
"name": "
|
| 375 |
-
"display_name": "
|
| 376 |
-
"quantity":
|
| 377 |
-
"unit": "
|
| 378 |
-
"display_quantity": "
|
| 379 |
},
|
| 380 |
{
|
| 381 |
-
"name": "
|
| 382 |
-
"display_name": "
|
| 383 |
-
"quantity":
|
| 384 |
-
"unit": "
|
| 385 |
-
"display_quantity": "
|
| 386 |
},
|
| 387 |
{
|
| 388 |
"name": "pasta",
|
| 389 |
"display_name": "Pasta",
|
| 390 |
-
"quantity":
|
| 391 |
-
"unit": "
|
| 392 |
-
"display_quantity": "
|
| 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": "
|
| 4 |
-
"quantity":
|
| 5 |
-
"unit": "
|
|
|
|
| 6 |
},
|
| 7 |
{
|
| 8 |
-
"name": "
|
| 9 |
-
"quantity":
|
| 10 |
-
"unit": "
|
|
|
|
| 11 |
},
|
| 12 |
{
|
| 13 |
"name": "pasta",
|
| 14 |
-
"quantity":
|
| 15 |
-
"unit": "
|
|
|
|
| 16 |
},
|
| 17 |
{
|
| 18 |
-
"name": "
|
| 19 |
-
"quantity":
|
| 20 |
-
"unit": "
|
|
|
|
| 21 |
},
|
| 22 |
{
|
| 23 |
-
"name": "
|
| 24 |
-
"quantity":
|
| 25 |
-
"unit": "
|
|
|
|
| 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 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 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 |
+
|