620 lines
20 KiB
Python
620 lines
20 KiB
Python
from picamera2 import Picamera2
|
|
from threading import Lock, RLock
|
|
import threading
|
|
import time
|
|
import numpy as np
|
|
import cv2
|
|
from pathlib import Path
|
|
|
|
|
|
class CameraManager:
|
|
def __init__(self, state):
|
|
self.state = state
|
|
self.initialized = False
|
|
|
|
self.camera_lock = RLock()
|
|
|
|
self.cameras_runtime = {}
|
|
self._reconfigure_needed = False
|
|
self._sensor_modes_cache = None
|
|
|
|
# =========================================================
|
|
# Estado interno
|
|
# =========================================================
|
|
|
|
def mark_reconfigure_needed(self):
|
|
with self.camera_lock:
|
|
self._reconfigure_needed = True
|
|
|
|
def _init_camera_runtime(self, cam_spec):
|
|
return {
|
|
"camera_id": cam_spec.id,
|
|
"camera_index": cam_spec.index,
|
|
"role": cam_spec.role,
|
|
"interface": getattr(cam_spec, "interface", "CSI"),
|
|
|
|
"backend": None, # picamera2 | opencv
|
|
"picam2": None,
|
|
"cap": None,
|
|
|
|
"last_frame": None,
|
|
"frame_id": 0,
|
|
"frame_ts": None,
|
|
"buffer": None,
|
|
|
|
"stop_event": threading.Event(),
|
|
"thread": None,
|
|
|
|
"last_signature": None,
|
|
"last_read_ts": None,
|
|
"last_new_frame_ts": None,
|
|
|
|
"frame_lock": Lock(),
|
|
"consecutive_failures": 0,
|
|
}
|
|
|
|
def _make_frame_signature(self, frame: np.ndarray):
|
|
if frame is None:
|
|
return None
|
|
|
|
h, w = frame.shape[:2]
|
|
|
|
# pega uma grade simples e barata
|
|
step_y = max(1, h // 16)
|
|
step_x = max(1, w // 16)
|
|
|
|
sample = frame[::step_y, ::step_x]
|
|
|
|
# reduz para assinatura curtinha
|
|
return (
|
|
sample.shape,
|
|
int(sample.mean()),
|
|
int(sample.std()),
|
|
int(sample[0, 0, 0]) if sample.ndim == 3 else int(sample[0, 0]),
|
|
int(sample[-1, -1, 1]) if sample.ndim == 3 and sample.shape[2] > 1 else 0,
|
|
)
|
|
|
|
# =========================================================
|
|
# Inicialização
|
|
# =========================================================
|
|
|
|
def begin(self):
|
|
with self.camera_lock:
|
|
self.stop()
|
|
|
|
required_ids = set(self.state.get_bootstrap_camera_ids_for_frame_type())
|
|
if not required_ids:
|
|
print("[WARN] Nenhuma câmera requerida para o frame_type/capture_mode atual")
|
|
|
|
cams_to_open = [cam for cam in self.state.cameras if cam.id in required_ids]
|
|
|
|
cams_to_open.sort(
|
|
key=lambda cam: 0 if getattr(cam, "interface", "CSI").upper() == "USB" else 1
|
|
)
|
|
|
|
for cam in cams_to_open:
|
|
if cam.id not in required_ids:
|
|
self.state.set_camera_connected(cam.index, False)
|
|
continue
|
|
|
|
try:
|
|
runtime = self._open_camera(cam)
|
|
self.cameras_runtime[cam.id] = runtime
|
|
|
|
self.state.set_camera_connected(
|
|
cam.index,
|
|
False,
|
|
width=cam.width,
|
|
height=cam.height,
|
|
bayer_pattern=cam.bayer_pattern,
|
|
bit_depth=cam.bit_depth,
|
|
)
|
|
|
|
except Exception as e:
|
|
print(f"[WARN] Falha ao abrir {cam.id} (index={cam.index}, role={cam.role}): {e}")
|
|
self.state.set_camera_connected(cam.index, False)
|
|
|
|
# Aplica controles logo após abrir
|
|
if self.cameras_runtime:
|
|
try:
|
|
self.apply_controls()
|
|
except Exception as e:
|
|
print(f"[WARN] Falha ao aplicar controles iniciais: {e}")
|
|
|
|
for cam_id in list(self.cameras_runtime.keys()):
|
|
self._start_thread(cam_id)
|
|
|
|
deadline = time.perf_counter() + 1.0
|
|
while time.perf_counter() < deadline:
|
|
if self.state.camera_count_active > 0:
|
|
break
|
|
time.sleep(0.02)
|
|
|
|
self.initialized = len(self.cameras_runtime) > 0
|
|
self._reconfigure_needed = False
|
|
|
|
return self.initialized
|
|
|
|
def _open_camera(self, cam):
|
|
interface = getattr(cam, "interface", "CSI").upper()
|
|
|
|
if interface == "CSI":
|
|
return self._open_csi_camera(cam)
|
|
|
|
if interface == "USB":
|
|
return self._open_usb_camera(cam)
|
|
|
|
raise RuntimeError(f"Interface de câmera não suportada: {interface}")
|
|
|
|
def _open_csi_camera(self, cam):
|
|
runtime = self._init_camera_runtime(cam)
|
|
|
|
picam2 = Picamera2(camera_num=cam.index)
|
|
|
|
config = picam2.create_video_configuration(
|
|
#main={"size": (640, 480), "format": "RGB888"},
|
|
raw={"size": (cam.width, cam.height)},
|
|
buffer_count=6
|
|
)
|
|
|
|
picam2.configure(config)
|
|
picam2.start()
|
|
|
|
runtime["backend"] = "picamera2"
|
|
runtime["picam2"] = picam2
|
|
return runtime
|
|
|
|
def _open_usb_camera(self, cam):
|
|
runtime = self._init_camera_runtime(cam)
|
|
|
|
backend_name = str(getattr(cam, "usb_backend", "V4L2")).upper()
|
|
api_preference = cv2.CAP_V4L2 if backend_name == "V4L2" else cv2.CAP_ANY
|
|
|
|
source = self._resolve_usb_video_path(cam)
|
|
if source:
|
|
cap = cv2.VideoCapture(source, api_preference)
|
|
else:
|
|
cap = cv2.VideoCapture(cam.index, api_preference)
|
|
|
|
if not cap.isOpened():
|
|
# fallback no índice se o path falhar
|
|
if source:
|
|
cap.release()
|
|
cap = cv2.VideoCapture(cam.index, api_preference)
|
|
|
|
if not cap.isOpened():
|
|
raise RuntimeError(
|
|
f"Falha ao abrir câmera USB. device_path={getattr(cam, 'device_path', None)} index={cam.index}"
|
|
)
|
|
|
|
# Configuração desejada
|
|
cap.set(cv2.CAP_PROP_FRAME_WIDTH, cam.width)
|
|
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, cam.height)
|
|
cap.set(cv2.CAP_PROP_FPS, float(self.state.fps))
|
|
|
|
# Tenta reduzir buffer
|
|
try:
|
|
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
except Exception:
|
|
pass
|
|
|
|
# Tenta MJPG, se a câmera suportar
|
|
try:
|
|
fourcc = cv2.VideoWriter_fourcc(*"MJPG")
|
|
cap.set(cv2.CAP_PROP_FOURCC, fourcc)
|
|
except Exception:
|
|
pass
|
|
|
|
# Pequeno warmup
|
|
time.sleep(0.25)
|
|
|
|
frame = None
|
|
ok = False
|
|
for i in range(15):
|
|
ok, frame = cap.read()
|
|
if ok and frame is not None:
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
if not ok or frame is None:
|
|
cap.release()
|
|
raise RuntimeError(
|
|
f"Falha ao capturar frame inicial da câmera USB "
|
|
f"(device_path={getattr(cam, 'device_path', None)}, index={cam.index})"
|
|
)
|
|
|
|
actual_h, actual_w = frame.shape[:2]
|
|
channels = frame.shape[2] if frame.ndim == 3 else 1
|
|
|
|
cam.width = int(actual_w)
|
|
cam.height = int(actual_h)
|
|
|
|
initial_ts = time.perf_counter()
|
|
runtime["backend"] = "opencv"
|
|
runtime["cap"] = cap
|
|
runtime["buffer"] = frame.copy()
|
|
runtime["last_frame"] = runtime["buffer"]
|
|
runtime["frame_id"] = 0
|
|
runtime["last_signature"] = self._make_frame_signature(frame)
|
|
runtime["last_read_ts"] = initial_ts
|
|
runtime["last_new_frame_ts"] = initial_ts
|
|
runtime["frame_ts"] = initial_ts
|
|
|
|
print(
|
|
f"[INFO] USB {cam.id} aberta: src={getattr(cam, 'device_path', None) or cam.index} "
|
|
f"{actual_w}x{actual_h}, channels={channels}, backend={backend_name}"
|
|
)
|
|
|
|
return runtime
|
|
|
|
def _resolve_usb_video_path(self, cam):
|
|
# 1. Se já veio um caminho explícito e ele existe, usa
|
|
device_path = getattr(cam, "device_path", None)
|
|
if device_path and Path(device_path).exists():
|
|
return str(Path(device_path).resolve()) if Path(device_path).is_symlink() else device_path
|
|
|
|
# 2. Tenta by-id
|
|
by_id_dir = Path("/dev/v4l/by-id")
|
|
if by_id_dir.exists():
|
|
candidates = sorted(by_id_dir.glob("*video-index0"))
|
|
if candidates:
|
|
# Para 1 webcam USB, o primeiro já costuma resolver
|
|
return str(candidates[0])
|
|
|
|
# 3. Tenta by-path
|
|
by_path_dir = Path("/dev/v4l/by-path")
|
|
if by_path_dir.exists():
|
|
candidates = sorted(by_path_dir.glob("*video-index0"))
|
|
if candidates:
|
|
return str(candidates[0])
|
|
|
|
# 4. Fallback bruto
|
|
return None
|
|
|
|
# =========================================================
|
|
# Threads de captura
|
|
# =========================================================
|
|
|
|
def _start_thread(self, cam_id):
|
|
runtime = self.cameras_runtime[cam_id]
|
|
runtime["stop_event"].clear()
|
|
runtime["consecutive_failures"] = 0
|
|
|
|
t = threading.Thread(
|
|
target=self._update_loop,
|
|
args=(cam_id,),
|
|
daemon=True
|
|
)
|
|
runtime["thread"] = t
|
|
t.start()
|
|
|
|
def _update_loop(self, cam_id):
|
|
runtime = self.cameras_runtime[cam_id]
|
|
backend = runtime["backend"]
|
|
|
|
while not runtime["stop_event"].is_set():
|
|
try:
|
|
if backend == "picamera2":
|
|
self._update_loop_picamera2(runtime)
|
|
elif backend == "opencv":
|
|
self._update_loop_opencv(runtime)
|
|
else:
|
|
raise RuntimeError(f"Backend desconhecido: {backend}")
|
|
|
|
runtime["consecutive_failures"] = 0
|
|
|
|
except Exception as e:
|
|
runtime["consecutive_failures"] += 1
|
|
print(f"[ERRO LOOP {cam_id}/{backend}] {e}")
|
|
|
|
if runtime["consecutive_failures"] >= 5:
|
|
self.state.set_camera_connected(runtime["camera_index"], False)
|
|
runtime["stop_event"].set()
|
|
print(f"[WARN] Desativando {cam_id} após falhas consecutivas no loop")
|
|
break
|
|
|
|
time.sleep(0.05)
|
|
|
|
def _update_loop_picamera2(self, runtime):
|
|
request = None
|
|
try:
|
|
picam2 = runtime["picam2"]
|
|
request = picam2.capture_request()
|
|
|
|
raw = request.make_array("raw")
|
|
|
|
with runtime["frame_lock"]:
|
|
first_valid_frame = runtime["frame_id"] == 0
|
|
|
|
if (
|
|
runtime["buffer"] is None or
|
|
runtime["buffer"].shape != raw.shape or
|
|
runtime["buffer"].dtype != raw.dtype
|
|
):
|
|
runtime["buffer"] = raw.copy()
|
|
else:
|
|
np.copyto(runtime["buffer"], raw)
|
|
|
|
read_ts = time.perf_counter()
|
|
runtime["last_frame"] = runtime["buffer"]
|
|
runtime["frame_id"] += 1
|
|
runtime["frame_ts"] = read_ts
|
|
runtime["last_read_ts"] = read_ts
|
|
runtime["last_new_frame_ts"] = read_ts
|
|
|
|
if first_valid_frame:
|
|
self.state.set_camera_connected(
|
|
runtime["camera_index"],
|
|
True,
|
|
width=raw.shape[1],
|
|
height=raw.shape[0],
|
|
)
|
|
|
|
finally:
|
|
if request is not None:
|
|
try:
|
|
request.release()
|
|
except Exception:
|
|
pass
|
|
|
|
def _update_loop_opencv(self, runtime):
|
|
cap = runtime["cap"]
|
|
|
|
target_fps = max(1.0, float(self.state.fps or 10))
|
|
min_period = 1.0 / target_fps
|
|
t_start = time.perf_counter()
|
|
|
|
ok, frame = cap.read()
|
|
read_ts = time.perf_counter()
|
|
|
|
if not ok or frame is None:
|
|
raise RuntimeError("Falha ao ler frame da câmera USB")
|
|
|
|
signature = self._make_frame_signature(frame)
|
|
|
|
with runtime["frame_lock"]:
|
|
first_valid_frame = runtime["frame_id"] == 0
|
|
runtime["last_read_ts"] = read_ts
|
|
|
|
if signature != runtime.get("last_signature"):
|
|
if (
|
|
runtime["buffer"] is None or
|
|
runtime["buffer"].shape != frame.shape or
|
|
runtime["buffer"].dtype != frame.dtype
|
|
):
|
|
runtime["buffer"] = frame.copy()
|
|
else:
|
|
np.copyto(runtime["buffer"], frame)
|
|
|
|
runtime["last_frame"] = runtime["buffer"]
|
|
runtime["frame_id"] += 1
|
|
runtime["frame_ts"] = read_ts
|
|
runtime["last_new_frame_ts"] = read_ts
|
|
runtime["last_signature"] = signature
|
|
|
|
if first_valid_frame:
|
|
self.state.set_camera_connected(
|
|
runtime["camera_index"],
|
|
True,
|
|
width=frame.shape[1],
|
|
height=frame.shape[0],
|
|
bit_depth=8,
|
|
)
|
|
|
|
dt = time.perf_counter() - t_start
|
|
sleep_s = min_period - dt
|
|
if sleep_s > 0:
|
|
time.sleep(sleep_s)
|
|
|
|
# =========================================================
|
|
# Leitura consolidada
|
|
# =========================================================
|
|
|
|
def capture_raw_frames(self):
|
|
result = {}
|
|
|
|
for cam_id, runtime in self.cameras_runtime.items():
|
|
with runtime["frame_lock"]:
|
|
if runtime["last_frame"] is None:
|
|
continue
|
|
|
|
frame = runtime["last_frame"].copy()
|
|
h, w = frame.shape[:2]
|
|
channels = frame.shape[2] if frame.ndim == 3 else 1
|
|
|
|
result[cam_id] = (
|
|
frame,
|
|
w,
|
|
h,
|
|
channels,
|
|
runtime["frame_id"],
|
|
runtime["frame_ts"],
|
|
runtime["last_read_ts"]
|
|
)
|
|
|
|
return result
|
|
|
|
# =========================================================
|
|
# Controles
|
|
# =========================================================
|
|
|
|
def apply_controls(self):
|
|
with self.camera_lock:
|
|
for cam_id, runtime in self.cameras_runtime.items():
|
|
backend = runtime.get("backend")
|
|
|
|
if backend == "picamera2":
|
|
self._apply_controls_picamera2(runtime)
|
|
|
|
elif backend == "opencv":
|
|
self._apply_controls_opencv(runtime)
|
|
|
|
return True
|
|
|
|
def _apply_controls_picamera2(self, runtime):
|
|
picam2 = runtime.get("picam2")
|
|
if picam2 is None:
|
|
return
|
|
|
|
controls = {}
|
|
frame_us = int(1_000_000 / max(1, self.state.fps or 10))
|
|
|
|
controls["FrameDurationLimits"] = (frame_us, frame_us)
|
|
controls["AeEnable"] = bool(self.state.ae_enable)
|
|
controls["AwbEnable"] = bool(self.state.awb_enable)
|
|
|
|
if not self.state.ae_enable:
|
|
if self.state.exposure_time_us is not None:
|
|
controls["ExposureTime"] = int(self.state.exposure_time_us)
|
|
if self.state.analogue_gain is not None:
|
|
controls["AnalogueGain"] = float(self.state.analogue_gain)
|
|
|
|
# Só faz sentido real para câmera colorida no backend Picamera2.
|
|
# Como RE/NIR são mono, normalmente AWB/ColourGains serão ignorados.
|
|
if not self.state.awb_enable and self.state.colour_gains is not None:
|
|
r_gain, b_gain = self.state.colour_gains
|
|
controls["ColourGains"] = (float(r_gain), float(b_gain))
|
|
|
|
try:
|
|
picam2.set_controls(controls)
|
|
except Exception as e:
|
|
print(f"[ERRO CONTROLS PICAM2] {e} | controls={controls}")
|
|
|
|
def _apply_controls_opencv(self, runtime):
|
|
cap = runtime.get("cap")
|
|
if cap is None:
|
|
return
|
|
|
|
# FPS
|
|
try:
|
|
cap.set(cv2.CAP_PROP_FPS, float(self.state.fps))
|
|
except Exception:
|
|
pass
|
|
|
|
# Exposição
|
|
if self.state.exposure_time_us is not None:
|
|
try:
|
|
# Nem toda webcam respeita isso; tentativa best-effort
|
|
cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) # manual em muitos backends V4L2
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
# OpenCV costuma usar escala dependente do driver.
|
|
# Mantemos como tentativa simples, depois calibramos na prática.
|
|
cap.set(cv2.CAP_PROP_EXPOSURE, float(self.state.exposure_time_us))
|
|
except Exception:
|
|
pass
|
|
|
|
# Ganho
|
|
if self.state.analogue_gain is not None:
|
|
try:
|
|
cap.set(cv2.CAP_PROP_GAIN, float(self.state.analogue_gain))
|
|
except Exception:
|
|
pass
|
|
|
|
# AWB / WB
|
|
try:
|
|
if self.state.awb_enable:
|
|
cap.set(cv2.CAP_PROP_AUTO_WB, 1)
|
|
else:
|
|
cap.set(cv2.CAP_PROP_AUTO_WB, 0)
|
|
except Exception:
|
|
pass
|
|
|
|
# =========================================================
|
|
# Sensor modes
|
|
# =========================================================
|
|
|
|
def get_sensor_modes(self):
|
|
if self._sensor_modes_cache is not None:
|
|
return self._sensor_modes_cache
|
|
|
|
result = []
|
|
|
|
for cam in self.state.cameras:
|
|
if getattr(cam, "interface", "CSI").upper() != "CSI":
|
|
continue
|
|
|
|
temp = None
|
|
try:
|
|
temp = Picamera2(camera_num=cam.index)
|
|
modes = temp.sensor_modes
|
|
|
|
cam_modes = []
|
|
for i, m in enumerate(modes):
|
|
cam_modes.append({
|
|
"camera_id": cam.id,
|
|
"camera_index": cam.index,
|
|
"role": cam.role,
|
|
"interface": cam.interface,
|
|
"mode_index": i,
|
|
"format": str(m.get("format")) if m.get("format") is not None else None,
|
|
"size": list(m.get("size")) if m.get("size") is not None else None,
|
|
"bit_depth": m.get("bit_depth"),
|
|
"fps": m.get("fps"),
|
|
"crop_limits": list(m.get("crop_limits")) if m.get("crop_limits") is not None else None,
|
|
"exposure_limits": list(m.get("exposure_limits")) if m.get("exposure_limits") is not None else None,
|
|
})
|
|
|
|
result.extend(cam_modes)
|
|
|
|
except Exception as e:
|
|
result.append({
|
|
"camera_id": cam.id,
|
|
"camera_index": cam.index,
|
|
"role": cam.role,
|
|
"interface": cam.interface,
|
|
"error": str(e),
|
|
})
|
|
|
|
finally:
|
|
if temp is not None:
|
|
try:
|
|
temp.close()
|
|
except Exception:
|
|
pass
|
|
|
|
self._sensor_modes_cache = result
|
|
return result
|
|
|
|
# =========================================================
|
|
# Encerramento
|
|
# =========================================================
|
|
|
|
def stop(self):
|
|
with self.camera_lock:
|
|
for runtime in self.cameras_runtime.values():
|
|
runtime["stop_event"].set()
|
|
|
|
for runtime in self.cameras_runtime.values():
|
|
t = runtime.get("thread")
|
|
if t:
|
|
t.join(timeout=1.5)
|
|
|
|
for runtime in self.cameras_runtime.values():
|
|
cam = runtime.get("picam2")
|
|
if cam:
|
|
try:
|
|
cam.stop()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
cam.close()
|
|
except Exception:
|
|
pass
|
|
|
|
cap = runtime.get("cap")
|
|
if cap:
|
|
try:
|
|
cap.release()
|
|
except Exception:
|
|
pass
|
|
|
|
self.cameras_runtime.clear()
|
|
self.initialized = False
|
|
self._reconfigure_needed = False
|
|
self._sensor_modes_cache = None
|
|
|
|
for cam in self.state.cameras:
|
|
self.state.set_camera_connected(cam.index, False) |