robustez no camera manager do weed e multiesctral
This commit is contained in:
parent
a59bee710d
commit
4ee8a1e76a
|
|
@ -83,6 +83,7 @@ class CameraMultispectral:
|
||||||
|
|
||||||
self._vida_lock = threading.RLock()
|
self._vida_lock = threading.RLock()
|
||||||
self._parando = False
|
self._parando = False
|
||||||
|
self._falha_fatal_pendente = False
|
||||||
self.client = None
|
self.client = None
|
||||||
|
|
||||||
self._ultimo_resultado_tensor = {
|
self._ultimo_resultado_tensor = {
|
||||||
|
|
@ -452,15 +453,22 @@ class CameraMultispectral:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _falha_fatal_depthai(self, motivo):
|
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)
|
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
|
# Captura principal
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
@ -567,6 +575,7 @@ class CameraMultispectral:
|
||||||
t_preview_ms = 0.0
|
t_preview_ms = 0.0
|
||||||
|
|
||||||
ts = time.time()
|
ts = time.time()
|
||||||
|
ts_raw_perf = time.perf_counter()
|
||||||
dur = time.perf_counter() - t_total0
|
dur = time.perf_counter() - t_total0
|
||||||
|
|
||||||
meta = dict(meta or {})
|
meta = dict(meta or {})
|
||||||
|
|
@ -606,6 +615,24 @@ class CameraMultispectral:
|
||||||
self.timestamp_ultimo_tensor = ts
|
self.timestamp_ultimo_tensor = ts
|
||||||
self._ultimo_resultado_tensor = resultado
|
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.ultimo_frame_rgb = preview_bgr
|
||||||
self.timestamp_ultimo_frame_rgb = ts
|
self.timestamp_ultimo_frame_rgb = ts
|
||||||
self._ultimo_resultado_rgb = {
|
self._ultimo_resultado_rgb = {
|
||||||
|
|
@ -679,50 +706,28 @@ class CameraMultispectral:
|
||||||
}
|
}
|
||||||
|
|
||||||
def requisitar_frame_raw_multi(self, force: bool = False, max_age_s: float = None):
|
def requisitar_frame_raw_multi(self, force: bool = False, max_age_s: float = None):
|
||||||
"""
|
"""Retorna cópia do último RAW capturado pelo fluxo operacional."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
try:
|
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()
|
agora = time.perf_counter()
|
||||||
if max_age_s is None:
|
|
||||||
max_age_s = self._cache_max_age_s
|
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
cache_ok = (
|
if self.ultimo_raw_multi is None or self.timestamp_ultimo_raw_multi is None:
|
||||||
self.ultimo_raw_multi is not None
|
raise RuntimeError("cache RAW ainda não disponível")
|
||||||
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:
|
idade = agora - self.timestamp_ultimo_raw_multi
|
||||||
return self.ultimo_raw_multi, dict(self._ultimo_resultado_raw)
|
if idade > float(max_age_s):
|
||||||
|
raise RuntimeError(f"cache RAW antigo: {idade:.2f}s")
|
||||||
|
|
||||||
if self.client is None:
|
raw_frame = {
|
||||||
raise RuntimeError("OakFcc3Client não inicializado")
|
cam_id: arr.copy()
|
||||||
|
for cam_id, arr in self.ultimo_raw_multi.items()
|
||||||
t0 = time.perf_counter()
|
}
|
||||||
raw_frame, raw_meta = self.client.get_next_raw_frame(timeout=self.timeout_s)
|
resultado = dict(self._ultimo_resultado_raw)
|
||||||
ts = time.perf_counter()
|
resultado["cache_age_s"] = float(idade)
|
||||||
dur = ts - t0
|
resultado["force_ignorado"] = bool(force)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return raw_frame, resultado
|
return raw_frame, resultado
|
||||||
|
|
||||||
|
|
@ -732,11 +737,9 @@ class CameraMultispectral:
|
||||||
"duracao": 0.0,
|
"duracao": 0.0,
|
||||||
"frame_valido": False,
|
"frame_valido": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._ultimo_resultado_raw = resultado
|
self._ultimo_resultado_raw = resultado
|
||||||
|
self.mostrar_log(f"[CameraMultispectral] RAW cache indisponível: {e}")
|
||||||
self.mostrar_log(f"[CameraMultispectral] Erro ao requisitar RAW multi: {e}")
|
|
||||||
return None, resultado
|
return None, resultado
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
@ -744,33 +747,35 @@ class CameraMultispectral:
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def atualizar_saude(self, pipeline_ia=None):
|
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:
|
if self.imu is not None:
|
||||||
self.imu.atualizar_saude()
|
self.imu.atualizar_saude()
|
||||||
|
|
||||||
conectado_ctx = (ContextoGlobalRedis.get_cameras() or {}).get(self.mx_id) is not None
|
conectado_ctx = (ContextoGlobalRedis.get_cameras() or {}).get(self.mx_id) is not None
|
||||||
|
|
||||||
conectado = bool(self.iniciado and self.client is not None)
|
|
||||||
motivos = []
|
motivos = []
|
||||||
saude = 50
|
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 = {
|
performance = {
|
||||||
"temperatura": 0.0,
|
"temperatura": 0.0,
|
||||||
"memoria_usada": 0.0,
|
"memoria_usada": 0.0,
|
||||||
"executando": False,
|
"executando": running,
|
||||||
"velocidade": "",
|
"velocidade": "",
|
||||||
"sync_dt_ms": 0.0
|
"sync_dt_ms": 0.0,
|
||||||
|
"async_capture": status.get("async_capture") or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
if pipeline_ia is not None:
|
if pipeline_ia is not None:
|
||||||
self._ultimo_pipeline_ia = dict(pipeline_ia)
|
self._ultimo_pipeline_ia = dict(pipeline_ia)
|
||||||
pipeline_ia = dict(self._ultimo_pipeline_ia or {})
|
pipeline_ia = dict(self._ultimo_pipeline_ia or {})
|
||||||
pipeline_ia_informado = True
|
pipeline_ia_ok = bool(pipeline_ia.get("ok", False))
|
||||||
pipeline_ia_ok = (
|
|
||||||
not pipeline_ia_informado
|
|
||||||
or bool(pipeline_ia.get("ok", False))
|
|
||||||
)
|
|
||||||
performance["pipeline_ia"] = pipeline_ia
|
performance["pipeline_ia"] = pipeline_ia
|
||||||
|
|
||||||
imu = self.imu.get_dados() if self.imu else {
|
imu = self.imu.get_dados() if self.imu else {
|
||||||
|
|
@ -794,59 +799,55 @@ class CameraMultispectral:
|
||||||
resultado = dict(self._ultimo_resultado_tensor)
|
resultado = dict(self._ultimo_resultado_tensor)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = self._safe_get_status()
|
metrics = (
|
||||||
running = bool(status.get("running", False))
|
self.client.get_device_metrics()
|
||||||
performance["executando"] = running
|
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.
|
temp = float(metrics.get("temperature_average_c", 0.0) or 0.0)
|
||||||
dev = None
|
performance["temperatura"] = temp
|
||||||
try:
|
|
||||||
dev = self.client.svc.manager.device
|
|
||||||
except Exception:
|
|
||||||
dev = None
|
|
||||||
|
|
||||||
if dev is not None:
|
if temp >= 80:
|
||||||
try:
|
motivos.append(f"Temperatura crítica: {temp:.1f} °C")
|
||||||
temp = float(dev.getChipTemperature().average)
|
saude -= 30
|
||||||
performance["temperatura"] = temp
|
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:
|
ddr = float(metrics.get("ddr_used_bytes", 0) or 0) / 1024.0 / 1024.0
|
||||||
motivos.append(f"Temperatura crítica: {temp:.1f} °C")
|
performance["memoria_usada"] = ddr
|
||||||
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
|
|
||||||
|
|
||||||
try:
|
if ddr >= 500:
|
||||||
ddr = float(dev.getDdrMemoryUsage().used) / 1024.0 / 1024.0
|
motivos.append(f"Memória DDR crítica: {ddr:.1f} MB")
|
||||||
performance["memoria_usada"] = ddr
|
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:
|
speed = str(metrics.get("usb_speed", "") or "")
|
||||||
motivos.append(f"Memória DDR crítica: {ddr:.1f} MB")
|
performance["velocidade"] = speed
|
||||||
saude -= 30
|
if speed and speed.lower() not in ("super", "superplus"):
|
||||||
elif ddr >= 400:
|
motivos.append(f"USB lenta: {speed}")
|
||||||
motivos.append(f"Memória DDR elevada: {ddr:.1f} MB")
|
saude -= 15
|
||||||
saude -= 15
|
|
||||||
elif ddr >= 300:
|
|
||||||
motivos.append(f"Memória DDR acima do ideal: {ddr:.1f} MB")
|
|
||||||
saude -= 5
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
async_status = metrics.get("async_capture") or performance["async_capture"]
|
||||||
speed = dev.getUsbSpeed().name
|
performance["async_capture"] = async_status
|
||||||
performance["velocidade"] = speed
|
|
||||||
|
|
||||||
if str(speed).lower() not in ["super", "superplus"]:
|
if running and async_status:
|
||||||
motivos.append(f"USB lenta: {speed}")
|
if not bool(async_status.get("thread_alive", False)):
|
||||||
saude -= 15
|
motivos.append("Thread assíncrona de captura parada")
|
||||||
except Exception:
|
saude = 0
|
||||||
pass
|
erro_async = async_status.get("last_error")
|
||||||
|
if erro_async:
|
||||||
|
motivos.append(f"Captura assíncrona: {erro_async}")
|
||||||
|
|
||||||
if not running:
|
if not running:
|
||||||
motivos.append("Pipeline parada")
|
motivos.append("Pipeline parada")
|
||||||
|
|
@ -858,19 +859,17 @@ class CameraMultispectral:
|
||||||
"duracao": 0.0,
|
"duracao": 0.0,
|
||||||
"frame_valido": False,
|
"frame_valido": False,
|
||||||
}
|
}
|
||||||
|
if self._is_erro_fatal_depthai(e):
|
||||||
if "Communication exception" in str(e) or "X_LINK_ERROR" in str(e):
|
|
||||||
conectado = False
|
conectado = False
|
||||||
|
|
||||||
agora = time.time()
|
agora = time.time()
|
||||||
ts_tensor = self.timestamp_ultimo_tensor or 0.0
|
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:
|
||||||
try:
|
performance["sync_dt_ms"] = float(resultado.get("sync_dt_ms", 0.0) or 0.0)
|
||||||
performance["sync_dt_ms"] = float(resultado.get("sync_dt_ms", 0.0))
|
except Exception:
|
||||||
except Exception:
|
pass
|
||||||
pass
|
|
||||||
|
|
||||||
if not conectado and not conectado_ctx:
|
if not conectado and not conectado_ctx:
|
||||||
motivos.append("desconectado")
|
motivos.append("desconectado")
|
||||||
|
|
@ -901,34 +900,25 @@ class CameraMultispectral:
|
||||||
saude -= 10
|
saude -= 10
|
||||||
motivos.append(f"Sincronismo alto: {sync_dt:.1f} ms")
|
motivos.append(f"Sincronismo alto: {sync_dt:.1f} ms")
|
||||||
|
|
||||||
|
if not pipeline_ia_ok:
|
||||||
if pipeline_ia_informado and not pipeline_ia_ok:
|
for motivo in pipeline_ia.get("motivos", []) or []:
|
||||||
motivos_pipeline = pipeline_ia.get("motivos", []) or []
|
|
||||||
for motivo in motivos_pipeline:
|
|
||||||
texto = f"Pipeline IA: {motivo}"
|
texto = f"Pipeline IA: {motivo}"
|
||||||
if texto not in motivos:
|
if texto not in motivos:
|
||||||
motivos.append(texto)
|
motivos.append(texto)
|
||||||
saude = 0
|
saude = 0
|
||||||
|
|
||||||
|
motivos = list(dict.fromkeys(str(m) for m in motivos if m))
|
||||||
saude = min(max(saude, 0), 100)
|
saude = min(max(saude, 0), 100)
|
||||||
|
|
||||||
status_mod = StatusModulo.OPERANTE
|
status_mod = StatusModulo.OPERANTE
|
||||||
if not conectado:
|
if not conectado:
|
||||||
status_mod = StatusModulo.DESCONECTADO
|
status_mod = StatusModulo.DESCONECTADO
|
||||||
elif not frame_recente:
|
elif not frame_recente or not pipeline_ia_ok or saude <= 0:
|
||||||
status_mod = StatusModulo.FALHA
|
|
||||||
elif not pipeline_ia_ok:
|
|
||||||
status_mod = StatusModulo.FALHA
|
|
||||||
elif saude <= 0:
|
|
||||||
status_mod = StatusModulo.FALHA
|
status_mod = StatusModulo.FALHA
|
||||||
elif saude < 80:
|
elif saude < 80:
|
||||||
status_mod = StatusModulo.ALERTA
|
status_mod = StatusModulo.ALERTA
|
||||||
|
|
||||||
self.rodando = (
|
self.rodando = bool(conectado and frame_recente and pipeline_ia_ok)
|
||||||
conectado
|
|
||||||
and frame_recente
|
|
||||||
and pipeline_ia_ok
|
|
||||||
)
|
|
||||||
|
|
||||||
saude_geral = {
|
saude_geral = {
|
||||||
"timestamp": agora,
|
"timestamp": agora,
|
||||||
|
|
@ -938,7 +928,6 @@ class CameraMultispectral:
|
||||||
"motivos": motivos,
|
"motivos": motivos,
|
||||||
"saude_individual": [],
|
"saude_individual": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
self.ultima_saude = saude_geral
|
self.ultima_saude = saude_geral
|
||||||
|
|
||||||
from camera_worker.manager import definir_saude_camera
|
from camera_worker.manager import definir_saude_camera
|
||||||
|
|
@ -953,7 +942,7 @@ class CameraMultispectral:
|
||||||
conectado,
|
conectado,
|
||||||
agora,
|
agora,
|
||||||
self.dispositivo,
|
self.dispositivo,
|
||||||
imu=imu
|
imu=imu,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _definir_heartbeat(self):
|
def _definir_heartbeat(self):
|
||||||
|
|
@ -1175,129 +1164,54 @@ class CameraMultispectral:
|
||||||
|
|
||||||
def requisitar_bundle_raw_multispec(self, force: bool = True, max_age_s: float = None):
|
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:
|
Não chama client.get_next_raw_frame(), portanto não disputa o cursor
|
||||||
bundle, resultado
|
assíncrono com a inferência. force é mantido por compatibilidade.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
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:
|
raw_frame, resultado = self.requisitar_frame_raw_multi(
|
||||||
max_age_s = self._cache_max_age_s
|
force=False,
|
||||||
|
max_age_s=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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
with self._lock:
|
||||||
raise RuntimeError(
|
raw_meta = dict(self.ultimo_meta or {})
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
required = {"CAM_A", "CAM_B", "CAM_C"}
|
required = {"CAM_A", "CAM_B", "CAM_C"}
|
||||||
presentes = set(raw_frame.keys())
|
presentes = set(raw_frame.keys())
|
||||||
faltando = sorted(required - presentes)
|
faltando = sorted(required - presentes)
|
||||||
|
|
||||||
if faltando:
|
if faltando:
|
||||||
raise RuntimeError(
|
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(
|
preview_bgr, preview_method = self._build_preview_raw_multispec(
|
||||||
raw_frame=raw_frame_copy,
|
raw_frame=raw_frame,
|
||||||
raw_meta=raw_meta,
|
raw_meta=raw_meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
resultado = {
|
resultado = dict(resultado)
|
||||||
|
resultado.update({
|
||||||
"erro": None,
|
"erro": None,
|
||||||
"duracao": dur,
|
|
||||||
"frame_valido": True,
|
"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,
|
"preview_method": preview_method,
|
||||||
}
|
"origem": "cache_da_captura_operacional",
|
||||||
|
})
|
||||||
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
|
|
||||||
|
|
||||||
bundle = {
|
bundle = {
|
||||||
"raw_frame": raw_frame_copy,
|
"raw_frame": raw_frame,
|
||||||
"raw_meta": raw_meta,
|
"raw_meta": raw_meta,
|
||||||
"preview_bgr": preview_bgr,
|
"preview_bgr": preview_bgr,
|
||||||
"preview_method": preview_method,
|
"preview_method": preview_method,
|
||||||
}
|
}
|
||||||
|
|
||||||
return bundle, resultado
|
return bundle, resultado
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1306,17 +1220,11 @@ class CameraMultispectral:
|
||||||
"duracao": 0.0,
|
"duracao": 0.0,
|
||||||
"frame_valido": False,
|
"frame_valido": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._ultimo_resultado_raw = resultado
|
self._ultimo_resultado_raw = resultado
|
||||||
|
|
||||||
self.mostrar_log(
|
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
|
return None, resultado
|
||||||
|
|
||||||
def _build_preview_raw_multispec(self, raw_frame: dict, raw_meta: dict):
|
def _build_preview_raw_multispec(self, raw_frame: dict, raw_meta: dict):
|
||||||
|
|
|
||||||
|
|
@ -173,10 +173,11 @@ class OakFcc3Client:
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
try:
|
# disconnect() já para o manager. Evita manager.stop() duplicado.
|
||||||
self.svc.stop()
|
return self.svc.disconnect()
|
||||||
finally:
|
|
||||||
self.svc.disconnect()
|
def get_device_metrics(self):
|
||||||
|
return self.svc.get_device_metrics()
|
||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
return self.svc.get_status()
|
return self.svc.get_status()
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ class OakFcc3Manager:
|
||||||
|
|
||||||
self.running = False
|
self.running = False
|
||||||
self.frame_id = 0
|
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.control_queues = {}
|
||||||
self.camera_controls = {
|
self.camera_controls = {
|
||||||
cam_id: self._default_controls_for_role(role)
|
cam_id: self._default_controls_for_role(role)
|
||||||
|
|
@ -746,13 +750,20 @@ class OakFcc3Manager:
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
with self._device_lock:
|
||||||
|
return self._start_impl()
|
||||||
|
|
||||||
|
def _start_impl(self):
|
||||||
if self.running:
|
if self.running:
|
||||||
return
|
return
|
||||||
|
|
||||||
thread_anterior = self._capture_thread
|
thread_anterior = self._capture_thread
|
||||||
|
|
||||||
if (thread_anterior is not None and thread_anterior.is_alive()):
|
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.")
|
raise RuntimeError(
|
||||||
|
"Não é seguro iniciar novo pipeline: "
|
||||||
|
"thread OakFcc3AsyncCapture anterior ainda está viva."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.dev_info = self._resolve_device_info()
|
self.dev_info = self._resolve_device_info()
|
||||||
|
|
@ -781,7 +792,10 @@ class OakFcc3Manager:
|
||||||
|
|
||||||
role = self.roles.get(socket_name, "unknown")
|
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(
|
cam, output = self._create_camera_node_classic(
|
||||||
socket=socket,
|
socket=socket,
|
||||||
|
|
@ -813,9 +827,7 @@ class OakFcc3Manager:
|
||||||
}
|
}
|
||||||
|
|
||||||
self._validate_capture_mode()
|
self._validate_capture_mode()
|
||||||
|
|
||||||
self._create_imu_node(self.pipeline)
|
self._create_imu_node(self.pipeline)
|
||||||
|
|
||||||
self.device.startPipeline(self.pipeline)
|
self.device.startPipeline(self.pipeline)
|
||||||
|
|
||||||
self.q_imu = None
|
self.q_imu = None
|
||||||
|
|
@ -861,21 +873,30 @@ class OakFcc3Manager:
|
||||||
self._start_async_capture_thread()
|
self._start_async_capture_thread()
|
||||||
time.sleep(0.05)
|
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:
|
except Exception:
|
||||||
# Garante limpeza mesmo se a falha acontecer
|
# _device_lock é RLock, então a limpeza pode reutilizar stop().
|
||||||
# no meio da construção do pipeline.
|
|
||||||
try:
|
try:
|
||||||
self.stop()
|
self.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
with self._device_lock:
|
||||||
|
return self._stop_impl()
|
||||||
|
|
||||||
|
def _stop_impl(self):
|
||||||
# Impede novas capturas imediatamente.
|
# Impede novas capturas imediatamente.
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
self._stop_async_capture_thread()
|
thread_ok = self._stop_async_capture_thread()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.pipeline is not None and hasattr(self.pipeline, "stop"):
|
if self.pipeline is not None and hasattr(self.pipeline, "stop"):
|
||||||
|
|
@ -891,6 +912,7 @@ class OakFcc3Manager:
|
||||||
|
|
||||||
self.pipeline = None
|
self.pipeline = None
|
||||||
self.device = None
|
self.device = None
|
||||||
|
self.dev_info = None
|
||||||
|
|
||||||
self.queues.clear()
|
self.queues.clear()
|
||||||
self.buffers.clear()
|
self.buffers.clear()
|
||||||
|
|
@ -911,15 +933,23 @@ class OakFcc3Manager:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
return bool(thread_ok is not False)
|
||||||
|
|
||||||
def _is_fatal_depthai_error(self, erro):
|
def _is_fatal_depthai_error(self, erro):
|
||||||
txt = str(erro)
|
txt = str(erro).lower()
|
||||||
|
|
||||||
sinais = [
|
sinais = [
|
||||||
"X_LINK_ERROR",
|
"x_link_error",
|
||||||
"Communication exception",
|
"communication exception",
|
||||||
"Couldn't read data from stream",
|
"couldn't read data from stream",
|
||||||
"Device already closed",
|
"couldn't open stream",
|
||||||
|
"device already closed",
|
||||||
"device has been 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)
|
return any(s in txt for s in sinais)
|
||||||
|
|
@ -928,29 +958,86 @@ class OakFcc3Manager:
|
||||||
# Status
|
# 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):
|
def get_status(self):
|
||||||
return {
|
with self._device_lock:
|
||||||
"mx_id": self.mx_id,
|
return {
|
||||||
"backend": "oak_fcc3",
|
"mx_id": self.mx_id,
|
||||||
"running": self.running,
|
"backend": "oak_fcc3",
|
||||||
"fps": self.fps,
|
"running": bool(self.running),
|
||||||
"width": self.width,
|
"fps": self.fps,
|
||||||
"height": self.height,
|
"width": self.width,
|
||||||
"sensor_width": self.sensor_width,
|
"height": self.height,
|
||||||
"sensor_height": self.sensor_height,
|
"sensor_width": self.sensor_width,
|
||||||
"frame_type": self.frame_type,
|
"sensor_height": self.sensor_height,
|
||||||
"output_dtype": self.output_dtype,
|
"frame_type": self.frame_type,
|
||||||
"capture_mode": self.capture_mode,
|
"output_dtype": self.output_dtype,
|
||||||
"raw_policy": self.raw_policy,
|
"capture_mode": self.capture_mode,
|
||||||
"sync_tolerance_ms": self.sync_tolerance_ms,
|
"raw_policy": self.raw_policy,
|
||||||
"buffer_size": self.buffer_size,
|
"sync_tolerance_ms": self.sync_tolerance_ms,
|
||||||
"geometry_stage": "oak" if self._is_multispec_mode() else "pc",
|
"buffer_size": self.buffer_size,
|
||||||
"aligned_geometry": self._serializable_aligned_geometry(),
|
"geometry_stage": "oak" if self._is_multispec_mode() else "pc",
|
||||||
"cameras": list(self.camera_info.values()),
|
"aligned_geometry": self._serializable_aligned_geometry(),
|
||||||
"async_capture": self.get_async_capture_status() if hasattr(self, "get_async_capture_status") else None,
|
"cameras": list(self.camera_info.values()),
|
||||||
"tem_imu": bool(getattr(self, "tem_imu", False)),
|
"async_capture": self.get_async_capture_status(),
|
||||||
"has_imu_pipeline": bool(getattr(self, "has_imu_pipeline", False)),
|
"tem_imu": bool(getattr(self, "tem_imu", False)),
|
||||||
}
|
"has_imu_pipeline": bool(getattr(self, "has_imu_pipeline", False)),
|
||||||
|
}
|
||||||
|
|
||||||
def _serializable_aligned_geometry(self):
|
def _serializable_aligned_geometry(self):
|
||||||
if not isinstance(self.aligned_geometry, dict):
|
if not isinstance(self.aligned_geometry, dict):
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,12 @@ class OakFcc3Service:
|
||||||
return {"ok": True, "backend": "oak_fcc3", "connected": True}
|
return {"ok": True, "backend": "oak_fcc3", "connected": True}
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
self.stop()
|
# Único caminho de fechamento do manager.
|
||||||
self.connected = False
|
try:
|
||||||
|
self.manager.stop()
|
||||||
|
finally:
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
return {"ok": True, "connected": False}
|
return {"ok": True, "connected": False}
|
||||||
|
|
||||||
def ping(self):
|
def ping(self):
|
||||||
|
|
@ -33,9 +37,13 @@ class OakFcc3Service:
|
||||||
for c in status.get("cameras", [])
|
for c in status.get("cameras", [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
running = bool(status.get("running", False))
|
||||||
|
realmente_ativo = bool(self.connected and running)
|
||||||
|
|
||||||
status.update({
|
status.update({
|
||||||
"ok": True,
|
"ok": realmente_ativo,
|
||||||
"connected": self.connected,
|
"connected": bool(self.connected),
|
||||||
|
"running": running,
|
||||||
"active_camera_ids": active_ids,
|
"active_camera_ids": active_ids,
|
||||||
"active_roles": active_roles,
|
"active_roles": active_roles,
|
||||||
"camera_count_active": len(active_ids),
|
"camera_count_active": len(active_ids),
|
||||||
|
|
@ -43,6 +51,9 @@ class OakFcc3Service:
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
def get_device_metrics(self):
|
||||||
|
return self.manager.get_device_metrics()
|
||||||
|
|
||||||
def get_config(self):
|
def get_config(self):
|
||||||
return {
|
return {
|
||||||
"mx_id": self.manager.mx_id,
|
"mx_id": self.manager.mx_id,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ class CameraManager:
|
||||||
self._loop_publicacao_iniciado = False
|
self._loop_publicacao_iniciado = False
|
||||||
|
|
||||||
self._vida_lock = threading.RLock()
|
self._vida_lock = threading.RLock()
|
||||||
|
self._camera_generation = 0
|
||||||
|
self._falhas_inicializacao = 0
|
||||||
|
self._proxima_inicializacao_monotonic = 0.0
|
||||||
self._fechando_camera = False
|
self._fechando_camera = False
|
||||||
self._em_warmup = False
|
self._em_warmup = False
|
||||||
|
|
||||||
|
|
@ -111,6 +114,7 @@ class CameraManager:
|
||||||
self._tensor_pronto = None
|
self._tensor_pronto = None
|
||||||
self._tensor_res = None
|
self._tensor_res = None
|
||||||
self._tensor_ts = 0.0
|
self._tensor_ts = 0.0
|
||||||
|
self._tensor_generation = int(getattr(self, "_camera_generation", 0))
|
||||||
self._tensor_consumido_ts = 0.0
|
self._tensor_consumido_ts = 0.0
|
||||||
|
|
||||||
self.pipeline_ia_ok = False
|
self.pipeline_ia_ok = False
|
||||||
|
|
@ -131,6 +135,7 @@ class CameraManager:
|
||||||
"infer_prepare_ms": 0.0,
|
"infer_prepare_ms": 0.0,
|
||||||
"infer_post_ms": 0.0,
|
"infer_post_ms": 0.0,
|
||||||
"fps_model": 0.0,
|
"fps_model": 0.0,
|
||||||
|
"generation": int(getattr(self, "_camera_generation", 0)),
|
||||||
}
|
}
|
||||||
self._pred_consumido_ts = 0.0
|
self._pred_consumido_ts = 0.0
|
||||||
|
|
||||||
|
|
@ -196,39 +201,85 @@ class CameraManager:
|
||||||
"total_ms": 0.0,
|
"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):
|
def inicializar(self, mx_id):
|
||||||
if mx_id is None:
|
if mx_id is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
with self._vida_lock:
|
with self._vida_lock:
|
||||||
if self.iniciando:
|
if self.iniciando or self._fechando_camera:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if (
|
agora_mono = time.monotonic()
|
||||||
self.camera is not None
|
if agora_mono < self._proxima_inicializacao_monotonic:
|
||||||
and self.operante
|
return False
|
||||||
and self.pipeline_ia_ok
|
|
||||||
):
|
camera_existente = self.camera
|
||||||
return True
|
|
||||||
|
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.iniciando = True
|
||||||
self.pipeline_ia_ok = False
|
self.pipeline_ia_ok = False
|
||||||
|
camera_nova = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.mx_id = mx_id
|
self.mx_id = str(mx_id)
|
||||||
|
|
||||||
from weed_worker.config import load_seg_config
|
from weed_worker.config import load_seg_config
|
||||||
self.seg_config = 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))
|
camera_nova = self._inicializar_camera(mx_id, self.seg_config)
|
||||||
|
if camera_nova is None:
|
||||||
self._inicializar_camera(mx_id, self.seg_config)
|
atraso = self._registrar_falha_inicializacao()
|
||||||
|
self.mostrar_log(
|
||||||
if self.camera is None:
|
f"❌ Camera com ID {mx_id} não iniciada. "
|
||||||
self.mostrar_log(f"❌ Camera com ID {mx_id} não iniciada.")
|
f"Nova tentativa após backoff de {atraso:.1f}s."
|
||||||
|
)
|
||||||
return False
|
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()
|
self._reset_runtime_state()
|
||||||
|
|
||||||
|
|
@ -242,22 +293,34 @@ class CameraManager:
|
||||||
self.operante = False
|
self.operante = False
|
||||||
self._em_warmup = True
|
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
|
self._em_warmup = False
|
||||||
|
|
||||||
if not warmup_ok:
|
if not warmup_ok:
|
||||||
self.mostrar_log("[weed][WARMUP] recuperação rejeitada: pipeline não concluiu validação ponta a ponta")
|
self.mostrar_log(
|
||||||
self.fechar_camera_manager("warmup incompleto após reconexão")
|
"[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
|
return False
|
||||||
|
|
||||||
self.operante = True
|
self.operante = True
|
||||||
|
|
||||||
self._iniciar_loops_se_necessario(self.seg_config)
|
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
|
self.iniciando = False
|
||||||
|
|
||||||
pipeline_ia = self._avaliar_saude_pipeline_ia()
|
pipeline_ia = self._avaliar_saude_pipeline_ia()
|
||||||
|
|
||||||
if not pipeline_ia.get("ok", False):
|
if not pipeline_ia.get("ok", False):
|
||||||
|
|
@ -265,23 +328,44 @@ class CameraManager:
|
||||||
"[weed] inicialização rejeitada após warmup: "
|
"[weed] inicialização rejeitada após warmup: "
|
||||||
+ "; ".join(pipeline_ia.get("motivos", []))
|
+ "; ".join(pipeline_ia.get("motivos", []))
|
||||||
)
|
)
|
||||||
|
self.fechar_camera_manager(
|
||||||
self.fechar_camera_manager(motivo="pipeline IA inválido após warmup", camera_esperada=self.camera)
|
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
|
return False
|
||||||
|
|
||||||
|
self._falhas_inicializacao = 0
|
||||||
|
self._proxima_inicializacao_monotonic = 0.0
|
||||||
self.atualizar_saude_camera(pipeline_ia=pipeline_ia)
|
self.atualizar_saude_camera(pipeline_ia=pipeline_ia)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.mostrar_log(f"[weed] erro ao inicializar câmera: {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
|
return False
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self.iniciando = False
|
self.iniciando = False
|
||||||
|
self._em_warmup = False
|
||||||
|
|
||||||
def _inicializar_camera(self, mx_id, seg_config):
|
def _inicializar_camera(self, mx_id, seg_config):
|
||||||
|
nova = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nova = CameraMultispectral(
|
nova = CameraMultispectral(
|
||||||
self.mostrar_log,
|
self.mostrar_log,
|
||||||
|
|
@ -289,18 +373,28 @@ class CameraManager:
|
||||||
module_calibration_json=seg_config.get("module_calibration_json"),
|
module_calibration_json=seg_config.get("module_calibration_json"),
|
||||||
width=int(seg_config.get("camera_width", 1280)),
|
width=int(seg_config.get("camera_width", 1280)),
|
||||||
height=int(seg_config.get("camera_height", 800)),
|
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]),
|
target_size=seg_config.get("ia_resolution", [1024, 640]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if nova.iniciado:
|
if not nova.iniciado:
|
||||||
self.camera = nova
|
try:
|
||||||
else:
|
nova.parar()
|
||||||
self.camera = None
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
return nova
|
||||||
|
|
||||||
except Exception as e:
|
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}")
|
self.mostrar_log(f"⚠️ Camera com ID {mx_id} não conectada: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _inicializar_modelo(self):
|
def _inicializar_modelo(self):
|
||||||
if self.model_svc is not None:
|
if self.model_svc is not None:
|
||||||
|
|
@ -408,42 +502,123 @@ class CameraManager:
|
||||||
|
|
||||||
def fechar_camera_manager(self, motivo="", camera_esperada=None):
|
def fechar_camera_manager(self, motivo="", camera_esperada=None):
|
||||||
with self._vida_lock:
|
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:
|
||||||
if (camera_esperada is not None and self.camera is not camera_esperada):
|
self.mostrar_log(
|
||||||
self.mostrar_log("[weed] fechamento ignorado: a câmera ativa já foi substituída")
|
"[weed] fechamento ignorado: a câmera ativa já foi substituída"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._fechando_camera:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
camera_alvo = self.camera
|
camera_alvo = self.camera
|
||||||
|
geracao_antiga = self._camera_generation
|
||||||
|
|
||||||
|
# Invalida primeiro todos os trabalhos em voo.
|
||||||
|
self._avancar_geracao_camera()
|
||||||
self.camera = None
|
self.camera = None
|
||||||
self.operante = False
|
self.operante = False
|
||||||
self.pipeline_ia_ok = False
|
self.pipeline_ia_ok = False
|
||||||
self._fechando_camera = True
|
self._fechando_camera = True
|
||||||
|
|
||||||
self._reset_runtime_state()
|
self._reset_runtime_state()
|
||||||
|
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if camera_alvo is not None:
|
if camera_alvo is not None:
|
||||||
camera_alvo.parar()
|
camera_alvo.parar()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.mostrar_log(f"[weed] Erro ao fechar câmera: {e}")
|
self.mostrar_log(f"[weed] Erro ao fechar câmera: {e}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._fechando_camera = False
|
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
|
return True
|
||||||
|
|
||||||
def reiniciar_deteccoes(self):
|
def reiniciar_deteccoes(self):
|
||||||
self._reset_runtime_state()
|
"""Limpa somente estado da operação, sem tocar na OAK ou no ONNX."""
|
||||||
return True, "estado operacional limpo"
|
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
|
# Warmup
|
||||||
|
|
@ -455,6 +630,7 @@ class CameraManager:
|
||||||
timeout_s=12.0,
|
timeout_s=12.0,
|
||||||
):
|
):
|
||||||
camera_alvo = self.camera
|
camera_alvo = self.camera
|
||||||
|
generation = self._camera_generation
|
||||||
|
|
||||||
if camera_alvo is None:
|
if camera_alvo is None:
|
||||||
return False
|
return False
|
||||||
|
|
@ -476,7 +652,7 @@ class CameraManager:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while ciclos_ok < n_ciclos and time.monotonic() < deadline:
|
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(
|
self.mostrar_log(
|
||||||
"[weed][WARMUP] câmera foi substituída durante o warmup"
|
"[weed][WARMUP] câmera foi substituída durante o warmup"
|
||||||
)
|
)
|
||||||
|
|
@ -498,6 +674,11 @@ class CameraManager:
|
||||||
self.mostrar_log(
|
self.mostrar_log(
|
||||||
f"[weed][WARMUP] captura inválida: {erro}"
|
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)
|
time.sleep(0.05)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -563,6 +744,10 @@ class CameraManager:
|
||||||
analise_completa = self.detectar_ervas(predictions)
|
analise_completa = self.detectar_ervas(predictions)
|
||||||
t_det1 = time.perf_counter()
|
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):
|
if not isinstance(analise_completa, dict):
|
||||||
self.mostrar_log(
|
self.mostrar_log(
|
||||||
f"[weed][WARMUP] detecção inválida no frame {frame_id}"
|
f"[weed][WARMUP] detecção inválida no frame {frame_id}"
|
||||||
|
|
@ -581,6 +766,7 @@ class CameraManager:
|
||||||
"pred_ts": agora,
|
"pred_ts": agora,
|
||||||
"infer_full": infer_full,
|
"infer_full": infer_full,
|
||||||
"analise": analise_completa,
|
"analise": analise_completa,
|
||||||
|
"generation": generation,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.mostrar_log(
|
self.mostrar_log(
|
||||||
|
|
@ -611,6 +797,7 @@ class CameraManager:
|
||||||
self._tensor_pronto = ultimo_ciclo["tensor"]
|
self._tensor_pronto = ultimo_ciclo["tensor"]
|
||||||
self._tensor_res = ultimo_ciclo["res"]
|
self._tensor_res = ultimo_ciclo["res"]
|
||||||
self._tensor_ts = ultimo_ciclo["tensor_ts"]
|
self._tensor_ts = ultimo_ciclo["tensor_ts"]
|
||||||
|
self._tensor_generation = ultimo_ciclo["generation"]
|
||||||
self._tensor_consumido_ts = ultimo_ciclo["tensor_ts"]
|
self._tensor_consumido_ts = ultimo_ciclo["tensor_ts"]
|
||||||
|
|
||||||
with self._pred_lock:
|
with self._pred_lock:
|
||||||
|
|
@ -624,6 +811,7 @@ class CameraManager:
|
||||||
"infer_prepare_ms": 0.0,
|
"infer_prepare_ms": 0.0,
|
||||||
"infer_post_ms": 0.0,
|
"infer_post_ms": 0.0,
|
||||||
"fps_model": float(self._fps_infer_ema or 0.0),
|
"fps_model": float(self._fps_infer_ema or 0.0),
|
||||||
|
"generation": ultimo_ciclo["generation"],
|
||||||
}
|
}
|
||||||
self._pred_consumido_ts = ultimo_ciclo["pred_ts"]
|
self._pred_consumido_ts = ultimo_ciclo["pred_ts"]
|
||||||
|
|
||||||
|
|
@ -666,15 +854,28 @@ class CameraManager:
|
||||||
self._ultima_saude_ts = time.time()
|
self._ultima_saude_ts = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.camera is not None:
|
# Mantém a referência estável durante leitura/publicação da saúde.
|
||||||
if pipeline_ia is None:
|
# fechar_camera_manager aguarda esta seção curta terminar.
|
||||||
pipeline_ia = self._avaliar_saude_pipeline_ia()
|
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:
|
elif self.mx_id is not None:
|
||||||
from camera_worker.manager import definir_saude_camera
|
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)
|
definir_saude_camera(
|
||||||
|
self.mx_id,
|
||||||
|
StatusModulo.DESCONECTADO,
|
||||||
|
0,
|
||||||
|
["desconectado"],
|
||||||
|
False,
|
||||||
|
{},
|
||||||
|
disp=T_Code.Cam,
|
||||||
|
conectado=False,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.mostrar_log(f"[saude] erro: {e}")
|
self.mostrar_log(f"[saude] erro: {e}")
|
||||||
|
|
@ -790,23 +991,28 @@ class CameraManager:
|
||||||
tensor5 = self._tensor_pronto
|
tensor5 = self._tensor_pronto
|
||||||
res = self._tensor_res
|
res = self._tensor_res
|
||||||
ts_tensor = self._tensor_ts
|
ts_tensor = self._tensor_ts
|
||||||
|
generation = self._tensor_generation
|
||||||
|
|
||||||
if tensor5 is None or ts_tensor <= 0:
|
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:
|
if ts_tensor == self._tensor_consumido_ts:
|
||||||
return None, None, res
|
return None, None, res, generation
|
||||||
|
|
||||||
self._tensor_consumido_ts = ts_tensor
|
self._tensor_consumido_ts = ts_tensor
|
||||||
return tensor5, ts_tensor, res
|
return tensor5, ts_tensor, res, generation
|
||||||
|
|
||||||
def _get_prediction_nova_para_deteccao(self):
|
def _get_prediction_nova_para_deteccao(self):
|
||||||
with self._pred_lock:
|
with self._pred_lock:
|
||||||
cache = dict(self._pred_cache)
|
cache = dict(self._pred_cache)
|
||||||
|
|
||||||
pred_ts = float(cache.get("ts", 0.0) or 0.0)
|
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
|
return None
|
||||||
|
|
||||||
if pred_ts == self._pred_consumido_ts:
|
if pred_ts == self._pred_consumido_ts:
|
||||||
|
|
@ -1122,50 +1328,51 @@ class CameraManager:
|
||||||
t0_wall = time.time()
|
t0_wall = time.time()
|
||||||
t0_perf = time.perf_counter()
|
t0_perf = time.perf_counter()
|
||||||
camera_atual = None
|
camera_atual = None
|
||||||
|
generation = -1
|
||||||
|
|
||||||
try:
|
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)
|
time.sleep(0.05)
|
||||||
continue
|
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))
|
tensor5, res = camera_atual.requisitar_tensor_multispec(force=True)
|
||||||
|
erro = res.get("erro") if isinstance(res, dict) else None
|
||||||
erro = (res.get("erro") if isinstance(res, dict) else None)
|
|
||||||
|
|
||||||
if erro:
|
if erro:
|
||||||
reiniciou = self._registrar_falha_captura(camera_atual, erro)
|
reiniciou = self._registrar_falha_captura(camera_atual, erro)
|
||||||
|
|
||||||
time.sleep(0.50 if reiniciou else 0.05)
|
time.sleep(0.50 if reiniciou else 0.05)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if tensor5 is None:
|
if tensor5 is None:
|
||||||
self.perf.inc("tensor_none")
|
self.perf.inc("tensor_none")
|
||||||
|
reiniciou = self._registrar_falha_captura(
|
||||||
reiniciou = self._registrar_falha_captura(camera_atual, "tensor None sem erro explícito")
|
camera_atual,
|
||||||
|
"tensor None sem erro explícito",
|
||||||
|
)
|
||||||
time.sleep(0.50 if reiniciou else 0.05)
|
time.sleep(0.50 if reiniciou else 0.05)
|
||||||
continue
|
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._falhas_captura_consecutivas = 0
|
||||||
self._primeira_falha_captura_ts = 0.0
|
self._primeira_falha_captura_ts = 0.0
|
||||||
|
|
||||||
agora = time.time()
|
agora = time.time()
|
||||||
perf = (
|
perf = res.get("perf", {}) if isinstance(res, dict) else {}
|
||||||
res.get("perf", {})
|
|
||||||
if isinstance(res, dict)
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
|
|
||||||
with self._lock_tensor:
|
with self._lock_tensor:
|
||||||
self._tensor_pronto = tensor5
|
self._tensor_pronto = tensor5
|
||||||
self._tensor_res = res
|
self._tensor_res = res
|
||||||
self._tensor_ts = agora
|
self._tensor_ts = agora
|
||||||
|
self._tensor_generation = generation
|
||||||
|
|
||||||
self._ultimo_tensor_ok_ts = agora
|
self._ultimo_tensor_ok_ts = agora
|
||||||
|
|
||||||
t1_perf = time.perf_counter()
|
t1_perf = time.perf_counter()
|
||||||
|
|
||||||
self.perf.tick(
|
self.perf.tick(
|
||||||
|
|
@ -1179,25 +1386,23 @@ class CameraManager:
|
||||||
preview_ms=float(perf.get("preview_ms", 0.0) or 0.0),
|
preview_ms=float(perf.get("preview_ms", 0.0) or 0.0),
|
||||||
frame_ts=agora,
|
frame_ts=agora,
|
||||||
idade_frame_ms=0.0,
|
idade_frame_ms=0.0,
|
||||||
|
generation=generation,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
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:
|
if camera_atual is None:
|
||||||
camera_atual = self.camera
|
camera_atual = self.camera
|
||||||
|
|
||||||
reiniciou = False
|
reiniciou = False
|
||||||
|
|
||||||
if camera_atual is not None:
|
if camera_atual is not None:
|
||||||
reiniciou = self._registrar_falha_captura(camera_atual, e)
|
reiniciou = self._registrar_falha_captura(camera_atual, e)
|
||||||
|
|
||||||
time.sleep(0.50 if reiniciou else 0.05)
|
time.sleep(0.50 if reiniciou else 0.05)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
gasto = time.time() - t0_wall
|
gasto = time.time() - t0_wall
|
||||||
restante = periodo - gasto
|
restante = periodo - gasto
|
||||||
|
|
||||||
if restante > 0:
|
if restante > 0:
|
||||||
time.sleep(restante)
|
time.sleep(restante)
|
||||||
|
|
||||||
|
|
@ -1215,21 +1420,26 @@ class CameraManager:
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
continue
|
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)
|
time.sleep(0.2)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
t_get0 = time.perf_counter()
|
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()
|
t_get1 = time.perf_counter()
|
||||||
|
|
||||||
if tensor5 is None or tensor_ts is None:
|
if tensor5 is None or tensor_ts is None:
|
||||||
self.perf.inc("infer_sem_tensor_novo")
|
self.perf.inc("infer_sem_tensor_novo")
|
||||||
|
|
||||||
# Espera curta, sem aplicar novamente um período inteiro.
|
|
||||||
time.sleep(0.001)
|
time.sleep(0.001)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not self._sessao_camera_valida(camera_atual, generation):
|
||||||
|
self.perf.inc("infer_descartada_geracao_antes")
|
||||||
|
continue
|
||||||
|
|
||||||
t_inf0 = time.perf_counter()
|
t_inf0 = time.perf_counter()
|
||||||
predictions = self.model_svc.infer_tensor_fast(
|
predictions = self.model_svc.infer_tensor_fast(
|
||||||
tensor5,
|
tensor5,
|
||||||
|
|
@ -1237,17 +1447,13 @@ class CameraManager:
|
||||||
)
|
)
|
||||||
t_inf1 = time.perf_counter()
|
t_inf1 = time.perf_counter()
|
||||||
|
|
||||||
infer_ms = (t_inf1 - t_inf0) * 1000.0
|
if not self._sessao_camera_valida(camera_atual, generation):
|
||||||
infer_full = getattr(
|
self.perf.inc("infer_descartada_geracao_depois")
|
||||||
self.model_svc,
|
continue
|
||||||
"_ultimo_predictions_full",
|
|
||||||
{},
|
|
||||||
) or {}
|
|
||||||
|
|
||||||
infer_forward_ms = infer_full.get(
|
infer_ms = (t_inf1 - t_inf0) * 1000.0
|
||||||
"forward_ms",
|
infer_full = getattr(self.model_svc, "_ultimo_predictions_full", {}) or {}
|
||||||
infer_full.get("infer_ms", 0.0),
|
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_prepare_ms = infer_full.get("prepare_ms", 0.0)
|
||||||
infer_post_ms = infer_full.get("post_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_prepare_ms": float(infer_prepare_ms or 0.0),
|
||||||
"infer_post_ms": float(infer_post_ms or 0.0),
|
"infer_post_ms": float(infer_post_ms or 0.0),
|
||||||
"fps_model": float(self._fps_infer_ema or 0.0),
|
"fps_model": float(self._fps_infer_ema or 0.0),
|
||||||
|
"generation": generation,
|
||||||
}
|
}
|
||||||
self._ultima_inferencia_ok_ts = pred_ts
|
self._ultima_inferencia_ok_ts = pred_ts
|
||||||
|
|
||||||
|
|
@ -1282,7 +1489,6 @@ class CameraManager:
|
||||||
self._ultimo_predictions_full = infer_full
|
self._ultimo_predictions_full = infer_full
|
||||||
|
|
||||||
t_loop1 = time.perf_counter()
|
t_loop1 = time.perf_counter()
|
||||||
|
|
||||||
self.perf.tick(
|
self.perf.tick(
|
||||||
"inferencia",
|
"inferencia",
|
||||||
latencia_ms=(t_loop1 - t_loop0) * 1000.0,
|
latencia_ms=(t_loop1 - t_loop0) * 1000.0,
|
||||||
|
|
@ -1294,26 +1500,20 @@ class CameraManager:
|
||||||
infer_gpu_ms=float(infer_forward_ms or 0.0),
|
infer_gpu_ms=float(infer_forward_ms or 0.0),
|
||||||
tensor_ts=tensor_ts,
|
tensor_ts=tensor_ts,
|
||||||
frame_ts=pred_ts,
|
frame_ts=pred_ts,
|
||||||
idade_tensor_ms=(
|
idade_tensor_ms=(time.time() - tensor_ts) * 1000.0 if tensor_ts else None,
|
||||||
(time.time() - tensor_ts) * 1000.0
|
generation=generation,
|
||||||
if tensor_ts else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Só limita depois de uma inferência realmente executada.
|
|
||||||
gasto = time.perf_counter() - t_loop0
|
gasto = time.perf_counter() - t_loop0
|
||||||
restante = periodo - gasto
|
restante = periodo - gasto
|
||||||
|
|
||||||
if restante > 0:
|
if restante > 0:
|
||||||
time.sleep(restante)
|
time.sleep(restante)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.mostrar_log(
|
self.mostrar_log(f"❌ Erro no loop_inferencia weed: {e}")
|
||||||
f"❌ Erro no loop_inferencia weed: {e}"
|
|
||||||
)
|
|
||||||
time.sleep(0.02)
|
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 _iniciar_loop_deteccao_weed(self, freq=25.0):
|
||||||
def loop():
|
def loop():
|
||||||
|
|
@ -1351,6 +1551,14 @@ class CameraManager:
|
||||||
predictions = pred_cache.get("predictions")
|
predictions = pred_cache.get("predictions")
|
||||||
pred_ts = float(pred_cache.get("ts", 0.0) or 0.0)
|
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)
|
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:
|
if predictions is None:
|
||||||
self.perf.inc("det_predictions_none")
|
self.perf.inc("det_predictions_none")
|
||||||
|
|
@ -1373,65 +1581,59 @@ class CameraManager:
|
||||||
analise_convertida = converter_valores_numpy(analise)
|
analise_convertida = converter_valores_numpy(analise)
|
||||||
t_conv1 = time.perf_counter()
|
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
|
# 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()
|
t_ctrl0 = time.perf_counter()
|
||||||
|
|
||||||
controle_detectado = analise.get("controle") or {}
|
controle_detectado = analise.get("controle") or {}
|
||||||
|
|
||||||
atuacao_bicos, debug_pulverizacao = self._aplicar_gate_pulverizacao(
|
atuacao_bicos, debug_pulverizacao = self._aplicar_gate_pulverizacao(
|
||||||
controle_detectado
|
controle_detectado
|
||||||
)
|
)
|
||||||
|
|
||||||
analise["controle"] = atuacao_bicos
|
analise["controle"] = atuacao_bicos
|
||||||
analise["pulverizacao"] = debug_pulverizacao
|
analise["pulverizacao"] = debug_pulverizacao
|
||||||
|
|
||||||
self._ultimo_controle = atuacao_bicos
|
|
||||||
self._ultimo_cmd_bicos = atuacao_bicos
|
|
||||||
t_ctrl1 = time.perf_counter()
|
t_ctrl1 = time.perf_counter()
|
||||||
|
|
||||||
t_pub_ctrl0 = time.perf_counter()
|
# Publicação atômica em relação a fechar/substituir câmera.
|
||||||
self._set_pub_cache("controle_bicos", atuacao_bicos)
|
# Se a geração mudou, nenhum dado antigo chega aos caches/bicos.
|
||||||
t_pub_ctrl1 = time.perf_counter()
|
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()
|
t_pub_analise0 = time.perf_counter()
|
||||||
self._set_pub_cache("cmd_controle", {
|
self._set_pub_cache("analise", {
|
||||||
"cmd": WeedWorkerCommandType.EnviarDadosControle.value,
|
"ts_analise": pred_ts,
|
||||||
"params": atuacao_bicos,
|
"fps_model": pred_cache.get("fps_model", 0.0),
|
||||||
"ts": time.time(),
|
"fps_inferencia": self._fps_infer_ema,
|
||||||
"origem": "weed_worker",
|
"infer_ms": pred_cache.get("infer_ms", 0.0),
|
||||||
"pulverizacao_permitida": bool(debug_pulverizacao.get("permitida", False)),
|
"infer_gpu_ms": pred_cache.get("infer_gpu_ms", 0.0),
|
||||||
"motivo": debug_pulverizacao.get("motivo", ""),
|
"analise": analise_convertida,
|
||||||
"forcado_off": not bool(debug_pulverizacao.get("permitida", False)),
|
"generation": generation,
|
||||||
})
|
}, ts_origem=pred_ts)
|
||||||
t_pub_cmd1 = time.perf_counter()
|
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.
|
t_pub_ctrl0 = time.perf_counter()
|
||||||
self._ultima_deteccao_ok_ts = time.time()
|
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()
|
t_loop1 = time.perf_counter()
|
||||||
total_ms = (t_loop1 - t_loop0) * 1000.0
|
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,
|
"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,
|
"tensor_age_ms": (time.time() - tensor_ts) * 1000.0 if tensor_ts else None,
|
||||||
"pulverizacao": debug_pulverizacao,
|
"pulverizacao": debug_pulverizacao,
|
||||||
|
"generation": generation,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub_cache_ms = (
|
pub_cache_ms = (
|
||||||
|
|
@ -2327,13 +2530,19 @@ class CameraManager:
|
||||||
)
|
)
|
||||||
continue
|
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:
|
try:
|
||||||
t_pp0 = time.perf_counter()
|
t_pp0 = time.perf_counter()
|
||||||
|
|
||||||
caminhos = self._salvar_bruto_multiespectral(
|
caminhos = self._salvar_bruto_multiespectral(
|
||||||
nome_base=nome_frame_async,
|
nome_base=nome_frame_async,
|
||||||
pasta=pasta_async
|
pasta=pasta_async,
|
||||||
|
camera_esperada=camera_esperada,
|
||||||
|
generation_esperada=generation_esperada,
|
||||||
)
|
)
|
||||||
|
|
||||||
t_pp1 = time.perf_counter()
|
t_pp1 = time.perf_counter()
|
||||||
|
|
@ -2355,7 +2564,7 @@ class CameraManager:
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=salvar_pp_async,
|
target=salvar_pp_async,
|
||||||
args=(nome_frame, pasta),
|
args=(nome_frame, pasta, camera_save, generation_save),
|
||||||
daemon=True
|
daemon=True
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
|
|
@ -2438,23 +2647,44 @@ class CameraManager:
|
||||||
except Exception:
|
except Exception:
|
||||||
return frame
|
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:
|
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.")
|
self.mostrar_log("⚠️ CameraMultispectral indisponível para salvar RAW multispec.")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not hasattr(self.camera, "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().")
|
self.mostrar_log(
|
||||||
|
"⚠️ CameraMultispectral ainda não possui salvar_bundle_raw_multispec()."
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return self.camera.salvar_bundle_raw_multispec(
|
return camera_atual.salvar_bundle_raw_multispec(
|
||||||
pasta=pasta,
|
pasta=pasta,
|
||||||
nome=nome_base,
|
nome=nome_base,
|
||||||
nota="pos_processamento_operacao",
|
nota="pos_processamento_operacao",
|
||||||
extra_meta={
|
extra_meta={
|
||||||
"origem_chamada": "weed_worker.salvar_frames",
|
"origem_chamada": "weed_worker.salvar_frames",
|
||||||
"tipo_requisitado": "PosProcessamento",
|
"tipo_requisitado": "PosProcessamento",
|
||||||
|
"camera_generation": generation_atual,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue