robustez no camera manager do weed e multiesctral

This commit is contained in:
Diego Freitas 2026-07-14 19:14:54 -03:00
parent a59bee710d
commit 4ee8a1e76a
5 changed files with 662 additions and 425 deletions

View File

@ -83,6 +83,7 @@ class CameraMultispectral:
self._vida_lock = threading.RLock()
self._parando = False
self._falha_fatal_pendente = False
self.client = None
self._ultimo_resultado_tensor = {
@ -452,15 +453,22 @@ class CameraMultispectral:
pass
def _falha_fatal_depthai(self, motivo):
self.mostrar_log(f"[CameraMultispectral] Falha fatal DepthAI. Fechando pipeline: {motivo}")
"""
Marca a falha, mas não fecha o dai.Device nesta camada.
O CameraManager é o único dono do ciclo de vida e fará o fechamento.
Isso evita CameraMultispectral.parar() e CameraManager.fechar_camera_manager()
concorrendo sobre o mesmo objeto nativo.
"""
if self._falha_fatal_pendente:
return
self._falha_fatal_pendente = True
self.mostrar_log(
f"[CameraMultispectral] Falha fatal DepthAI detectada: {motivo}"
)
self._marcar_desconectada(motivo)
try:
self.parar()
except Exception as e:
self.mostrar_log(f"[CameraMultispectral] Erro ao parar após falha fatal: {e}")
# ============================================================
# Captura principal
# ============================================================
@ -567,6 +575,7 @@ class CameraMultispectral:
t_preview_ms = 0.0
ts = time.time()
ts_raw_perf = time.perf_counter()
dur = time.perf_counter() - t_total0
meta = dict(meta or {})
@ -606,6 +615,24 @@ class CameraMultispectral:
self.timestamp_ultimo_tensor = ts
self._ultimo_resultado_tensor = resultado
# O mesmo pacote RAW usado na inferência alimenta o cache científico.
# O salvamento não precisa consumir outra vez o cursor DepthAI.
if isinstance(frame, dict) and frame:
# Arrays do pacote são imutáveis após a captura.
# Mantemos referências e copiamos somente no salvamento.
self.ultimo_raw_multi = dict(frame)
self.timestamp_ultimo_raw_multi = ts_raw_perf
self._ultimo_resultado_raw = {
"erro": None,
"duracao": dur,
"frame_valido": True,
"cameras": list(frame.keys()),
"sync_ok": bool(meta.get("sync_ok", True)),
"sync_dt_ms": float(meta.get("sync_dt_ms", 0.0) or 0.0),
"frame_id": int(meta.get("frame_id", 0) or 0),
"origem": "cache_da_captura_operacional",
}
self.ultimo_frame_rgb = preview_bgr
self.timestamp_ultimo_frame_rgb = ts
self._ultimo_resultado_rgb = {
@ -679,50 +706,28 @@ class CameraMultispectral:
}
def requisitar_frame_raw_multi(self, force: bool = False, max_age_s: float = None):
"""
Debug opcional.
Retorna dict por câmera:
{"CAM_A": raw, "CAM_B": raw, "CAM_C": raw}, resultado
O weed_worker normal não precisa usar isso.
"""
"""Retorna cópia do último RAW capturado pelo fluxo operacional."""
try:
if max_age_s is None or float(max_age_s) <= 0:
max_age_s = max(1.0, self._cache_max_age_s)
agora = time.perf_counter()
if max_age_s is None:
max_age_s = self._cache_max_age_s
with self._lock:
cache_ok = (
self.ultimo_raw_multi is not None
and self.timestamp_ultimo_raw_multi is not None
and (agora - self.timestamp_ultimo_raw_multi) < max_age_s
)
if self.ultimo_raw_multi is None or self.timestamp_ultimo_raw_multi is None:
raise RuntimeError("cache RAW ainda não disponível")
if cache_ok and not force:
return self.ultimo_raw_multi, dict(self._ultimo_resultado_raw)
idade = agora - self.timestamp_ultimo_raw_multi
if idade > float(max_age_s):
raise RuntimeError(f"cache RAW antigo: {idade:.2f}s")
if self.client is None:
raise RuntimeError("OakFcc3Client não inicializado")
t0 = time.perf_counter()
raw_frame, raw_meta = self.client.get_next_raw_frame(timeout=self.timeout_s)
ts = time.perf_counter()
dur = ts - t0
resultado = {
"erro": None,
"duracao": dur,
"frame_valido": bool(raw_frame),
"cameras": list(raw_frame.keys()) if isinstance(raw_frame, dict) else [],
"sync_ok": bool(raw_meta.get("sync_ok", True)) if isinstance(raw_meta, dict) else True,
"sync_dt_ms": float(raw_meta.get("sync_dt_ms", 0.0)) if isinstance(raw_meta, dict) else 0.0,
}
with self._lock:
self.ultimo_raw_multi = raw_frame
self.ultimo_meta = raw_meta
self.timestamp_ultimo_raw_multi = ts
self._ultimo_resultado_raw = resultado
raw_frame = {
cam_id: arr.copy()
for cam_id, arr in self.ultimo_raw_multi.items()
}
resultado = dict(self._ultimo_resultado_raw)
resultado["cache_age_s"] = float(idade)
resultado["force_ignorado"] = bool(force)
return raw_frame, resultado
@ -732,11 +737,9 @@ class CameraMultispectral:
"duracao": 0.0,
"frame_valido": False,
}
with self._lock:
self._ultimo_resultado_raw = resultado
self.mostrar_log(f"[CameraMultispectral] Erro ao requisitar RAW multi: {e}")
self.mostrar_log(f"[CameraMultispectral] RAW cache indisponível: {e}")
return None, resultado
# ============================================================
@ -744,33 +747,35 @@ class CameraMultispectral:
# ============================================================
def atualizar_saude(self, pipeline_ia=None):
#self.mostrar_log(f"[{self.mx_id}] Atualizando saude {self.dispositivo.name} multispectral...")
if self.imu is not None:
self.imu.atualizar_saude()
conectado_ctx = (ContextoGlobalRedis.get_cameras() or {}).get(self.mx_id) is not None
conectado = bool(self.iniciado and self.client is not None)
motivos = []
saude = 50
status = self._safe_get_status()
running = bool(status.get("running", False))
conectado = bool(
self.iniciado
and self.client is not None
and running
and not self._falha_fatal_pendente
)
performance = {
"temperatura": 0.0,
"memoria_usada": 0.0,
"executando": False,
"executando": running,
"velocidade": "",
"sync_dt_ms": 0.0
"sync_dt_ms": 0.0,
"async_capture": status.get("async_capture") or {},
}
if pipeline_ia is not None:
self._ultimo_pipeline_ia = dict(pipeline_ia)
pipeline_ia = dict(self._ultimo_pipeline_ia or {})
pipeline_ia_informado = True
pipeline_ia_ok = (
not pipeline_ia_informado
or bool(pipeline_ia.get("ok", False))
)
pipeline_ia_ok = bool(pipeline_ia.get("ok", False))
performance["pipeline_ia"] = pipeline_ia
imu = self.imu.get_dados() if self.imu else {
@ -794,59 +799,55 @@ class CameraMultispectral:
resultado = dict(self._ultimo_resultado_tensor)
try:
status = self._safe_get_status()
running = bool(status.get("running", False))
performance["executando"] = running
metrics = (
self.client.get_device_metrics()
if self.client is not None
else {"ok": False, "error": "client indisponível"}
)
performance["device_metrics"] = metrics
# Tenta chegar no device real por baixo do service/manager.
dev = None
try:
dev = self.client.svc.manager.device
except Exception:
dev = None
temp = float(metrics.get("temperature_average_c", 0.0) or 0.0)
performance["temperatura"] = temp
if dev is not None:
try:
temp = float(dev.getChipTemperature().average)
performance["temperatura"] = temp
if temp >= 80:
motivos.append(f"Temperatura crítica: {temp:.1f} °C")
saude -= 30
elif temp >= 70:
motivos.append(f"Temperatura elevada: {temp:.1f} °C")
saude -= 15
elif temp >= 60:
motivos.append(f"Temperatura acima do ideal: {temp:.1f} °C")
saude -= 5
if temp >= 80:
motivos.append(f"Temperatura crítica: {temp:.1f} °C")
saude -= 30
elif temp >= 70:
motivos.append(f"Temperatura elevada: {temp:.1f} °C")
saude -= 15
elif temp >= 60:
motivos.append(f"Temperatura acima do ideal: {temp:.1f} °C")
saude -= 5
except Exception:
pass
ddr = float(metrics.get("ddr_used_bytes", 0) or 0) / 1024.0 / 1024.0
performance["memoria_usada"] = ddr
try:
ddr = float(dev.getDdrMemoryUsage().used) / 1024.0 / 1024.0
performance["memoria_usada"] = ddr
if ddr >= 500:
motivos.append(f"Memória DDR crítica: {ddr:.1f} MB")
saude -= 30
elif ddr >= 400:
motivos.append(f"Memória DDR elevada: {ddr:.1f} MB")
saude -= 15
elif ddr >= 300:
motivos.append(f"Memória DDR acima do ideal: {ddr:.1f} MB")
saude -= 5
if ddr >= 500:
motivos.append(f"Memória DDR crítica: {ddr:.1f} MB")
saude -= 30
elif ddr >= 400:
motivos.append(f"Memória DDR elevada: {ddr:.1f} MB")
saude -= 15
elif ddr >= 300:
motivos.append(f"Memória DDR acima do ideal: {ddr:.1f} MB")
saude -= 5
except Exception:
pass
speed = str(metrics.get("usb_speed", "") or "")
performance["velocidade"] = speed
if speed and speed.lower() not in ("super", "superplus"):
motivos.append(f"USB lenta: {speed}")
saude -= 15
try:
speed = dev.getUsbSpeed().name
performance["velocidade"] = speed
async_status = metrics.get("async_capture") or performance["async_capture"]
performance["async_capture"] = async_status
if str(speed).lower() not in ["super", "superplus"]:
motivos.append(f"USB lenta: {speed}")
saude -= 15
except Exception:
pass
if running and async_status:
if not bool(async_status.get("thread_alive", False)):
motivos.append("Thread assíncrona de captura parada")
saude = 0
erro_async = async_status.get("last_error")
if erro_async:
motivos.append(f"Captura assíncrona: {erro_async}")
if not running:
motivos.append("Pipeline parada")
@ -858,19 +859,17 @@ class CameraMultispectral:
"duracao": 0.0,
"frame_valido": False,
}
if "Communication exception" in str(e) or "X_LINK_ERROR" in str(e):
if self._is_erro_fatal_depthai(e):
conectado = False
agora = time.time()
ts_tensor = self.timestamp_ultimo_tensor or 0.0
frame_recente = (agora - ts_tensor) <= 2.0
frame_recente = ts_tensor > 0 and (agora - ts_tensor) <= 2.0
if resultado.get("sync_dt_ms") is not None:
try:
performance["sync_dt_ms"] = float(resultado.get("sync_dt_ms", 0.0))
except Exception:
pass
try:
performance["sync_dt_ms"] = float(resultado.get("sync_dt_ms", 0.0) or 0.0)
except Exception:
pass
if not conectado and not conectado_ctx:
motivos.append("desconectado")
@ -901,34 +900,25 @@ class CameraMultispectral:
saude -= 10
motivos.append(f"Sincronismo alto: {sync_dt:.1f} ms")
if pipeline_ia_informado and not pipeline_ia_ok:
motivos_pipeline = pipeline_ia.get("motivos", []) or []
for motivo in motivos_pipeline:
if not pipeline_ia_ok:
for motivo in pipeline_ia.get("motivos", []) or []:
texto = f"Pipeline IA: {motivo}"
if texto not in motivos:
motivos.append(texto)
saude = 0
motivos = list(dict.fromkeys(str(m) for m in motivos if m))
saude = min(max(saude, 0), 100)
status_mod = StatusModulo.OPERANTE
if not conectado:
status_mod = StatusModulo.DESCONECTADO
elif not frame_recente:
status_mod = StatusModulo.FALHA
elif not pipeline_ia_ok:
status_mod = StatusModulo.FALHA
elif saude <= 0:
elif not frame_recente or not pipeline_ia_ok or saude <= 0:
status_mod = StatusModulo.FALHA
elif saude < 80:
status_mod = StatusModulo.ALERTA
self.rodando = (
conectado
and frame_recente
and pipeline_ia_ok
)
self.rodando = bool(conectado and frame_recente and pipeline_ia_ok)
saude_geral = {
"timestamp": agora,
@ -938,7 +928,6 @@ class CameraMultispectral:
"motivos": motivos,
"saude_individual": [],
}
self.ultima_saude = saude_geral
from camera_worker.manager import definir_saude_camera
@ -953,7 +942,7 @@ class CameraMultispectral:
conectado,
agora,
self.dispositivo,
imu=imu
imu=imu,
)
def _definir_heartbeat(self):
@ -1175,129 +1164,54 @@ class CameraMultispectral:
def requisitar_bundle_raw_multispec(self, force: bool = True, max_age_s: float = None):
"""
Retorna um bundle científico RAW_BRUTO completo.
Monta o bundle a partir do cache do fluxo operacional.
Retorno:
bundle, resultado
bundle = {
"raw_frame": {
"CAM_A": np.ndarray RAW10 packed,
"CAM_B": np.ndarray RAW10 packed,
"CAM_C": np.ndarray RAW10 packed,
},
"raw_meta": dict,
"preview_bgr": np.ndarray BGR uint8 ou None,
"preview_method": str,
}
Este método não salva nada em disco.
Ele apenas coleta e organiza o pacote bruto.
Não chama client.get_next_raw_frame(), portanto não disputa o cursor
assíncrono com a inferência. force é mantido por compatibilidade.
"""
try:
agora = time.perf_counter()
if max_age_s is None or float(max_age_s) <= 0:
max_age_s = max(1.0, self._cache_max_age_s)
if max_age_s is None:
max_age_s = self._cache_max_age_s
with self._lock:
cache_ok = (
self.ultimo_raw_multi is not None
and self.ultimo_meta is not None
and self.timestamp_ultimo_raw_multi is not None
and (agora - self.timestamp_ultimo_raw_multi) < max_age_s
)
if cache_ok and not force:
raw_frame = {
cam_id: arr.copy()
for cam_id, arr in self.ultimo_raw_multi.items()
}
raw_meta = dict(self.ultimo_meta)
preview_bgr, preview_method = self._build_preview_raw_multispec(
raw_frame=raw_frame,
raw_meta=raw_meta,
)
bundle = {
"raw_frame": raw_frame,
"raw_meta": raw_meta,
"preview_bgr": preview_bgr,
"preview_method": preview_method,
}
return bundle, dict(self._ultimo_resultado_raw)
if self.client is None:
raise RuntimeError("OakFcc3Client não inicializado")
t0 = time.perf_counter()
raw_frame, raw_meta = self.client.get_next_raw_frame(
timeout=self.timeout_s
raw_frame, resultado = self.requisitar_frame_raw_multi(
force=False,
max_age_s=max_age_s,
)
dur = time.perf_counter() - t0
if raw_frame is None:
raise RuntimeError(resultado.get("erro") or "RAW cache indisponível")
if not isinstance(raw_frame, dict) or not raw_frame:
raise RuntimeError(
f"RAW_BRUTO inválido. Esperado dict por câmera, veio {type(raw_frame)}"
)
raw_meta = dict(raw_meta or {})
frame_type = str(raw_meta.get("frame_type", "")).upper()
if frame_type and frame_type != "RAW_BRUTO":
raise RuntimeError(
f"Bundle científico esperado em RAW_BRUTO, mas veio frame_type={frame_type}"
)
with self._lock:
raw_meta = dict(self.ultimo_meta or {})
required = {"CAM_A", "CAM_B", "CAM_C"}
presentes = set(raw_frame.keys())
faltando = sorted(required - presentes)
if faltando:
raise RuntimeError(
f"RAW_BRUTO incompleto. Faltando câmeras: {faltando}. Presentes: {sorted(presentes)}"
f"RAW_BRUTO incompleto. Faltando câmeras: {faltando}. "
f"Presentes: {sorted(presentes)}"
)
raw_frame_copy = {
cam_id: arr.copy()
for cam_id, arr in raw_frame.items()
}
preview_bgr, preview_method = self._build_preview_raw_multispec(
raw_frame=raw_frame_copy,
raw_frame=raw_frame,
raw_meta=raw_meta,
)
resultado = {
resultado = dict(resultado)
resultado.update({
"erro": None,
"duracao": dur,
"frame_valido": True,
"cameras": list(raw_frame_copy.keys()),
"sync_ok": bool(raw_meta.get("sync_ok", True)),
"sync_dt_ms": float(raw_meta.get("sync_dt_ms", 0.0) or 0.0),
"frame_id": raw_meta.get("frame_id"),
"preview_method": preview_method,
}
with self._lock:
self.ultimo_raw_multi = raw_frame_copy
self.ultimo_meta = raw_meta
self.timestamp_ultimo_raw_multi = agora
self._ultimo_resultado_raw = resultado
"origem": "cache_da_captura_operacional",
})
bundle = {
"raw_frame": raw_frame_copy,
"raw_frame": raw_frame,
"raw_meta": raw_meta,
"preview_bgr": preview_bgr,
"preview_method": preview_method,
}
return bundle, resultado
except Exception as e:
@ -1306,17 +1220,11 @@ class CameraMultispectral:
"duracao": 0.0,
"frame_valido": False,
}
with self._lock:
self._ultimo_resultado_raw = resultado
self.mostrar_log(
f"[CameraMultispectral] Erro ao requisitar bundle RAW multispec: {e}"
f"[CameraMultispectral] Erro ao montar bundle RAW cacheado: {e}"
)
if self._is_erro_fatal_depthai(e):
self._falha_fatal_depthai(e)
return None, resultado
def _build_preview_raw_multispec(self, raw_frame: dict, raw_meta: dict):

View File

@ -173,10 +173,11 @@ class OakFcc3Client:
return resp
def stop(self):
try:
self.svc.stop()
finally:
self.svc.disconnect()
# disconnect() já para o manager. Evita manager.stop() duplicado.
return self.svc.disconnect()
def get_device_metrics(self):
return self.svc.get_device_metrics()
def get_status(self):
return self.svc.get_status()

View File

@ -92,6 +92,10 @@ class OakFcc3Manager:
self.running = False
self.frame_id = 0
# Serializa start/stop e leituras nativas de telemetria.
# Evita getChipTemperature/getUsbSpeed concorrendo com device.close().
self._device_lock = threading.RLock()
self.control_queues = {}
self.camera_controls = {
cam_id: self._default_controls_for_role(role)
@ -746,13 +750,20 @@ class OakFcc3Manager:
# ============================================================
def start(self):
with self._device_lock:
return self._start_impl()
def _start_impl(self):
if self.running:
return
thread_anterior = self._capture_thread
if (thread_anterior is not None and thread_anterior.is_alive()):
raise RuntimeError("Não é seguro iniciar novo pipeline: thread OakFcc3AsyncCapture anterior ainda está viva.")
if thread_anterior is not None and thread_anterior.is_alive():
raise RuntimeError(
"Não é seguro iniciar novo pipeline: "
"thread OakFcc3AsyncCapture anterior ainda está viva."
)
try:
self.dev_info = self._resolve_device_info()
@ -781,7 +792,10 @@ class OakFcc3Manager:
role = self.roles.get(socket_name, "unknown")
print(f"[OAK] Criando câmera {socket_name} sensor={f.sensorName} role={role}")
print(
f"[OAK] Criando câmera {socket_name} "
f"sensor={f.sensorName} role={role}"
)
cam, output = self._create_camera_node_classic(
socket=socket,
@ -813,9 +827,7 @@ class OakFcc3Manager:
}
self._validate_capture_mode()
self._create_imu_node(self.pipeline)
self.device.startPipeline(self.pipeline)
self.q_imu = None
@ -861,21 +873,30 @@ class OakFcc3Manager:
self._start_async_capture_thread()
time.sleep(0.05)
if not self.running:
erro = self._capture_thread_stats.get("last_error")
raise RuntimeError(
"Pipeline DepthAI caiu imediatamente após iniciar. "
f"Último erro: {erro}"
)
except Exception:
# Garante limpeza mesmo se a falha acontecer
# no meio da construção do pipeline.
# _device_lock é RLock, então a limpeza pode reutilizar stop().
try:
self.stop()
except Exception:
pass
raise
def stop(self):
with self._device_lock:
return self._stop_impl()
def _stop_impl(self):
# Impede novas capturas imediatamente.
self.running = False
self._stop_async_capture_thread()
thread_ok = self._stop_async_capture_thread()
try:
if self.pipeline is not None and hasattr(self.pipeline, "stop"):
@ -891,6 +912,7 @@ class OakFcc3Manager:
self.pipeline = None
self.device = None
self.dev_info = None
self.queues.clear()
self.buffers.clear()
@ -911,15 +933,23 @@ class OakFcc3Manager:
except Exception:
pass
return bool(thread_ok is not False)
def _is_fatal_depthai_error(self, erro):
txt = str(erro)
txt = str(erro).lower()
sinais = [
"X_LINK_ERROR",
"Communication exception",
"Couldn't read data from stream",
"Device already closed",
"x_link_error",
"communication exception",
"couldn't read data from stream",
"couldn't open stream",
"device already closed",
"device has been closed",
"x_link_device_not_found",
"failed to find device",
"no available devices",
"nenhum dispositivo depthai",
"device crashed",
]
return any(s in txt for s in sinais)
@ -928,29 +958,86 @@ class OakFcc3Manager:
# Status
# ============================================================
def get_device_metrics(self):
"""Lê métricas nativas sem concorrer com start/stop/device.close()."""
with self._device_lock:
dev = self.device
if dev is None or not self.running:
return {
"ok": False,
"running": bool(self.running),
"error": "device indisponível",
}
out = {
"ok": True,
"running": True,
"timestamp": time.time(),
}
try:
speed = dev.getUsbSpeed()
out["usb_speed"] = getattr(speed, "name", str(speed))
except Exception as e:
out["usb_error"] = str(e)
try:
temp = dev.getChipTemperature()
for attr in ("average", "css", "mss", "upa", "dss"):
if hasattr(temp, attr):
out[f"temperature_{attr}_c"] = float(getattr(temp, attr))
except Exception as e:
out["temperature_error"] = str(e)
for nome, metodo in (
("ddr", "getDdrMemoryUsage"),
("cmx", "getCmxMemoryUsage"),
):
try:
uso = getattr(dev, metodo)()
out[f"{nome}_used_bytes"] = int(getattr(uso, "used", 0))
out[f"{nome}_total_bytes"] = int(getattr(uso, "total", 0))
except Exception as e:
out[f"{nome}_error"] = str(e)
for nome, metodo in (
("leon_css", "getLeonCssCpuUsage"),
("leon_mss", "getLeonMssCpuUsage"),
):
try:
uso = getattr(dev, metodo)()
out[f"{nome}_average"] = float(getattr(uso, "average", uso))
except Exception as e:
out[f"{nome}_error"] = str(e)
out["async_capture"] = self.get_async_capture_status()
return out
def get_status(self):
return {
"mx_id": self.mx_id,
"backend": "oak_fcc3",
"running": self.running,
"fps": self.fps,
"width": self.width,
"height": self.height,
"sensor_width": self.sensor_width,
"sensor_height": self.sensor_height,
"frame_type": self.frame_type,
"output_dtype": self.output_dtype,
"capture_mode": self.capture_mode,
"raw_policy": self.raw_policy,
"sync_tolerance_ms": self.sync_tolerance_ms,
"buffer_size": self.buffer_size,
"geometry_stage": "oak" if self._is_multispec_mode() else "pc",
"aligned_geometry": self._serializable_aligned_geometry(),
"cameras": list(self.camera_info.values()),
"async_capture": self.get_async_capture_status() if hasattr(self, "get_async_capture_status") else None,
"tem_imu": bool(getattr(self, "tem_imu", False)),
"has_imu_pipeline": bool(getattr(self, "has_imu_pipeline", False)),
}
with self._device_lock:
return {
"mx_id": self.mx_id,
"backend": "oak_fcc3",
"running": bool(self.running),
"fps": self.fps,
"width": self.width,
"height": self.height,
"sensor_width": self.sensor_width,
"sensor_height": self.sensor_height,
"frame_type": self.frame_type,
"output_dtype": self.output_dtype,
"capture_mode": self.capture_mode,
"raw_policy": self.raw_policy,
"sync_tolerance_ms": self.sync_tolerance_ms,
"buffer_size": self.buffer_size,
"geometry_stage": "oak" if self._is_multispec_mode() else "pc",
"aligned_geometry": self._serializable_aligned_geometry(),
"cameras": list(self.camera_info.values()),
"async_capture": self.get_async_capture_status(),
"tem_imu": bool(getattr(self, "tem_imu", False)),
"has_imu_pipeline": bool(getattr(self, "has_imu_pipeline", False)),
}
def _serializable_aligned_geometry(self):
if not isinstance(self.aligned_geometry, dict):

View File

@ -13,8 +13,12 @@ class OakFcc3Service:
return {"ok": True, "backend": "oak_fcc3", "connected": True}
def disconnect(self):
self.stop()
self.connected = False
# Único caminho de fechamento do manager.
try:
self.manager.stop()
finally:
self.connected = False
return {"ok": True, "connected": False}
def ping(self):
@ -33,9 +37,13 @@ class OakFcc3Service:
for c in status.get("cameras", [])
}
running = bool(status.get("running", False))
realmente_ativo = bool(self.connected and running)
status.update({
"ok": True,
"connected": self.connected,
"ok": realmente_ativo,
"connected": bool(self.connected),
"running": running,
"active_camera_ids": active_ids,
"active_roles": active_roles,
"camera_count_active": len(active_ids),
@ -43,6 +51,9 @@ class OakFcc3Service:
return status
def get_device_metrics(self):
return self.manager.get_device_metrics()
def get_config(self):
return {
"mx_id": self.manager.mx_id,

View File

@ -75,6 +75,9 @@ class CameraManager:
self._loop_publicacao_iniciado = False
self._vida_lock = threading.RLock()
self._camera_generation = 0
self._falhas_inicializacao = 0
self._proxima_inicializacao_monotonic = 0.0
self._fechando_camera = False
self._em_warmup = False
@ -111,6 +114,7 @@ class CameraManager:
self._tensor_pronto = None
self._tensor_res = None
self._tensor_ts = 0.0
self._tensor_generation = int(getattr(self, "_camera_generation", 0))
self._tensor_consumido_ts = 0.0
self.pipeline_ia_ok = False
@ -131,6 +135,7 @@ class CameraManager:
"infer_prepare_ms": 0.0,
"infer_post_ms": 0.0,
"fps_model": 0.0,
"generation": int(getattr(self, "_camera_generation", 0)),
}
self._pred_consumido_ts = 0.0
@ -196,39 +201,85 @@ class CameraManager:
"total_ms": 0.0,
}
def _avancar_geracao_camera(self):
self._camera_generation += 1
return self._camera_generation
def _sessao_camera_valida(self, camera, generation):
with self._vida_lock:
return bool(
camera is not None
and camera is self.camera
and int(generation) == int(self._camera_generation)
)
def _registrar_falha_inicializacao(self):
self._falhas_inicializacao += 1
atraso = min(10.0, 1.0 * (2 ** min(self._falhas_inicializacao - 1, 3)))
self._proxima_inicializacao_monotonic = time.monotonic() + atraso
return atraso
def inicializar(self, mx_id):
if mx_id is None:
return False
with self._vida_lock:
if self.iniciando:
if self.iniciando or self._fechando_camera:
return False
if (
self.camera is not None
and self.operante
and self.pipeline_ia_ok
):
return True
agora_mono = time.monotonic()
if agora_mono < self._proxima_inicializacao_monotonic:
return False
camera_existente = self.camera
if camera_existente is not None:
status_existente = camera_existente._safe_get_status()
ativa = bool(
camera_existente.iniciado
and status_existente.get("running", False)
)
if ativa and self.operante and self.pipeline_ia_ok:
return True
# Nunca tenta abrir outra instância sobre uma referência existente.
self.fechar_camera_manager(
motivo="reinicialização solicitada sobre câmera não saudável",
camera_esperada=camera_existente,
)
# Dá tempo para o runtime/USB liberar a sessão anterior.
time.sleep(1.0)
self.iniciando = True
self.pipeline_ia_ok = False
camera_nova = None
try:
self.mx_id = mx_id
self.mx_id = str(mx_id)
from weed_worker.config import load_seg_config
self.seg_config = load_seg_config()
self.posproc_intervalo_min_s = float(
self.seg_config.get("posproc_intervalo_min_s", 5.0)
)
self.posproc_intervalo_min_s = float(self.seg_config.get("posproc_intervalo_min_s", 5.0))
self._inicializar_camera(mx_id, self.seg_config)
if self.camera is None:
self.mostrar_log(f"❌ Camera com ID {mx_id} não iniciada.")
camera_nova = self._inicializar_camera(mx_id, self.seg_config)
if camera_nova is None:
atraso = self._registrar_falha_inicializacao()
self.mostrar_log(
f"❌ Camera com ID {mx_id} não iniciada. "
f"Nova tentativa após backoff de {atraso:.1f}s."
)
return False
self.mostrar_log(f"📷 Camera selecionada: {self.camera.modelo} - {self.camera.mx_id}")
self.camera = camera_nova
generation = self._avancar_geracao_camera()
self.mostrar_log(
f"📷 Camera selecionada: {camera_nova.modelo} - "
f"{camera_nova.mx_id} | geração={generation}"
)
self._reset_runtime_state()
@ -242,22 +293,34 @@ class CameraManager:
self.operante = False
self._em_warmup = True
warmup_ok = self._executar_warmup_camera_manager(n_ciclos=3, timeout_s=12.0)
warmup_ok = self._executar_warmup_camera_manager(
n_ciclos=3,
timeout_s=12.0,
)
self._em_warmup = False
if not warmup_ok:
self.mostrar_log("[weed][WARMUP] recuperação rejeitada: pipeline não concluiu validação ponta a ponta")
self.fechar_camera_manager("warmup incompleto após reconexão")
self.mostrar_log(
"[weed][WARMUP] recuperação rejeitada: "
"pipeline não concluiu validação ponta a ponta"
)
self.fechar_camera_manager(
"warmup incompleto após reconexão",
camera_esperada=camera_nova,
)
atraso = self._registrar_falha_inicializacao()
self.mostrar_log(f"[weed] backoff de reconexão: {atraso:.1f}s")
return False
if not self._sessao_camera_valida(camera_nova, generation):
return False
self.operante = True
self._iniciar_loops_se_necessario(self.seg_config)
# A avaliação final precisa acontecer depois que a inicialização acabou.
# Avaliação final precisa ocorrer fora do estado iniciando.
self.iniciando = False
pipeline_ia = self._avaliar_saude_pipeline_ia()
if not pipeline_ia.get("ok", False):
@ -265,23 +328,44 @@ class CameraManager:
"[weed] inicialização rejeitada após warmup: "
+ "; ".join(pipeline_ia.get("motivos", []))
)
self.fechar_camera_manager(motivo="pipeline IA inválido após warmup", camera_esperada=self.camera)
self.fechar_camera_manager(
motivo="pipeline IA inválido após warmup",
camera_esperada=camera_nova,
)
atraso = self._registrar_falha_inicializacao()
self.mostrar_log(f"[weed] backoff de reconexão: {atraso:.1f}s")
return False
self._falhas_inicializacao = 0
self._proxima_inicializacao_monotonic = 0.0
self.atualizar_saude_camera(pipeline_ia=pipeline_ia)
return True
except Exception as e:
self.mostrar_log(f"[weed] erro ao inicializar câmera: {e}")
self.fechar_camera_manager(motivo=f"falha de inicialização: {e}", camera_esperada=self.camera)
if self.camera is camera_nova and camera_nova is not None:
self.fechar_camera_manager(
motivo=f"falha de inicialização: {e}",
camera_esperada=camera_nova,
)
elif camera_nova is not None:
try:
camera_nova.parar()
except Exception:
pass
atraso = self._registrar_falha_inicializacao()
self.mostrar_log(f"[weed] backoff de reconexão: {atraso:.1f}s")
return False
finally:
self.iniciando = False
self._em_warmup = False
def _inicializar_camera(self, mx_id, seg_config):
nova = None
try:
nova = CameraMultispectral(
self.mostrar_log,
@ -289,18 +373,28 @@ class CameraManager:
module_calibration_json=seg_config.get("module_calibration_json"),
width=int(seg_config.get("camera_width", 1280)),
height=int(seg_config.get("camera_height", 800)),
fps=int(seg_config.get("camera_fps", 40)),
# Default conservador para campo. Se o JSON definir 40, ele será respeitado.
fps=int(seg_config.get("camera_fps", 20)),
target_size=seg_config.get("ia_resolution", [1024, 640]),
)
if nova.iniciado:
self.camera = nova
else:
self.camera = None
if not nova.iniciado:
try:
nova.parar()
except Exception:
pass
return None
return nova
except Exception as e:
self.camera = None
if nova is not None:
try:
nova.parar()
except Exception:
pass
self.mostrar_log(f"⚠️ Camera com ID {mx_id} não conectada: {e}")
return None
def _inicializar_modelo(self):
if self.model_svc is not None:
@ -408,42 +502,123 @@ class CameraManager:
def fechar_camera_manager(self, motivo="", camera_esperada=None):
with self._vida_lock:
# Um erro atrasado da câmera antiga jamais pode fechar a nova.
if (camera_esperada is not None and self.camera is not camera_esperada):
self.mostrar_log("[weed] fechamento ignorado: a câmera ativa já foi substituída")
if camera_esperada is not None and self.camera is not camera_esperada:
self.mostrar_log(
"[weed] fechamento ignorado: a câmera ativa já foi substituída"
)
return False
if self._fechando_camera:
return False
camera_alvo = self.camera
geracao_antiga = self._camera_generation
# Invalida primeiro todos os trabalhos em voo.
self._avancar_geracao_camera()
self.camera = None
self.operante = False
self.pipeline_ia_ok = False
self._fechando_camera = True
self._reset_runtime_state()
try:
self._forcar_bicos_off(motivo=f"câmera fechada: {motivo}", intervalo_min_s=0.0)
self._forcar_bicos_off(
motivo=f"câmera fechada: {motivo}",
intervalo_min_s=0.0,
)
except Exception:
pass
try:
if camera_alvo is not None:
camera_alvo.parar()
except Exception as e:
self.mostrar_log(f"[weed] Erro ao fechar câmera: {e}")
finally:
self._fechando_camera = False
self.mostrar_log(f"[weed] Camera Manager fechado: {motivo}")
# Evita reabrir enquanto o dispositivo ainda está reenlistando no USB.
self._proxima_inicializacao_monotonic = max(
self._proxima_inicializacao_monotonic,
time.monotonic() + 1.0,
)
self.mostrar_log(
f"[weed] Camera Manager fechado: {motivo} | "
f"geração={geracao_antiga}->{self._camera_generation}"
)
return True
def reiniciar_deteccoes(self):
self._reset_runtime_state()
return True, "estado operacional limpo"
"""Limpa somente estado da operação, sem tocar na OAK ou no ONNX."""
with self._vida_lock:
detector = self.weed_detector
# Descarta resultados pendentes, mas mantém saúde física e timestamps vivos.
with self._lock_tensor:
self._tensor_consumido_ts = self._tensor_ts
with self._pred_lock:
self._pred_cache = {
"ts": 0.0,
"tensor_ts": 0.0,
"predictions": None,
"res": None,
"infer_ms": 0.0,
"infer_gpu_ms": 0.0,
"infer_prepare_ms": 0.0,
"infer_post_ms": 0.0,
"fps_model": 0.0,
"generation": self._camera_generation,
}
self._pred_consumido_ts = 0.0
self._ultima_analise = {}
self._ultimo_predictions = None
self._ultimo_predictions_full = None
self._ultimo_controle = None
self._ultimo_cmd_bicos = None
with self._pub_lock:
for chave in ("analise", "controle_bicos", "performance_weed", "cmd_controle"):
self._pub_cache[chave] = None
for chave in (
"dirty_analise",
"dirty_controle_bicos",
"dirty_performance_weed",
"dirty_cmd_controle",
):
self._pub_cache[chave] = False
self._pub_cache["ts_analise"] = 0.0
reset_nome = None
if detector is not None:
for nome in (
"reiniciar_estado_operacional",
"reiniciar_deteccoes",
"reset_estado_operacional",
"reset",
):
fn = getattr(detector, nome, None)
if callable(fn):
fn()
reset_nome = nome
break
if detector is None or reset_nome is None:
# Fallback seguro: recria apenas o detector, nunca câmera/modelo.
self.weed_detector = WeedDetector()
if self.seg_config and hasattr(self.weed_detector, "atualizar_config"):
self.weed_detector.atualizar_config(self.seg_config)
reset_nome = "recriado"
self._forcar_bicos_off(
motivo="reinício do estado operacional das detecções",
intervalo_min_s=0.0,
)
return True, f"estado operacional limpo | detector={reset_nome}"
# ============================================================
# Warmup
@ -455,6 +630,7 @@ class CameraManager:
timeout_s=12.0,
):
camera_alvo = self.camera
generation = self._camera_generation
if camera_alvo is None:
return False
@ -476,7 +652,7 @@ class CameraManager:
try:
while ciclos_ok < n_ciclos and time.monotonic() < deadline:
if self.camera is not camera_alvo:
if not self._sessao_camera_valida(camera_alvo, generation):
self.mostrar_log(
"[weed][WARMUP] câmera foi substituída durante o warmup"
)
@ -498,6 +674,11 @@ class CameraManager:
self.mostrar_log(
f"[weed][WARMUP] captura inválida: {erro}"
)
try:
if camera_alvo._is_erro_fatal_depthai(erro):
return False
except Exception:
pass
time.sleep(0.05)
continue
@ -563,6 +744,10 @@ class CameraManager:
analise_completa = self.detectar_ervas(predictions)
t_det1 = time.perf_counter()
if not self._sessao_camera_valida(camera_alvo, generation):
self.perf.inc("warmup_descartado_geracao")
continue
if not isinstance(analise_completa, dict):
self.mostrar_log(
f"[weed][WARMUP] detecção inválida no frame {frame_id}"
@ -581,6 +766,7 @@ class CameraManager:
"pred_ts": agora,
"infer_full": infer_full,
"analise": analise_completa,
"generation": generation,
}
self.mostrar_log(
@ -611,6 +797,7 @@ class CameraManager:
self._tensor_pronto = ultimo_ciclo["tensor"]
self._tensor_res = ultimo_ciclo["res"]
self._tensor_ts = ultimo_ciclo["tensor_ts"]
self._tensor_generation = ultimo_ciclo["generation"]
self._tensor_consumido_ts = ultimo_ciclo["tensor_ts"]
with self._pred_lock:
@ -624,6 +811,7 @@ class CameraManager:
"infer_prepare_ms": 0.0,
"infer_post_ms": 0.0,
"fps_model": float(self._fps_infer_ema or 0.0),
"generation": ultimo_ciclo["generation"],
}
self._pred_consumido_ts = ultimo_ciclo["pred_ts"]
@ -666,15 +854,28 @@ class CameraManager:
self._ultima_saude_ts = time.time()
try:
if self.camera is not None:
if pipeline_ia is None:
pipeline_ia = self._avaliar_saude_pipeline_ia()
# Mantém a referência estável durante leitura/publicação da saúde.
# fechar_camera_manager aguarda esta seção curta terminar.
with self._vida_lock:
camera_atual = self.camera
self.camera.atualizar_saude(pipeline_ia=pipeline_ia)
if camera_atual is not None:
if pipeline_ia is None:
pipeline_ia = self._avaliar_saude_pipeline_ia()
camera_atual.atualizar_saude(pipeline_ia=pipeline_ia)
elif self.mx_id is not None:
from camera_worker.manager import definir_saude_camera
definir_saude_camera(self.mx_id, StatusModulo.DESCONECTADO, 0, ["desconectado"], False, {}, disp=T_Code.Cam, conectado=False)
elif self.mx_id is not None:
from camera_worker.manager import definir_saude_camera
definir_saude_camera(
self.mx_id,
StatusModulo.DESCONECTADO,
0,
["desconectado"],
False,
{},
disp=T_Code.Cam,
conectado=False,
)
except Exception as e:
self.mostrar_log(f"[saude] erro: {e}")
@ -790,23 +991,28 @@ class CameraManager:
tensor5 = self._tensor_pronto
res = self._tensor_res
ts_tensor = self._tensor_ts
generation = self._tensor_generation
if tensor5 is None or ts_tensor <= 0:
return None, None, None
return None, None, None, None
if int(generation) != int(self._camera_generation):
return None, None, res, generation
if ts_tensor == self._tensor_consumido_ts:
return None, None, res
return None, None, res, generation
self._tensor_consumido_ts = ts_tensor
return tensor5, ts_tensor, res
return tensor5, ts_tensor, res, generation
def _get_prediction_nova_para_deteccao(self):
with self._pred_lock:
cache = dict(self._pred_cache)
pred_ts = float(cache.get("ts", 0.0) or 0.0)
generation = int(cache.get("generation", -1))
if pred_ts <= 0:
if pred_ts <= 0 or generation != int(self._camera_generation):
return None
if pred_ts == self._pred_consumido_ts:
@ -1122,50 +1328,51 @@ class CameraManager:
t0_wall = time.time()
t0_perf = time.perf_counter()
camera_atual = None
generation = -1
try:
if (self.iniciando or self._em_warmup or not self.operante or self.camera is None):
if self.iniciando or self._em_warmup or not self.operante or self.camera is None:
time.sleep(0.05)
continue
camera_atual = self.camera
with self._vida_lock:
camera_atual = self.camera
generation = self._camera_generation
tensor5, res = (camera_atual.requisitar_tensor_multispec(force=True))
erro = (res.get("erro") if isinstance(res, dict) else None)
tensor5, res = camera_atual.requisitar_tensor_multispec(force=True)
erro = res.get("erro") if isinstance(res, dict) else None
if erro:
reiniciou = self._registrar_falha_captura(camera_atual, erro)
time.sleep(0.50 if reiniciou else 0.05)
continue
if tensor5 is None:
self.perf.inc("tensor_none")
reiniciou = self._registrar_falha_captura(camera_atual, "tensor None sem erro explícito")
reiniciou = self._registrar_falha_captura(
camera_atual,
"tensor None sem erro explícito",
)
time.sleep(0.50 if reiniciou else 0.05)
continue
# Uma captura válida limpa a sequência de falhas.
if not self._sessao_camera_valida(camera_atual, generation):
self.perf.inc("tensor_descartado_geracao")
continue
self._falhas_captura_consecutivas = 0
self._primeira_falha_captura_ts = 0.0
agora = time.time()
perf = (
res.get("perf", {})
if isinstance(res, dict)
else {}
)
perf = res.get("perf", {}) if isinstance(res, dict) else {}
with self._lock_tensor:
self._tensor_pronto = tensor5
self._tensor_res = res
self._tensor_ts = agora
self._tensor_generation = generation
self._ultimo_tensor_ok_ts = agora
t1_perf = time.perf_counter()
self.perf.tick(
@ -1179,25 +1386,23 @@ class CameraManager:
preview_ms=float(perf.get("preview_ms", 0.0) or 0.0),
frame_ts=agora,
idade_frame_ms=0.0,
generation=generation,
)
except Exception as e:
self.mostrar_log(f"[weed][CAPTURE] Erro inesperado: {type(e).__name__}: {e}")
self.mostrar_log(
f"[weed][CAPTURE] Erro inesperado: {type(e).__name__}: {e}"
)
if camera_atual is None:
camera_atual = self.camera
reiniciou = False
if camera_atual is not None:
reiniciou = self._registrar_falha_captura(camera_atual, e)
time.sleep(0.50 if reiniciou else 0.05)
finally:
gasto = time.time() - t0_wall
restante = periodo - gasto
if restante > 0:
time.sleep(restante)
@ -1215,21 +1420,26 @@ class CameraManager:
time.sleep(0.02)
continue
if not self.operante or self.camera is None:
with self._vida_lock:
camera_atual = self.camera
if not self.operante or camera_atual is None:
time.sleep(0.2)
continue
t_get0 = time.perf_counter()
tensor5, tensor_ts, res = self._get_tensor_novo_para_inferencia()
tensor5, tensor_ts, res, generation = self._get_tensor_novo_para_inferencia()
t_get1 = time.perf_counter()
if tensor5 is None or tensor_ts is None:
self.perf.inc("infer_sem_tensor_novo")
# Espera curta, sem aplicar novamente um período inteiro.
time.sleep(0.001)
continue
if not self._sessao_camera_valida(camera_atual, generation):
self.perf.inc("infer_descartada_geracao_antes")
continue
t_inf0 = time.perf_counter()
predictions = self.model_svc.infer_tensor_fast(
tensor5,
@ -1237,17 +1447,13 @@ class CameraManager:
)
t_inf1 = time.perf_counter()
infer_ms = (t_inf1 - t_inf0) * 1000.0
infer_full = getattr(
self.model_svc,
"_ultimo_predictions_full",
{},
) or {}
if not self._sessao_camera_valida(camera_atual, generation):
self.perf.inc("infer_descartada_geracao_depois")
continue
infer_forward_ms = infer_full.get(
"forward_ms",
infer_full.get("infer_ms", 0.0),
)
infer_ms = (t_inf1 - t_inf0) * 1000.0
infer_full = getattr(self.model_svc, "_ultimo_predictions_full", {}) or {}
infer_forward_ms = infer_full.get("forward_ms", infer_full.get("infer_ms", 0.0))
infer_prepare_ms = infer_full.get("prepare_ms", 0.0)
infer_post_ms = infer_full.get("post_ms", 0.0)
@ -1273,6 +1479,7 @@ class CameraManager:
"infer_prepare_ms": float(infer_prepare_ms or 0.0),
"infer_post_ms": float(infer_post_ms or 0.0),
"fps_model": float(self._fps_infer_ema or 0.0),
"generation": generation,
}
self._ultima_inferencia_ok_ts = pred_ts
@ -1282,7 +1489,6 @@ class CameraManager:
self._ultimo_predictions_full = infer_full
t_loop1 = time.perf_counter()
self.perf.tick(
"inferencia",
latencia_ms=(t_loop1 - t_loop0) * 1000.0,
@ -1294,26 +1500,20 @@ class CameraManager:
infer_gpu_ms=float(infer_forward_ms or 0.0),
tensor_ts=tensor_ts,
frame_ts=pred_ts,
idade_tensor_ms=(
(time.time() - tensor_ts) * 1000.0
if tensor_ts else None
),
idade_tensor_ms=(time.time() - tensor_ts) * 1000.0 if tensor_ts else None,
generation=generation,
)
# Só limita depois de uma inferência realmente executada.
gasto = time.perf_counter() - t_loop0
restante = periodo - gasto
if restante > 0:
time.sleep(restante)
except Exception as e:
self.mostrar_log(
f"❌ Erro no loop_inferencia weed: {e}"
)
self.mostrar_log(f"❌ Erro no loop_inferencia weed: {e}")
time.sleep(0.02)
threading.Thread(target=loop, daemon=True).start()
threading.Thread(target=loop, name="WeedInference", daemon=True).start()
def _iniciar_loop_deteccao_weed(self, freq=25.0):
def loop():
@ -1351,6 +1551,14 @@ class CameraManager:
predictions = pred_cache.get("predictions")
pred_ts = float(pred_cache.get("ts", 0.0) or 0.0)
tensor_ts = float(pred_cache.get("tensor_ts", 0.0) or 0.0)
generation = int(pred_cache.get("generation", -1))
with self._vida_lock:
camera_atual = self.camera
if not self._sessao_camera_valida(camera_atual, generation):
self.perf.inc("det_descartada_geracao_antes")
continue
if predictions is None:
self.perf.inc("det_predictions_none")
@ -1373,65 +1581,59 @@ class CameraManager:
analise_convertida = converter_valores_numpy(analise)
t_conv1 = time.perf_counter()
t_pub_analise0 = time.perf_counter()
self._set_pub_cache("analise", {
"ts_analise": pred_ts,
"fps_model": pred_cache.get("fps_model", 0.0),
"fps_inferencia": self._fps_infer_ema,
"infer_ms": pred_cache.get("infer_ms", 0.0),
"infer_gpu_ms": pred_cache.get("infer_gpu_ms", 0.0),
"analise": analise_convertida,
}, ts_origem=pred_ts)
t_pub_analise1 = time.perf_counter()
# ====================================================
# Gate de pulverização
# ====================================================
# O detector pode enxergar erva, mas só liberamos bicos se:
# - pulverizador automático ligado;
# - operação EmAndamento;
# - não finalizando/pausado/emergência/calibrando;
# - trajetória/status do carro = CaminhandoRua;
# - há comando/velocidade de movimento.
#
# Se qualquer item falhar, TODOS os bicos são OFF.
# Isso corrige o caso: operação ainda EmAndamento, mas Trajetoria.status=Parado.
t_ctrl0 = time.perf_counter()
controle_detectado = analise.get("controle") or {}
atuacao_bicos, debug_pulverizacao = self._aplicar_gate_pulverizacao(
controle_detectado
)
analise["controle"] = atuacao_bicos
analise["pulverizacao"] = debug_pulverizacao
self._ultimo_controle = atuacao_bicos
self._ultimo_cmd_bicos = atuacao_bicos
t_ctrl1 = time.perf_counter()
t_pub_ctrl0 = time.perf_counter()
self._set_pub_cache("controle_bicos", atuacao_bicos)
t_pub_ctrl1 = time.perf_counter()
# Publicação atômica em relação a fechar/substituir câmera.
# Se a geração mudou, nenhum dado antigo chega aos caches/bicos.
with self._vida_lock:
if not self._sessao_camera_valida(camera_atual, generation):
self.perf.inc("det_descartada_geracao_depois")
continue
t_pub_cmd0 = time.perf_counter()
self._set_pub_cache("cmd_controle", {
"cmd": WeedWorkerCommandType.EnviarDadosControle.value,
"params": atuacao_bicos,
"ts": time.time(),
"origem": "weed_worker",
"pulverizacao_permitida": bool(debug_pulverizacao.get("permitida", False)),
"motivo": debug_pulverizacao.get("motivo", ""),
"forcado_off": not bool(debug_pulverizacao.get("permitida", False)),
})
t_pub_cmd1 = time.perf_counter()
t_pub_analise0 = time.perf_counter()
self._set_pub_cache("analise", {
"ts_analise": pred_ts,
"fps_model": pred_cache.get("fps_model", 0.0),
"fps_inferencia": self._fps_infer_ema,
"infer_ms": pred_cache.get("infer_ms", 0.0),
"infer_gpu_ms": pred_cache.get("infer_gpu_ms", 0.0),
"analise": analise_convertida,
"generation": generation,
}, ts_origem=pred_ts)
t_pub_analise1 = time.perf_counter()
self._ultima_analise = analise_completa.copy()
self._ultimo_controle = atuacao_bicos
self._ultimo_cmd_bicos = atuacao_bicos
# Só considera o ciclo válido quando análise, gate e comando já foram preparados com sucesso.
self._ultima_deteccao_ok_ts = time.time()
t_pub_ctrl0 = time.perf_counter()
self._set_pub_cache("controle_bicos", atuacao_bicos)
t_pub_ctrl1 = time.perf_counter()
t_pub_cmd0 = time.perf_counter()
self._set_pub_cache("cmd_controle", {
"cmd": WeedWorkerCommandType.EnviarDadosControle.value,
"params": atuacao_bicos,
"ts": time.time(),
"origem": "weed_worker",
"pulverizacao_permitida": bool(debug_pulverizacao.get("permitida", False)),
"motivo": debug_pulverizacao.get("motivo", ""),
"forcado_off": not bool(debug_pulverizacao.get("permitida", False)),
"generation": generation,
})
t_pub_cmd1 = time.perf_counter()
self._ultima_analise = analise_completa.copy()
self._ultima_deteccao_ok_ts = time.time()
t_loop1 = time.perf_counter()
total_ms = (t_loop1 - t_loop0) * 1000.0
@ -1458,6 +1660,7 @@ class CameraManager:
"pred_age_ms": (time.time() - pred_ts) * 1000.0 if pred_ts else None,
"tensor_age_ms": (time.time() - tensor_ts) * 1000.0 if tensor_ts else None,
"pulverizacao": debug_pulverizacao,
"generation": generation,
}
pub_cache_ms = (
@ -2327,13 +2530,19 @@ class CameraManager:
)
continue
def salvar_pp_async(nome_frame_async, pasta_async):
with self._vida_lock:
camera_save = self.camera
generation_save = self._camera_generation
def salvar_pp_async(nome_frame_async, pasta_async, camera_esperada, generation_esperada):
try:
t_pp0 = time.perf_counter()
caminhos = self._salvar_bruto_multiespectral(
nome_base=nome_frame_async,
pasta=pasta_async
pasta=pasta_async,
camera_esperada=camera_esperada,
generation_esperada=generation_esperada,
)
t_pp1 = time.perf_counter()
@ -2355,7 +2564,7 @@ class CameraManager:
threading.Thread(
target=salvar_pp_async,
args=(nome_frame, pasta),
args=(nome_frame, pasta, camera_save, generation_save),
daemon=True
).start()
@ -2438,23 +2647,44 @@ class CameraManager:
except Exception:
return frame
def _salvar_bruto_multiespectral(self, nome_base: str, pasta: str):
def _salvar_bruto_multiespectral(
self,
nome_base: str,
pasta: str,
camera_esperada=None,
generation_esperada=None,
):
try:
if not hasattr(self, "camera") or self.camera is None:
with self._vida_lock:
camera_atual = self.camera
generation_atual = self._camera_generation
if camera_esperada is not None and camera_atual is not camera_esperada:
self.mostrar_log("[weed][SAVE_PP] cancelado: câmera foi substituída")
return []
if generation_esperada is not None and int(generation_atual) != int(generation_esperada):
self.mostrar_log("[weed][SAVE_PP] cancelado: geração da câmera mudou")
return []
if camera_atual is None:
self.mostrar_log("⚠️ CameraMultispectral indisponível para salvar RAW multispec.")
return []
if not hasattr(self.camera, "salvar_bundle_raw_multispec"):
self.mostrar_log("⚠️ CameraMultispectral ainda não possui salvar_bundle_raw_multispec().")
if not hasattr(camera_atual, "salvar_bundle_raw_multispec"):
self.mostrar_log(
"⚠️ CameraMultispectral ainda não possui salvar_bundle_raw_multispec()."
)
return []
return self.camera.salvar_bundle_raw_multispec(
return camera_atual.salvar_bundle_raw_multispec(
pasta=pasta,
nome=nome_base,
nota="pos_processamento_operacao",
extra_meta={
"origem_chamada": "weed_worker.salvar_frames",
"tipo_requisitado": "PosProcessamento",
"camera_generation": generation_atual,
},
)