262 lines
13 KiB
Python
262 lines
13 KiB
Python
"""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
|