Files
Alfredv2/alfred/app/services/dona_vision_v95.py
T
2026-10-31 11:04:45 +01:00

262 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dona V9.5 : YOLOv8 local opt-in + vision ponctuelle Hermes.
Pas d'enregistrement de photo, de vidéos, ni d'activation sans demande.
Detection de personne = classe COCO person. Objet presente = heuristique :
classe non-personne stable au centre (ce n'est pas une detection de geste).
"""
from __future__ import annotations
import importlib.util
import os
from collections import deque
from datetime import datetime
from pathlib import Path
import re
import threading
import time
from flask import Blueprint, jsonify, request
def _env_float(name, default, low, high):
try: return max(low,min(high,float(os.getenv(name,default))))
except (ValueError,TypeError): return default
def _env_int(name, default, low, high):
try: return max(low,min(high,int(os.getenv(name,default))))
except (ValueError,TypeError): return default
class EventTracker:
"""State machine de debouncing. Seuils en nombre d'images, sans images stockees."""
def __init__(self, person_frames=2, object_frames=3, absent_frames=2, cooldown=45):
self.person_frames=person_frames
self.object_frames=object_frames
self.absent_frames=absent_frames
self.cooldown=cooldown
self.person_count=0
self.person_absences=0
self.person_active=False
self.object_candidate=None
self.object_count=0
self.object_absences=0
self.object_active=None
self.last_event={}
def update(self, detections, now=None, watch_person=True, watch_objects=True):
now=time.monotonic() if now is None else now
out=[]
visible_people=[d for d in detections if d['label']=='person']
if visible_people:
self.person_absences=0
self.person_count+=1
if not self.person_active and self.person_count>=self.person_frames:
self.person_active=True
if watch_person and self._can_emit('person',now):
out.append({'type':'person','title':'Une personne est apparue','detail':'Personne détectée devant Dona (identité inconnue).'})
else:
self.person_count=0
self.person_absences+=1
if self.person_absences>=self.absent_frames:self.person_active=False
# Un objet est considere 'presente' s'il est suffisamment central et visible.
centrally=[]
for d in detections:
if d['label']=='person':continue
x1,y1,x2,y2=d['box']
cx=(x1+x2)/2; cy=(y1+y2)/2; area=(x2-x1)*(y2-y1)
if 0.28<=cx<=0.72 and 0.20<=cy<=0.85 and area>=0.018:
centrally.append((d, area))
if centrally:
best=max(centrally,key=lambda x:(x[0]['confidence'],x[1]))[0]
label=best['label']
self.object_absences=0
if label==self.object_candidate:self.object_count+=1
else:self.object_candidate=label;self.object_count=1
if self.object_count>=self.object_frames and self.object_active!=label:
self.object_active=label
if watch_objects and self._can_emit('object:'+label,now):
out.append({'type':'object','title':'Objet présenté devant Dona',
'detail':f'Objet détecté au centre : {TRANSLATIONS.get(label,label)} (à confirmer).'})
else:
self.object_absences+=1
if self.object_absences>=self.absent_frames:
self.object_candidate=None;self.object_count=0;self.object_active=None
return out
def _can_emit(self,key,now):
if now-self.last_event.get(key,float('-inf'))<self.cooldown:return False
self.last_event[key]=now
return True
TRANSLATIONS = {'person':'personne','bottle':'bouteille','cup':'tasse','book':'livre','cell phone':'téléphone',
'laptop':'ordinateur portable','keyboard':'clavier','mouse':'souris','chair':'chaise','remote':'télécommande',
'scissors':'ciseaux','dining table':'table','backpack':'sac à dos','handbag':'sac','clock':'horloge',
'banana':'banane','apple':'pomme','orange':'orange','carrot':'carotte','dog':'chien','cat':'chat',
'tv':'télévision','potted plant':'plante','sports ball':'ballon','teddy bear':'peluche'}
def _detections(result):
"""Convertit les resultats Ultralytics en dictionnaires avec coordonnees [0..1]."""
out=[]
height,width=result.orig_shape[:2]
for item in result.boxes:
index=int(item.cls[0].item())
label=str(result.names.get(index,index) if hasattr(result.names,'get') else result.names[index])
conf=float(item.conf[0].item())
x1,y1,x2,y2=[float(x) for x in item.xyxy[0].tolist()]
out.append({'label':label,'confidence':round(conf,2),'box':[max(0,x1/width),max(0,y1/height),min(1,x2/width),min(1,y2/height)]})
return out
# Éviter les collisions avec les commandes de reconnaissance faciale et les images importées.
VISION_PATTERN=re.compile(r"(?:qu[’']est[- ]ce que tu vois|que vois[- ]tu|d[eé]cris[- ]moi ce que tu vois|regarde (?:cet|ce|cette) objet|que vois[- ]tu devant toi|analyse (?:ce que tu vois|la cam[eé]ra)|(?:regarde|d[eé]cris|analyse|observe|montre)[^.!?]{0,55}(?:cam[eé]ra|devant toi|devant dona))",re.I)
def is_camera_question(prompt):
prompt=str(prompt or '').strip()
if not VISION_PATTERN.search(prompt):return False
if re.search(r'\b(photo|fichier|document|pi[eè]ce jointe|image import[eé]e|qui est|reconnais|identifie)\b',prompt,re.I):return False
return True
def _allowed():
if request.headers.get('X-Alfred-Request')!='1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/')!=request.host_url.rstrip('/'):return False
return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none')
class VisionService:
def __init__(self,socketio):
self.socketio=socketio
self._guard=threading.RLock()
self._stop=threading.Event()
self._thread=None
self.running=False
self.status_message='Surveillance désactivée.'
self.settings={'person':True,'objects':True}
self.latest=[]
self.events=deque(maxlen=30)
self.tracker=EventTracker(cooldown=_env_float('DONA_VISION_COOLDOWN',45,10,300))
self.interval=_env_float('DONA_VISION_INTERVAL',2.5,1.2,15.0)
self.confidence=_env_float('DONA_VISION_CONFIDENCE',0.50,.25,.9)
self.model_path=os.getenv('DONA_VISION_MODEL',str(Path(__file__).resolve().parent.parent/'models/yolov8n.pt'))
self._model=None
self._analysis_lock=threading.Lock()
def status(self):
with self._guard:
return {'ok':True,'running':self.running,'message':self.status_message,
'ultralytics_installed':importlib.util.find_spec('ultralytics') is not None,
'model_ready':Path(self.model_path).is_file(),
'model':Path(self.model_path).name,'interval':self.interval,
'settings':dict(self.settings),'latest':list(self.latest),
'events':list(self.events)}
def start(self,person=True,objects=True):
with self._guard:
if self.running:return self.status()
if self._thread is not None and self._thread.is_alive():
raise RuntimeError('Le processus YOLO précédent est encore en cours d’arrêt.')
if importlib.util.find_spec('ultralytics') is None:
raise RuntimeError('YOLOv8 non installé. Consulte le README : installer ultralytics dans le Python d’Alfred.')
if not Path(self.model_path).is_file():
raise RuntimeError('Modèle yolov8n.pt absent. Télécharger les poids avant d’activer la surveillance (voir README).')
self.settings={'person':bool(person),'objects':bool(objects)}
if not any(self.settings.values()):raise ValueError('Active au moins un type de détection.')
self.tracker=EventTracker(cooldown=_env_float('DONA_VISION_COOLDOWN',45,10,300))
self._stop.clear();self.running=True
self.status_message='Initialisation de YOLOv8…'
self._thread=threading.Thread(target=self._loop,name='dona-yolov8',daemon=True)
self._thread.start()
return self.status()
def stop(self):
self._stop.set()
thread=self._thread
if thread is not None and thread.is_alive() and thread is not threading.current_thread():
thread.join(timeout=self.interval+2)
with self._guard:
self.running=False
self.status_message='Surveillance arrêtée.' if thread is None or not thread.is_alive() else 'Arrêt demandé, fin de l’inférence en cours…'
return self.status()
def _loop(self):
from services import camera
try:
from ultralytics import YOLO
if self._model is None:self._model=YOLO(self.model_path)
while not self._stop.is_set():
try:
frame=camera.read_frame(max_age=1.4)
result=self._model.predict(frame,conf=self.confidence,imgsz=416,device='cpu',verbose=False)[0]
detections=_detections(result)
now=time.monotonic()
with self._guard:
self.latest=[{'label':x['label'],'confidence':x['confidence']} for x in detections[:12]]
self.status_message=f'Surveillance active · {len(detections)} objet(s) détecté(s)'
events=self.tracker.update(detections,now,self.settings['person'],self.settings['objects'])
for event in events:
payload={**event,'time':datetime.now().astimezone().strftime('%H:%M:%S')}
self.events.appendleft(payload)
self.socketio.emit('dona_vision_event_v95',payload)
self.socketio.emit('dona_vision_status_v95', {'running':True,'message':self.status_message})
except RuntimeError as exc:
# Caméra occupée brièvement par un autre service : temporisation.
with self._guard:self.status_message=str(exc)[:180]
self._stop.wait(self.interval)
except Exception as exc:
with self._guard:self.status_message='YOLOv8 indisponible : '+str(exc)[:170]
finally:
with self._guard:self.running=False
self.socketio.emit('dona_vision_status_v95',{'running':False,'message':self.status_message})
def analyze(self,question='Décris ce que tu vois devant la caméra.'):
if not self._analysis_lock.acquire(blocking=False):
raise RuntimeError('Une analyse de caméra est déjà en cours.')
try:
from services import camera, dona_files_v91
import cv2
frame=camera.read_frame(max_age=1.4)
ok,encoded=cv2.imencode('.jpg',frame,[cv2.IMWRITE_JPEG_QUALITY,85])
if not ok:raise RuntimeError('Impossible de préparer la capture.')
# Reutiliser la chaine Hermes image V9.1 (pixels reels, session isolee).
item=dona_files_v91.Attachment('vision-cam','capture-camera.jpg','.jpg',encoded.tobytes(),time.monotonic())
prompt='Image prise sur demande depuis la caméra locale de Dona. '+str(question or '')[:600]
answer=dona_files_v91._hermes_analyze(item,prompt)
return str(answer).strip() or 'Hermes n’a produit aucune description.'
finally:self._analysis_lock.release()
def register(app,socketio):
bp=Blueprint('dona_vision_v95',__name__)
vision=VisionService(socketio)
@bp.get('/api/dona/vision/v95/status')
def vision_status():return jsonify(vision.status())
@bp.post('/api/dona/vision/v95/monitor')
def monitor():
if not _allowed() or not request.is_json:return jsonify(ok=False,error='Accès non autorisé.'),403
data=request.get_json(silent=True) or {}
if not isinstance(data,dict):return jsonify(ok=False,error='JSON invalide.'),400
try:
if data.get('enabled') is True: return jsonify(vision.start(data.get('person',True),data.get('objects',True)))
if data.get('enabled') is False:return jsonify(vision.stop())
raise ValueError('Le paramètre enabled doit être booléen.')
except (RuntimeError,ValueError) as exc:return jsonify(ok=False,error=str(exc)),400
@bp.post('/api/dona/vision/v95/analyze')
def analyze():
if not _allowed() or not request.is_json:return jsonify(ok=False,error='Accès non autorisé.'),403
data=request.get_json(silent=True) or {}
if not isinstance(data,dict):return jsonify(ok=False,error='JSON invalide.'),400
question=str(data.get('question') or 'Décris ce que tu vois devant la caméra.')[:600]
# Requete bloque uniquement le worker HTTP de cet appel, pas la conversation principale.
try:
answer=vision.analyze(question)
return jsonify(ok=True,answer=answer)
except Exception as exc:
app.logger.warning('Analyse camera V9.5 : %s',type(exc).__name__)
return jsonify(ok=False,error=str(exc)[:160] if isinstance(exc,RuntimeError) else 'Analyse indisponible : voir journaux.'),502
app.register_blueprint(bp)
return vision