diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py index f17a1373f..fdb37321e 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py @@ -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): diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py index b9e515dd7..4ac67aaa2 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py @@ -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() diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py index 6a9142a44..a9346803d 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py @@ -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): diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py index 1fde71780..1eebd5bad 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py @@ -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, diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py index 243fb3cda..60d0eba1d 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py @@ -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, }, )