Files
Mark-LIV/actions/screen_processor.py
T

442 lines
14 KiB
Python
Raw Normal View History

2026-04-19 15:22:49 +03:00
from __future__ import annotations
2026-03-02 04:30:10 +03:00
import asyncio
import base64
import io
import json
import re
import sys
import threading
2026-04-19 15:22:49 +03:00
import time
2026-03-02 04:30:10 +03:00
from pathlib import Path
2026-04-19 15:22:49 +03:00
from typing import Optional
import numpy as np
import sounddevice as sd
try:
import cv2
_CV2 = True
except ImportError:
_CV2 = False
try:
import mss
import mss.tools
_MSS = True
except ImportError:
_MSS = False
2026-03-02 04:30:10 +03:00
try:
import PIL.Image
2026-04-19 15:22:49 +03:00
_PIL = True
2026-03-02 04:30:10 +03:00
except ImportError:
2026-04-19 15:22:49 +03:00
_PIL = False
2026-03-02 04:30:10 +03:00
from google import genai
2026-04-19 15:22:49 +03:00
from google.genai import types as gtypes
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _base_dir() -> Path:
2026-03-02 04:30:10 +03:00
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path(__file__).resolve().parent.parent
2026-04-19 15:22:49 +03:00
_BASE = _base_dir()
_CONFIG_PATH = _BASE / "config" / "api_keys.json"
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _load_config() -> dict:
try:
return json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
def _save_config_key(key: str, value) -> None:
2026-03-02 04:30:10 +03:00
try:
2026-04-19 15:22:49 +03:00
cfg = _load_config()
cfg[key] = value
_CONFIG_PATH.write_text(json.dumps(cfg, indent=4), encoding="utf-8")
2026-03-02 04:30:10 +03:00
except Exception as e:
2026-04-19 15:22:49 +03:00
print(f"[Vision] ⚠️ Could not save config key '{key}': {e}")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _get_api_key() -> str:
key = _load_config().get("gemini_api_key", "")
if not key:
raise RuntimeError("gemini_api_key not found in config.")
return key
def _get_os() -> str:
return _load_config().get("os_system", "windows").lower()
_LIVE_MODEL = "models/gemini-2.5-flash-native-audio-preview-12-2025"
_CHANNELS = 1
_RECEIVE_SAMPLE_RATE = 24_000
_CHUNK_SIZE = 1_024
_IMG_MAX_W = 640
_IMG_MAX_H = 360
_JPEG_Q = 60
_SYSTEM_PROMPT = (
"You are JARVIS, an advanced AI assistant. "
"Analyze the provided image with precision and intelligence. "
"Be concise and direct — maximum two sentences unless the user's question "
"requires more detail. "
"Address the user respectfully. "
"Always call the appropriate tool; never simulate results."
)
def _compress(img_bytes: bytes, source_format: str = "PNG") -> tuple[bytes, str]:
if not _PIL:
return img_bytes, f"image/{source_format.lower()}"
2026-03-02 04:30:10 +03:00
try:
2026-04-19 15:22:49 +03:00
img = PIL.Image.open(io.BytesIO(img_bytes)).convert("RGB")
img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=_JPEG_Q, optimize=False)
return buf.getvalue(), "image/jpeg"
except Exception as e:
print(f"[Vision] ⚠️ Image compress failed: {e}")
return img_bytes, f"image/{source_format.lower()}"
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _capture_screen() -> tuple[bytes, str]:
if not _MSS:
raise RuntimeError("mss is not installed. Run: pip install mss")
with mss.mss() as sct:
monitors = sct.monitors # [0] = all combined, [1..n] = real screens
target = monitors[1] if len(monitors) > 1 else monitors[0]
shot = sct.grab(target)
png = mss.tools.to_png(shot.rgb, shot.size)
return _compress(png, "PNG")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _cv2_backend() -> int:
"""Return the best OpenCV camera backend for the current OS."""
if not _CV2:
return 0
os_name = _get_os()
if os_name == "windows":
return cv2.CAP_DSHOW
if os_name == "mac":
return cv2.CAP_AVFOUNDATION
return cv2.CAP_ANY
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _probe_camera(index: int, backend: int, warmup: int = 5) -> bool:
if not _CV2:
return False
cap = cv2.VideoCapture(index, backend)
if not cap.isOpened():
2026-03-02 04:30:10 +03:00
cap.release()
2026-04-19 15:22:49 +03:00
return False
for _ in range(warmup):
cap.read()
ret, frame = cap.read()
cap.release()
if not ret or frame is None:
return False
return bool(np.mean(frame) > 8)
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _detect_camera_index() -> int:
backend = _cv2_backend()
print("[Vision] 🔍 Auto-detecting camera...")
for idx in range(6):
if _probe_camera(idx, backend):
print(f"[Vision] ✅ Camera found at index {idx}")
_save_config_key("camera_index", idx)
return idx
print(f"[Vision] ⚠️ Camera index {idx}: no usable frame")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
print("[Vision] ⚠️ No camera found — defaulting to index 0")
_save_config_key("camera_index", 0)
return 0
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _get_camera_index() -> int:
cfg = _load_config()
if "camera_index" in cfg:
return int(cfg["camera_index"])
return _detect_camera_index()
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _capture_camera() -> tuple[bytes, str]:
if not _CV2:
raise RuntimeError("OpenCV (cv2) is not installed. Run: pip install opencv-python")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
index = _get_camera_index()
backend = _cv2_backend()
cap = cv2.VideoCapture(index, backend)
2026-03-02 04:30:10 +03:00
if not cap.isOpened():
2026-04-19 15:22:49 +03:00
raise RuntimeError(f"Camera index {index} could not be opened.")
2026-03-02 04:30:10 +03:00
for _ in range(10):
cap.read()
ret, frame = cap.read()
cap.release()
if not ret or frame is None:
2026-04-19 15:22:49 +03:00
raise RuntimeError("Camera returned no frame.")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
if _PIL:
2026-03-02 04:30:10 +03:00
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img = PIL.Image.fromarray(rgb)
2026-04-19 15:22:49 +03:00
img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR)
2026-03-02 04:30:10 +03:00
buf = io.BytesIO()
2026-04-19 15:22:49 +03:00
img.save(buf, format="JPEG", quality=_JPEG_Q)
return buf.getvalue(), "image/jpeg"
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_Q])
return buf.tobytes(), "image/jpeg"
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
class _VisionSession:
2026-03-02 04:30:10 +03:00
def __init__(self):
2026-04-19 15:22:49 +03:00
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._thread: Optional[threading.Thread] = None
self._session = None
self._out_queue: Optional[asyncio.Queue] = None
self._audio_in: Optional[asyncio.Queue] = None
self._ready_evt: threading.Event = threading.Event()
self._player = None
self._lock: threading.Lock = threading.Lock()
def start(self, player=None, timeout: float = 25.0) -> None:
with self._lock:
if self._thread and self._thread.is_alive():
if player is not None:
self._player = player
return
self._player = player
self._thread = threading.Thread(
target=self._run_event_loop,
daemon=True,
name="VisionSessionThread",
)
self._thread.start()
if not self._ready_evt.wait(timeout=timeout):
raise RuntimeError(f"Vision session did not connect within {timeout}s.")
print("[Vision] ✅ Session ready")
def analyze(self, image_bytes: bytes, mime_type: str, user_text: str) -> None:
if not self._loop or not self._out_queue:
print("[Vision] ⚠️ Session not started — dropping request")
2026-03-02 04:30:10 +03:00
return
2026-04-19 15:22:49 +03:00
asyncio.run_coroutine_threadsafe(
self._out_queue.put((image_bytes, mime_type, user_text)),
self._loop,
2026-03-02 04:30:10 +03:00
)
2026-04-19 15:22:49 +03:00
def is_ready(self) -> bool:
return self._session is not None
def _run_event_loop(self) -> None:
2026-03-02 04:30:10 +03:00
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
2026-04-19 15:22:49 +03:00
self._loop.run_until_complete(self._session_loop())
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
async def _session_loop(self) -> None:
2026-03-02 04:30:10 +03:00
self._out_queue = asyncio.Queue(maxsize=30)
self._audio_in = asyncio.Queue()
client = genai.Client(
api_key=_get_api_key(),
2026-04-19 15:22:49 +03:00
http_options={"api_version": "v1beta"},
2026-03-02 04:30:10 +03:00
)
2026-04-19 15:22:49 +03:00
config = gtypes.LiveConnectConfig(
2026-03-02 04:30:10 +03:00
response_modalities=["AUDIO"],
output_audio_transcription={},
2026-04-19 15:22:49 +03:00
system_instruction=_SYSTEM_PROMPT,
speech_config=gtypes.SpeechConfig(
voice_config=gtypes.VoiceConfig(
prebuilt_voice_config=gtypes.PrebuiltVoiceConfig(
2026-03-02 04:30:10 +03:00
voice_name="Charon"
)
)
),
)
2026-04-19 15:22:49 +03:00
backoff = 2.0
2026-03-02 04:30:10 +03:00
while True:
try:
2026-04-19 15:22:49 +03:00
print("[Vision] 🔌 Connecting...")
async with client.aio.live.connect(
model=_LIVE_MODEL, config=config
) as session:
2026-03-02 04:30:10 +03:00
self._session = session
2026-04-19 15:22:49 +03:00
self._ready_evt.set()
backoff = 2.0
print("[Vision] ✅ Connected")
2026-03-02 04:30:10 +03:00
async with asyncio.TaskGroup() as tg:
tg.create_task(self._send_loop())
tg.create_task(self._recv_loop())
tg.create_task(self._play_loop())
2026-04-19 15:22:49 +03:00
except* Exception as eg:
for exc in eg.exceptions:
print(f"[Vision] ⚠️ Session error: {exc}")
finally:
2026-03-02 04:30:10 +03:00
self._session = None
2026-04-19 15:22:49 +03:00
self._ready_evt.clear()
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
print(f"[Vision] 🔄 Reconnecting in {backoff:.0f}s...")
await asyncio.sleep(backoff)
backoff = min(backoff * 1.5, 30.0)
self._ready_evt.set()
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
async def _send_loop(self) -> None:
2026-03-02 04:30:10 +03:00
while True:
2026-04-19 15:22:49 +03:00
image_bytes, mime_type, user_text = await self._out_queue.get()
if not self._session:
print("[Vision] ⚠️ No session — dropping image")
continue
try:
b64 = base64.b64encode(image_bytes).decode("ascii")
await self._session.send_client_content(
turns={
"parts": [
{"inline_data": {"mime_type": mime_type, "data": b64}},
{"text": user_text},
]
},
turn_complete=True,
)
print(f"[Vision] 📤 Sent {len(image_bytes):,} bytes — '{user_text[:60]}'")
except Exception as e:
print(f"[Vision] ⚠️ Send error: {e}")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
async def _recv_loop(self) -> None:
transcript: list[str] = []
2026-03-02 04:30:10 +03:00
try:
async for response in self._session.receive():
if response.data:
await self._audio_in.put(response.data)
sc = response.server_content
if not sc:
continue
if sc.output_transcription and sc.output_transcription.text:
chunk = sc.output_transcription.text.strip()
if chunk:
2026-04-19 15:22:49 +03:00
transcript.append(chunk)
2026-03-02 04:30:10 +03:00
if sc.turn_complete:
2026-04-19 15:22:49 +03:00
if transcript and self._player:
full = re.sub(r"\s+", " ", " ".join(transcript)).strip()
2026-03-02 04:30:10 +03:00
if full:
self._player.write_log(f"Jarvis: {full}")
2026-04-19 15:22:49 +03:00
print(f"[Vision] 💬 {full}")
transcript = []
2026-03-02 04:30:10 +03:00
except Exception as e:
2026-04-19 15:22:49 +03:00
print(f"[Vision] ⚠️ Recv error: {e}")
raise
async def _play_loop(self) -> None:
stream = sd.RawOutputStream(
samplerate=_RECEIVE_SAMPLE_RATE,
channels=_CHANNELS,
dtype="int16",
blocksize=_CHUNK_SIZE,
2026-03-02 04:30:10 +03:00
)
2026-04-19 15:22:49 +03:00
stream.start()
2026-03-02 04:30:10 +03:00
try:
while True:
chunk = await self._audio_in.get()
await asyncio.to_thread(stream.write, chunk)
2026-04-19 15:22:49 +03:00
except Exception as e:
print(f"[Vision] ❌ Play error: {e}")
raise
2026-03-02 04:30:10 +03:00
finally:
2026-04-19 15:22:49 +03:00
stream.stop()
2026-03-02 04:30:10 +03:00
stream.close()
2026-04-19 15:22:49 +03:00
_session = _VisionSession()
_session_lock = threading.Lock()
_session_up = False
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
def _ensure_session(player=None) -> None:
global _session_up
with _session_lock:
if not _session_up:
_session.start(player=player)
_session_up = True
2026-03-02 04:30:10 +03:00
elif player is not None:
2026-04-19 15:22:49 +03:00
_session._player = player
2026-03-02 04:30:10 +03:00
def screen_process(
parameters: dict,
2026-04-19 15:22:49 +03:00
response=None,
2026-03-02 04:30:10 +03:00
player=None,
session_memory=None,
) -> bool:
2026-04-19 15:22:49 +03:00
params = parameters or {}
user_text = (params.get("text") or params.get("user_text") or "").strip()
angle = params.get("angle", "screen").lower().strip()
2026-03-02 04:30:10 +03:00
if not user_text:
2026-04-19 15:22:49 +03:00
print("[Vision] ⚠️ No question provided — aborting")
2026-03-02 04:30:10 +03:00
return False
2026-04-19 15:22:49 +03:00
print(f"[Vision] ▶ angle={angle!r} question='{user_text[:80]}'")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
try:
_ensure_session(player=player)
except Exception as e:
print(f"[Vision] ❌ Could not start session: {e}")
return False
2026-03-02 04:30:10 +03:00
try:
if angle == "camera":
2026-04-19 15:22:49 +03:00
image_bytes, mime_type = _capture_camera()
print(f"[Vision] 📷 Camera: {len(image_bytes):,} bytes")
2026-03-02 04:30:10 +03:00
else:
2026-04-19 15:22:49 +03:00
image_bytes, mime_type = _capture_screen()
print(f"[Vision] 🖥️ Screen: {len(image_bytes):,} bytes")
2026-03-02 04:30:10 +03:00
except Exception as e:
2026-04-19 15:22:49 +03:00
print(f"[Vision] ❌ Capture error: {e}")
2026-03-02 04:30:10 +03:00
return False
2026-04-19 15:22:49 +03:00
_session.analyze(image_bytes, mime_type, user_text)
2026-03-02 04:30:10 +03:00
return True
2026-04-19 15:22:49 +03:00
def warmup_session(player=None) -> None:
2026-03-02 04:30:10 +03:00
try:
2026-04-19 15:22:49 +03:00
_ensure_session(player=player)
2026-03-02 04:30:10 +03:00
except Exception as e:
2026-04-19 15:22:49 +03:00
print(f"[Vision] ⚠️ Warmup failed: {e}")
2026-03-02 04:30:10 +03:00
if __name__ == "__main__":
2026-04-19 15:22:49 +03:00
print("[TEST] screen_processor.py")
print("=" * 52)
mode = input("angle — screen / camera (default: screen): ").strip().lower() or "screen"
q = input("Question (Enter = default): ").strip() or "What do you see? Be brief."
2026-03-02 04:30:10 +03:00
t0 = time.perf_counter()
warmup_session()
2026-04-19 15:22:49 +03:00
print(f"Session ready in {time.perf_counter()-t0:.2f}s\n")
2026-03-02 04:30:10 +03:00
2026-04-19 15:22:49 +03:00
t1 = time.perf_counter()
ok = screen_process({"angle": mode, "text": q})
print(f"Queued in {time.perf_counter()-t1:.3f}s — waiting for audio...")
time.sleep(10)
print("Done." if ok else "Failed.")