Update 2026-04-21 16:27:07

This commit is contained in:
kassam 2026-04-21 16:27:11 +04:00
parent e0f6acd5c7
commit af1d0c1b8a
9 changed files with 79 additions and 63 deletions

View File

@ -28,22 +28,27 @@ import json
import logging import logging
import os import os
import subprocess import subprocess
import sys
import threading import threading
import time import time
import wave import wave
import numpy as np import numpy as np
# ─── PATH CONFIG ───────────────────────────────────────── # ─── PATH + CONFIG ───────────────────────────────────────
from dotenv import load_dotenv # Use the canonical loaders from Core/ so path + config logic lives in one place.
load_dotenv() _PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_DIR not in sys.path:
BASE_PATH = os.environ.get("PROJECT_BASE", "/home/unitree") sys.path.insert(0, _PROJECT_DIR)
PROJECT_NAME = "Marcus" from Core.env_loader import PROJECT_ROOT
PROJECT_ROOT = os.path.join(BASE_PATH, PROJECT_NAME) from Core.config_loader import load_config
LOG_DIR = os.path.join(PROJECT_ROOT, "logs") LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
# Note: logging.basicConfig() only takes effect on the first call per process.
# If the voice module already configured logging (common path via run_marcus.py),
# this call is a no-op. When audio_api is used standalone, it wires logs to
# logs/voice.log + stderr.
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
@ -55,12 +60,6 @@ logging.basicConfig(
log = logging.getLogger("audio_api") log = logging.getLogger("audio_api")
def load_config(name: str) -> dict:
path = os.path.join(PROJECT_ROOT, "Config", f"config_{name}.json")
with open(path, "r") as f:
return json.load(f)
# ─── AUDIO API CLASS ───────────────────────────────────── # ─── AUDIO API CLASS ─────────────────────────────────────
class AudioAPI: class AudioAPI:
@ -135,11 +134,12 @@ class AudioAPI:
Speak `text` in English through the G1 built-in TTS (TtsMaker). Speak `text` in English through the G1 built-in TTS (TtsMaker).
Mutes (flushes) the mic during playback so the voice loop doesn't Mutes (flushes) the mic during playback so the voice loop doesn't
hear the robot's own voice and transcribe itself. The `lang` hear the robot's own voice and transcribe itself. `lang` is kept
argument is accepted for API compatibility but only "en" plays in the signature for API compatibility but only `"en"` is accepted
non-ASCII text (Arabic) is rejected by BuiltinTTS. non-ASCII text (Arabic) is rejected by BuiltinTTS because the
G1 firmware silently maps it to Chinese, which nobody wants.
""" """
if lang and lang not in ("en", "auto"): if lang and lang != "en":
log.warning("builtin_tts only supports English; got lang=%r — skipping", lang) log.warning("builtin_tts only supports English; got lang=%r — skipping", lang)
return return
if self._tts_engine is None: if self._tts_engine is None:

View File

@ -31,7 +31,7 @@ Files saved
summary.txt auto-generated LLaVA summary summary.txt auto-generated LLaVA summary
frames/ camera captures at interesting points frames/ camera captures at interesting points
Import in marcus_llava.py Import in marcus_brain.py
------------------------- -------------------------
from marcus_autonomous import AutonomousMode from marcus_autonomous import AutonomousMode
auto = AutonomousMode( auto = AutonomousMode(
@ -303,17 +303,20 @@ class AutonomousMode:
def _move_forward(self) -> bool: def _move_forward(self) -> bool:
""" """
Move forward for FORWARD_DURATION seconds. Move forward for FORWARD_DURATION seconds.
Returns True if blocked (no actual movement detected).
Returns True if blocked. Obstacle detection is not wired in here
LiDAR runs at the brain layer (API.lidar_api.obstacle_ahead) and
isn't passed into AutonomousMode today. Extend __init__ with an
optional `obstacle_fn=` hook if you want this loop to stop on
obstacles detected outside YOLO's view.
""" """
# TODO: integrate LiDAR when available for true obstacle detection
# For now: move and assume not blocked
t0 = time.time() t0 = time.time()
while time.time() - t0 < FORWARD_DURATION and self._enabled: while time.time() - t0 < FORWARD_DURATION and self._enabled:
self._send_vel(vx=0.25) self._send_vel(vx=0.25)
time.sleep(0.05) time.sleep(0.05)
self._send_vel(0, 0, 0) self._send_vel(0, 0, 0)
time.sleep(0.1) time.sleep(0.1)
return False # Not blocked — no LiDAR yet return False
def _turn(self, direction: str, duration: float): def _turn(self, direction: str, duration: float):
"""Turn left or right for given duration.""" """Turn left or right for given duration."""

View File

@ -501,14 +501,18 @@ def run_terminal():
continue continue
if cmd.lower() in ("q", "quit", "exit"): if cmd.lower() in ("q", "quit", "exit"):
break break
if cmd.lower() == "mute/": if cmd.lower() in ("mute/", "unmute/"):
import subprocess # Route through the audio API so the action respects whichever
subprocess.run(["pactl", "set-source-mute", "0", "1"], capture_output=True) # mic backend is active (BuiltinMic flushes the UDP buffer;
print(" Mic muted") # the legacy pactl path mutes PulseAudio source 3).
if _audio_api is None:
print(" Voice is not initialized")
continue continue
if cmd.lower() == "unmute/": if cmd.lower() == "mute/":
import subprocess _audio_api._mute_mic()
subprocess.run(["pactl", "set-source-mute", "0", "0"], capture_output=True) print(" Mic muted")
else:
_audio_api._unmute_mic()
print(" Mic unmuted") print(" Mic unmuted")
continue continue
result = process_command(cmd) result = process_command(cmd)

View File

@ -19,7 +19,7 @@ Folder structure
alerts.json [{time, type, detail}] alerts.json [{time, type, detail}]
summary.txt auto-generated session summary summary.txt auto-generated session summary
Import in marcus_llava.py Import in marcus_brain.py
------------------------- -------------------------
from marcus_memory import Memory from marcus_memory import Memory
mem = Memory() call once at startup mem = Memory() call once at startup

View File

@ -14,6 +14,18 @@ import io
import numpy as np import numpy as np
import websockets import websockets
# Load network defaults from Config/config_Network.json. Falls back to sane
# hardcoded values if the config isn't reachable (the GUI can run standalone
# from a workstation that isn't on the same filesystem as the project).
_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_DIR not in sys.path:
sys.path.insert(0, _PROJECT_DIR)
try:
from Core.config_loader import load_config
_net = load_config("Network")
JETSON_IP = _net.get("jetson_ip", "192.168.123.164")
JETSON_PORT = _net.get("websocket_port", 8765)
except Exception:
JETSON_IP = "192.168.123.164" JETSON_IP = "192.168.123.164"
JETSON_PORT = 8765 JETSON_PORT = 8765
@ -1000,7 +1012,10 @@ class MarcusGUI:
p2 = ImageTk.PhotoImage(img_resized) p2 = ImageTk.PhotoImage(img_resized)
self.cam_preview.configure(image=p2, text="") self.cam_preview.configure(image=p2, text="")
self.cam_preview.image = p2 self.cam_preview.image = p2
except Exception: pass except Exception as e:
# Malformed base64 / torn frame / Tk disposed during shutdown —
# log it so UI freezes become debuggable rather than silent.
print(f" [Client] _update_frame dropped: {type(e).__name__}: {e}")
def _update_cam_config(self, d): def _update_cam_config(self, d):
p, w, h, f = d.get("profile","?"), d.get("width","?"), d.get("height","?"), d.get("fps","?") p, w, h, f = d.get("profile","?"), d.get("width","?"), d.get("height","?"), d.get("fps","?")

View File

@ -9,7 +9,7 @@ Two sources (priority order):
1. ROS2 /dog_odom joint encoder data ±2cm accuracy 1. ROS2 /dog_odom joint encoder data ±2cm accuracy
2. Dead reckoning velocity × time integration ±10cm accuracy 2. Dead reckoning velocity × time integration ±10cm accuracy
Import in marcus_llava.py Import in marcus_brain.py
-------------------------- --------------------------
from marcus_odometry import Odometry from marcus_odometry import Odometry
odom = Odometry() odom = Odometry()
@ -78,7 +78,7 @@ class Odometry:
def __init__(self): def __init__(self):
self._lock = threading.Lock() self._lock = threading.Lock()
self._alive = [False] self._alive = [False]
self._sock = None # ZMQ PUB socket (shared from marcus_llava) self._sock = None # ZMQ PUB socket (shared from marcus_brain)
# Position state # Position state
self._x = 0.0 # meters from start self._x = 0.0 # meters from start
@ -105,7 +105,7 @@ class Odometry:
Start odometry tracking in background thread. Start odometry tracking in background thread.
Args: Args:
zmq_sock : existing ZMQ PUB socket from marcus_llava.py. zmq_sock : existing ZMQ PUB socket from marcus_brain.py.
If None, creates its own socket on ZMQ_PORT. If None, creates its own socket on ZMQ_PORT.
Passing the shared socket avoids port conflicts. Passing the shared socket avoids port conflicts.
@ -169,7 +169,7 @@ class Odometry:
except zmq.ZMQError as e: except zmq.ZMQError as e:
if "Address already in use" in str(e): if "Address already in use" in str(e):
print(f" [Odom] ⚠️ ZMQ port {ZMQ_PORT} already in use.") print(f" [Odom] ⚠️ ZMQ port {ZMQ_PORT} already in use.")
print(" [Odom] Pass zmq_sock=sock from marcus_llava.py instead.") print(" [Odom] Pass zmq_sock=sock from marcus_brain.py instead.")
else: else:
print(f" [Odom] ⚠️ ZMQ error: {e}") print(f" [Odom] ⚠️ ZMQ error: {e}")
self._sock = None self._sock = None

View File

@ -15,7 +15,7 @@ How it works
5. Optional: YOLO pre-filter speeds up search (find person class first, 5. Optional: YOLO pre-filter speeds up search (find person class first,
then LLaVA verifies it's the right person) then LLaVA verifies it's the right person)
Usage in marcus_llava.py Usage in marcus_brain.py
------------------------ ------------------------
from marcus_imgsearch import ImageSearch from marcus_imgsearch import ImageSearch
searcher = ImageSearch(get_frame_fn=get_frame, send_vel_fn=send_vel, searcher = ImageSearch(get_frame_fn=get_frame, send_vel_fn=send_vel,
@ -420,11 +420,11 @@ class ImageSearch:
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
# WIRE INTO marcus_llava.py — add to main loop # WIRE INTO marcus_brain.py — add to main loop
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
""" """
Add to marcus_llava.py imports: Add to marcus_brain.py imports:
from marcus_imgsearch import ImageSearch from marcus_imgsearch import ImageSearch
Add after Memory init: Add after Memory init:
@ -504,7 +504,7 @@ if __name__ == "__main__":
elif args.image or args.hint: elif args.image or args.hint:
# Real search — needs robot hardware # Real search — needs robot hardware
print("Real search requires robot hardware.") print("Real search requires robot hardware.")
print("Import ImageSearch into marcus_llava.py instead.") print("Import ImageSearch into marcus_brain.py instead.")
print(f" image: {args.image}") print(f" image: {args.image}")
print(f" hint: {args.hint}") print(f" hint: {args.hint}")

View File

@ -3,7 +3,7 @@ marcus_yolo.py — Marcus Vision Module
======================================= =======================================
Project : Marcus | YS Lootah Technology Project : Marcus | YS Lootah Technology
Purpose : YOLO-based person + object detection Purpose : YOLO-based person + object detection
Import this module in marcus_llava.py runs as background thread Import this module in marcus_brain.py runs as background thread
Usage (imported): Usage (imported):
from marcus_yolo import start_yolo, yolo_sees, yolo_count, yolo_closest, yolo_summary from marcus_yolo import start_yolo, yolo_sees, yolo_count, yolo_closest, yolo_summary
@ -365,20 +365,20 @@ def _camera_loop(raw_frame_ref, frame_lock, cam_alive):
time.sleep(2.0) time.sleep(2.0)
# ── Start function — called by marcus_llava.py ──────────────────────────────── # ── Start function — called by marcus_brain.py ────────────────────────────────
def start_yolo(raw_frame_ref=None, frame_lock=None): def start_yolo(raw_frame_ref=None, frame_lock=None):
""" """
Start YOLO inference in background thread. Start YOLO inference in background thread.
Called automatically by marcus_llava.py during startup. Called automatically by marcus_brain.py during startup.
Shares the camera frame reference from marcus_llava's camera thread. Shares the camera frame reference from marcus_brain's camera thread.
Args: Args:
raw_frame_ref : list[np.ndarray|None] shared raw BGR frame raw_frame_ref : list[np.ndarray|None] shared raw BGR frame
frame_lock : threading.Lock protecting raw_frame_ref frame_lock : threading.Lock protecting raw_frame_ref
Example (in marcus_llava.py): Example (in marcus_brain.py):
from marcus_yolo import start_yolo, yolo_sees, yolo_summary from marcus_yolo import start_yolo, yolo_sees, yolo_summary
start_yolo(raw_frame_ref=_raw_frame, frame_lock=_raw_lock) start_yolo(raw_frame_ref=_raw_frame, frame_lock=_raw_lock)
""" """

View File

@ -22,23 +22,23 @@ Usage:
import logging import logging
import os import os
import sys
import threading import threading
import time import time
import numpy as np import numpy as np
# ─── PATH CONFIG ───────────────────────────────────────── # ─── PATH + CONFIG ───────────────────────────────────────
from dotenv import load_dotenv # Single source of truth lives in Core/; everyone else imports from there.
load_dotenv() _PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_DIR not in sys.path:
BASE_PATH = os.environ.get("PROJECT_BASE", "/home/unitree") sys.path.insert(0, _PROJECT_DIR)
PROJECT_NAME = "Marcus" from Core.env_loader import PROJECT_ROOT
PROJECT_ROOT = os.path.join(BASE_PATH, PROJECT_NAME) from Core.config_loader import load_config
import json
LOG_DIR = os.path.join(PROJECT_ROOT, "logs") LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
# Idempotent — only the first call per process installs handlers.
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
@ -50,12 +50,6 @@ logging.basicConfig(
log = logging.getLogger("marcus_voice") log = logging.getLogger("marcus_voice")
def load_config(name: str) -> dict:
path = os.path.join(PROJECT_ROOT, "Config", f"config_{name}.json")
with open(path, "r") as f:
return json.load(f)
# ─── STATE ENUM ────────────────────────────────────────── # ─── STATE ENUM ──────────────────────────────────────────
class State: class State: