emotion-fusion-api / face_module /camera_runtime.py
cagedsheep's picture
Initial deploy: emotion fusion API with Docker
41e6846 verified
Raw
History Blame Contribute Delete
26.4 kB
from __future__ import annotations
import logging
import sys
import threading
import time
from typing import Any, Callable
import numpy as np
from config import (
FACE_ANALYSIS_INTERVAL_SECONDS,
FACE_BOX_TRACK_INTERVAL_SECONDS,
FACE_CAMERA_BACKEND,
FACE_CAMERA_INDEX,
FACE_CAMERA_POLL_INTERVAL_SECONDS,
FACE_CAMERA_PREVIEW_FPS,
FACE_CAMERA_PREVIEW_JPEG_QUALITY,
FACE_CAMERA_PREVIEW_WIDTH,
FACE_CAMERA_SCAN_INDICES,
FACE_DETECT_INTERVAL,
)
LOGGER = logging.getLogger(__name__)
def probe_camera_access(
*,
camera_index: int = FACE_CAMERA_INDEX,
camera_backend: str | int | None = FACE_CAMERA_BACKEND,
candidate_camera_indices: tuple[int, ...] = (),
) -> dict[str, object]:
"""Try to open and read one frame without loading face analysis models."""
try:
import cv2 as cv2_module
except Exception as exc:
return {
"ok": False,
"error": f"OpenCV (cv2) 未安装,无法启动本地摄像头:{exc}",
"camera_index": None,
"camera_backend": None,
"frame_shape": None,
"attempts": [],
}
runtime = FaceCameraRuntime(
analyzer_factory=lambda: object(),
camera_index=camera_index,
camera_backend=camera_backend,
candidate_camera_indices=candidate_camera_indices,
cv2_module=cv2_module,
)
capture, active_index, active_backend, attempts, first_frame = runtime._open_capture(cv2_module)
frame_shape = None
if capture is not None:
try:
frame_shape = getattr(first_frame, "shape", None)
if frame_shape is None:
width = int(capture.get(cv2_module.CAP_PROP_FRAME_WIDTH))
height = int(capture.get(cv2_module.CAP_PROP_FRAME_HEIGHT))
frame_shape = (height, width, 3) if width and height else None
except Exception:
frame_shape = None
runtime._release_capture_object(capture)
return {
"ok": capture is not None,
"error": None if capture is not None else "无法打开并读取摄像头画面。",
"camera_index": active_index,
"camera_backend": active_backend,
"frame_shape": frame_shape,
"attempts": attempts,
}
class FaceCameraRuntime:
"""Background camera capture and face analysis runtime.
The analyzer is created once in start() so the factory (which hits
Streamlit's cache) is only called on startup, not every Nth frame.
"""
def __init__(
self,
analyzer_factory: Callable[[], Any],
*,
detect_interval: int = FACE_DETECT_INTERVAL,
camera_index: int = FACE_CAMERA_INDEX,
camera_backend: str | int | None = FACE_CAMERA_BACKEND,
candidate_camera_indices: tuple[int, ...] = FACE_CAMERA_SCAN_INDICES,
poll_interval_seconds: float = FACE_CAMERA_POLL_INTERVAL_SECONDS,
analysis_interval_seconds: float = FACE_ANALYSIS_INTERVAL_SECONDS,
box_track_interval_seconds: float = FACE_BOX_TRACK_INTERVAL_SECONDS,
preview_fps: float = FACE_CAMERA_PREVIEW_FPS,
preview_width: int = FACE_CAMERA_PREVIEW_WIDTH,
preview_jpeg_quality: int = FACE_CAMERA_PREVIEW_JPEG_QUALITY,
camera_factory: Callable[[int], Any] | None = None,
cv2_module: Any | None = None,
) -> None:
self._analyzer_factory = analyzer_factory
self._detect_interval = max(1, int(detect_interval))
self._camera_index = int(camera_index)
self._camera_backend = camera_backend
self._candidate_camera_indices = tuple(int(index) for index in candidate_camera_indices)
self._poll_interval_seconds = max(0.01, float(poll_interval_seconds))
self._analysis_interval_seconds = max(0.0, float(analysis_interval_seconds))
self._box_track_interval_seconds = max(0.1, float(box_track_interval_seconds))
self._preview_interval_seconds = 1.0 / max(1.0, float(preview_fps))
self._preview_width = max(0, int(preview_width))
self._preview_jpeg_quality = min(95, max(30, int(preview_jpeg_quality)))
self._camera_factory = camera_factory
self._cv2 = cv2_module
self._analyzer: Any = None
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._capture = None
self._lock = threading.Lock()
self._analysis_lock = threading.Lock()
self._analysis_thread: threading.Thread | None = None
self._analysis_in_progress = False
self._run_id = 0
self._running = False
self._latest_frame: np.ndarray | None = None
self._latest_result: dict[str, object] | None = None
self._latest_result_at: float | None = None
self._latest_face_box: tuple[int, int, int, int] | None = None
self._latest_face_box_at: float | None = None
self._latest_error: str | None = None
self._latest_frame_at: float | None = None
self._latest_preview_jpeg: bytes | None = None
self._latest_preview_at: float | None = None
self._frame_count = 0
self._last_analysis_scheduled_at = 0.0
self._last_preview_encoded_at = 0.0
self._last_box_tracked_at = 0.0
self._preview_encode_status = "idle"
self._haar_cascade = None
self._open_attempts: list[str] = []
self._active_camera_index: int | None = None
self._active_camera_backend: str | None = None
def start(self) -> bool:
if self._running:
return True
cv2_module = self._cv2
if cv2_module is None:
try:
import cv2 as _cv2
cv2_module = _cv2
except Exception as exc:
self._set_error(f"OpenCV (cv2) 未安装,无法启动本地摄像头:{exc}")
return False
self._cv2 = cv2_module
capture, camera_index, camera_backend, attempts, first_frame = self._open_capture(cv2_module)
self._open_attempts = attempts
if capture is None:
details = ";".join(attempts) if attempts else "没有可用的摄像头打开尝试"
self._set_error(
"无法打开摄像头。"
f"已尝试:{details}。"
"请检查系统摄像头权限、是否被其他程序占用,或切换摄像头索引/后端。"
)
return False
try:
analyzer = self._analyzer_factory()
except Exception as exc:
LOGGER.exception("Face analyzer initialization failed.")
self._release_capture_object(capture)
self._set_error(f"人脸分析模型初始化失败,摄像头已释放:{exc}")
return False
self._analyzer = analyzer
self._capture = capture
self._active_camera_index = camera_index
self._active_camera_backend = camera_backend
self._stop_event.clear()
with self._lock:
self._running = True
self._run_id += 1
run_id = self._run_id
self._latest_frame = None
self._latest_result = None
self._latest_result_at = None
self._latest_face_box = None
self._latest_face_box_at = None
self._latest_error = None
self._latest_frame_at = None
self._latest_preview_jpeg = None
self._latest_preview_at = None
self._frame_count = 0
self._last_analysis_scheduled_at = 0.0
self._last_preview_encoded_at = 0.0
self._last_box_tracked_at = 0.0
self._preview_encode_status = "idle"
if first_frame is not None:
try:
first_frame_rgb = cv2_module.cvtColor(first_frame, cv2_module.COLOR_BGR2RGB)
except Exception:
first_frame_rgb = None
if first_frame_rgb is not None:
with self._lock:
self._latest_frame = first_frame_rgb
self._latest_frame_at = time.time()
self._frame_count = 1
self._update_preview_jpeg(first_frame_rgb, cv2_module, force=True)
self._thread = threading.Thread(target=self._loop, args=(run_id,), name="face-camera-runtime", daemon=True)
self._thread.start()
return True
def stop(self) -> None:
self._stop_event.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=1.5)
self._thread = None
with self._lock:
self._running = False
self._run_id += 1
self._release_capture()
def is_running(self) -> bool:
return self._running
def snapshot(self) -> dict[str, object]:
with self._lock:
frame = self._latest_frame
result = self._latest_result
error = self._latest_error
frame_count = self._frame_count
latest_frame_at = self._latest_frame_at
preview_jpeg = self._latest_preview_jpeg
preview_at = self._latest_preview_at
result_at = self._latest_result_at
face_box = self._latest_face_box
face_box_at = self._latest_face_box_at
analysis_in_progress = self._analysis_in_progress
preview_encode_status = self._preview_encode_status
return {
"frame": frame,
"result": result,
"error": error,
"running": self._running,
"preview_jpeg": preview_jpeg,
"preview_at": preview_at,
"preview_encode_status": preview_encode_status,
"result_at": result_at,
"face_box": face_box,
"face_box_at": face_box_at,
"camera_index": self._active_camera_index,
"camera_backend": self._active_camera_backend,
"frame_count": frame_count,
"latest_frame_at": latest_frame_at,
"analysis_in_progress": analysis_in_progress,
"open_attempts": list(self._open_attempts),
}
def _open_capture(self, cv2_module: Any) -> tuple[Any | None, int | None, str | None, list[str], Any | None]:
if self._camera_factory:
capture = self._camera_factory(self._camera_index)
ok, reason, first_frame = self._validate_capture(capture)
attempts = [f"index={self._camera_index} backend=custom: {reason}"]
if ok:
return capture, self._camera_index, "custom", attempts, first_frame
self._release_capture_object(capture)
return None, None, None, attempts, None
attempts: list[str] = []
for camera_index in self._camera_indices():
for backend_name, backend_id in self._backend_candidates(cv2_module):
try:
if backend_id is None:
capture = cv2_module.VideoCapture(camera_index)
else:
capture = cv2_module.VideoCapture(camera_index, backend_id)
except Exception as exc:
attempts.append(f"index={camera_index} backend={backend_name}: 异常 {exc}")
continue
ok, reason, first_frame = self._validate_capture(capture)
attempts.append(f"index={camera_index} backend={backend_name}: {reason}")
if ok:
return capture, camera_index, backend_name, attempts, first_frame
self._release_capture_object(capture)
return None, None, None, attempts, None
def _camera_indices(self) -> tuple[int, ...]:
ordered = [self._camera_index, *self._candidate_camera_indices]
seen: set[int] = set()
indices: list[int] = []
for index in ordered:
if index not in seen:
seen.add(index)
indices.append(index)
return tuple(indices)
def _backend_candidates(self, cv2_module: Any) -> tuple[tuple[str, int | None], ...]:
requested = self._camera_backend
if isinstance(requested, int):
return ((str(requested), requested),)
requested_name = str(requested or "auto").strip().lower()
if requested_name != "auto":
backend = self._backend_from_name(cv2_module, requested_name)
return (backend,) if backend else (("Default", None),)
if sys.platform.startswith("win"):
names = ("dshow", "any", "msmf")
elif sys.platform == "darwin":
names = ("avfoundation", "any")
else:
names = ("v4l2", "any")
candidates = [
backend
for name in names
if (backend := self._backend_from_name(cv2_module, name)) is not None
]
return tuple(dict.fromkeys(candidates)) or (("Default", None),)
@staticmethod
def _backend_from_name(cv2_module: Any, name: str) -> tuple[str, int | None] | None:
aliases = {
"any": ("Default", None),
"default": ("Default", None),
"opencv": ("Default", None),
"dshow": ("DirectShow", "CAP_DSHOW"),
"directshow": ("DirectShow", "CAP_DSHOW"),
"msmf": ("Media Foundation", "CAP_MSMF"),
"mediafoundation": ("Media Foundation", "CAP_MSMF"),
"v4l2": ("V4L2", "CAP_V4L2"),
"avfoundation": ("AVFoundation", "CAP_AVFOUNDATION"),
}
label_and_attr = aliases.get(name)
if label_and_attr is None:
return None
label, attr = label_and_attr
if attr is None:
return label, None
backend_id = getattr(cv2_module, attr, None)
if backend_id is None:
return None
return label, int(backend_id)
@staticmethod
def _validate_capture(capture: Any | None) -> tuple[bool, str, Any | None]:
if capture is None:
return False, "创建失败", None
try:
if not capture.isOpened():
return False, "未打开", None
except Exception as exc:
return False, f"状态检查异常 {exc}", None
for _ in range(3):
try:
ret, frame = capture.read()
except Exception as exc:
return False, f"读取异常 {exc}", None
if ret and frame is not None:
return True, "已打开并读取到画面", frame
time.sleep(0.05)
return False, "已打开但无法读取画面", None
def _loop(self, run_id: int) -> None:
cv2_module = self._cv2
capture = self._capture
frame_count = 0
try:
while not self._stop_event.is_set() and self._is_current_run(run_id):
if capture is None or cv2_module is None:
self._set_error("摄像头运行时未正确初始化。")
break
ret, frame = capture.read()
if not ret:
self._set_error("无法读取摄像头画面。")
break
frame_rgb = cv2_module.cvtColor(frame, cv2_module.COLOR_BGR2RGB)
self._track_face_box(frame_rgb, cv2_module)
with self._lock:
self._latest_frame = frame_rgb
self._latest_frame_at = time.time()
self._frame_count += 1
self._update_preview_jpeg(frame_rgb, cv2_module)
now = time.monotonic()
can_analyze = now - self._last_analysis_scheduled_at >= self._analysis_interval_seconds
if can_analyze and frame_count % self._detect_interval == 0:
self._last_analysis_scheduled_at = now
self._schedule_analysis(frame_rgb.copy(), run_id)
frame_count += 1
time.sleep(self._poll_interval_seconds)
finally:
if self._is_current_run(run_id):
with self._lock:
self._running = False
self._release_capture()
def _schedule_analysis(self, frame_rgb: np.ndarray, run_id: int) -> None:
with self._analysis_lock:
if self._analysis_in_progress or not self._is_current_run(run_id):
return
self._analysis_in_progress = True
self._analysis_thread = threading.Thread(
target=self._analyze_frame,
args=(frame_rgb, run_id),
name="face-camera-analysis",
daemon=True,
)
self._analysis_thread.start()
def _analyze_frame(self, frame_rgb: np.ndarray, run_id: int) -> None:
try:
detected_result = self._analyzer.analyze_image(frame_rgb)
except Exception as exc:
LOGGER.exception("Face frame analysis failed.")
if self._is_current_run(run_id):
self._set_error(f"人脸分析失败:{exc}")
else:
if self._is_current_run(run_id):
with self._lock:
self._latest_error = None
if isinstance(detected_result, dict):
self._latest_result = detected_result
self._latest_result_at = time.time()
box = _normalize_box(detected_result.get("face_box"))
if box is not None:
self._latest_face_box = box
self._latest_face_box_at = time.time()
finally:
with self._analysis_lock:
self._analysis_in_progress = False
def _track_face_box(self, frame_rgb: np.ndarray, cv2_module: Any) -> None:
now = time.monotonic()
if now - self._last_box_tracked_at < self._box_track_interval_seconds:
return
self._last_box_tracked_at = now
box = _detect_largest_face_box(frame_rgb, cv2_module, self)
with self._lock:
self._latest_face_box = box
self._latest_face_box_at = time.time()
def _update_preview_jpeg(self, frame_rgb: np.ndarray, cv2_module: Any, *, force: bool = False) -> None:
now = time.monotonic()
if not force and now - self._last_preview_encoded_at < self._preview_interval_seconds:
return
self._last_preview_encoded_at = now
if not hasattr(cv2_module, "imencode"):
return
with self._lock:
face_box = self._latest_face_box
result = dict(self._latest_result) if isinstance(self._latest_result, dict) else None
result_at = self._latest_result_at
analysis_in_progress = self._analysis_in_progress
preview_frame = frame_rgb
try:
if (
self._preview_width
and getattr(frame_rgb, "ndim", 0) == 3
and frame_rgb.shape[1] > self._preview_width
and hasattr(cv2_module, "resize")
):
target_height = max(1, int(frame_rgb.shape[0] * self._preview_width / frame_rgb.shape[1]))
interpolation = getattr(cv2_module, "INTER_AREA", 3)
preview_frame = cv2_module.resize(frame_rgb, (self._preview_width, target_height), interpolation=interpolation)
preview_frame = _draw_preview_overlay(
preview_frame,
cv2_module,
face_box=face_box,
source_shape=frame_rgb.shape,
result=result,
result_at=result_at,
analysis_in_progress=analysis_in_progress,
)
encode_frame = preview_frame
if hasattr(cv2_module, "COLOR_RGB2BGR"):
encode_frame = cv2_module.cvtColor(preview_frame, cv2_module.COLOR_RGB2BGR)
params: list[int] = []
quality_prop = getattr(cv2_module, "IMWRITE_JPEG_QUALITY", None)
if quality_prop is not None:
params = [int(quality_prop), self._preview_jpeg_quality]
ok, encoded = cv2_module.imencode(".jpg", encode_frame, params)
except Exception:
LOGGER.warning("Preview frame encoding failed.", exc_info=True)
with self._lock:
self._preview_encode_status = "error"
return
if not ok:
with self._lock:
self._preview_encode_status = "failed"
return
with self._lock:
self._latest_preview_jpeg = encoded.tobytes()
self._latest_preview_at = time.time()
self._preview_encode_status = "ok"
def _is_current_run(self, run_id: int) -> bool:
return self._running and run_id == self._run_id
def _release_capture(self) -> None:
capture = self._capture
self._capture = None
self._release_capture_object(capture)
self._active_camera_index = None
self._active_camera_backend = None
def _set_error(self, message: str) -> None:
with self._lock:
self._latest_error = message
def _clear_error(self) -> None:
with self._lock:
self._latest_error = None
@staticmethod
def _release_capture_object(capture: Any | None) -> None:
if capture is None:
return
try:
capture.release()
except Exception:
LOGGER.warning("Release camera failed.", exc_info=True)
def _normalize_box(value: object) -> tuple[int, int, int, int] | None:
if not isinstance(value, (tuple, list)) or len(value) != 4:
return None
try:
x, y, width, height = (int(item) for item in value)
except (TypeError, ValueError):
return None
if width <= 0 or height <= 0:
return None
return x, y, width, height
def _detect_largest_face_box(
frame_rgb: np.ndarray,
cv2_module: Any,
runtime: FaceCameraRuntime,
) -> tuple[int, int, int, int] | None:
if not all(hasattr(cv2_module, attr) for attr in ("CascadeClassifier", "cvtColor")):
return None
try:
detector = runtime._haar_cascade
if detector is None:
cascade_path = None
data = getattr(cv2_module, "data", None)
haarcascades = getattr(data, "haarcascades", None)
if haarcascades:
from pathlib import Path
for name in (
"haarcascade_frontalface_default.xml",
"haarcascade_frontalface_alt.xml",
"haarcascade_frontalface_alt2.xml",
):
candidate = Path(haarcascades) / name
if candidate.exists():
cascade_path = str(candidate)
break
if cascade_path is None:
return None
detector = cv2_module.CascadeClassifier(cascade_path)
if hasattr(detector, "empty") and detector.empty():
return None
runtime._haar_cascade = detector
gray = cv2_module.cvtColor(frame_rgb, cv2_module.COLOR_RGB2GRAY)
faces = detector.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(48, 48))
if len(faces) == 0:
return None
x, y, width, height = max(faces, key=lambda box: int(box[2]) * int(box[3]))
return int(x), int(y), int(width), int(height)
except Exception:
LOGGER.debug("Lightweight preview face tracking failed.", exc_info=True)
return None
def _draw_preview_overlay(
frame_rgb: np.ndarray,
cv2_module: Any,
*,
face_box: tuple[int, int, int, int] | None,
source_shape: tuple[int, ...],
result: dict[str, object] | None,
result_at: float | None,
analysis_in_progress: bool,
) -> np.ndarray:
output = frame_rgb.copy()
if not all(hasattr(cv2_module, attr) for attr in ("rectangle", "putText")):
return output
scale_x = output.shape[1] / max(1, int(source_shape[1]))
scale_y = output.shape[0] / max(1, int(source_shape[0]))
if face_box is not None:
x, y, width, height = face_box
x0 = int(x * scale_x)
y0 = int(y * scale_y)
x1 = int((x + width) * scale_x)
y1 = int((y + height) * scale_y)
cv2_module.rectangle(output, (x0, y0), (x1, y1), (60, 220, 90), 2)
lines = _overlay_lines(result, result_at, analysis_in_progress)
font = getattr(cv2_module, "FONT_HERSHEY_SIMPLEX", 0)
line_height = 22
box_width = min(output.shape[1] - 16, 310)
box_height = max(48, 12 + len(lines) * line_height)
cv2_module.rectangle(output, (8, 8), (8 + box_width, 8 + box_height), (8, 18, 34), -1)
cv2_module.rectangle(output, (8, 8), (8 + box_width, 8 + box_height), (80, 170, 255), 1)
for index, line in enumerate(lines):
y = 30 + index * line_height
cv2_module.putText(output, line, (18, y), font, 0.52, (245, 248, 255), 1, getattr(cv2_module, "LINE_AA", 16))
return output
def _overlay_lines(
result: dict[str, object] | None,
result_at: float | None,
analysis_in_progress: bool,
) -> list[str]:
if result is None:
state = "Analyzing..." if analysis_in_progress else "Waiting for face analysis"
return [state, "Emotion: -- Conf: --"]
if not result.get("available"):
message = str(result.get("error_code") or "no_face")
return ["No face detected", f"Status: {message}"]
updated = "--"
if result_at is not None:
age = max(0.0, time.time() - float(result_at))
updated = f"{age:.0f}s ago"
emotion = str(result.get("emotion") or "--")
confidence = _format_overlay_float(result.get("confidence"))
quality = _format_overlay_float(result.get("quality"))
valence = _format_overlay_float(result.get("valence"))
arousal = _format_overlay_float(result.get("arousal"))
return [
f"Emotion: {emotion} Conf: {confidence}",
f"Quality: {quality} Updated: {updated}",
f"V: {valence} A: {arousal}",
]
def _format_overlay_float(value: object) -> str:
try:
return f"{float(value):.2f}"
except (TypeError, ValueError):
return "--"