diff --git a/AgroBase/AgroBase/Models/Enums.cs b/AgroBase/AgroBase/Models/Enums.cs index a5110f8c2..27a2edb25 100644 --- a/AgroBase/AgroBase/Models/Enums.cs +++ b/AgroBase/AgroBase/Models/Enums.cs @@ -381,5 +381,13 @@ PID = 1 } + public enum CabecasModelo + { + Target = 0, + Vegetation = 1, + Cana = 2, + Segmentation = 3, + } + } } diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs index dbbbe221e..ea619a077 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs @@ -527,7 +527,9 @@ namespace AgroBase.Models AtuPercentualInicioPulverizacao = 85, AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, - AtuModoControle = ModoControleBomba.PID + AtuModoControle = ModoControleBomba.PID, + AtuModeloCabeca = CabecasModelo.Target, + AtuClasseAlvo = "erva" }; op.Parametros.ModulosMandatorios.AddRange(new List() { @@ -640,6 +642,8 @@ namespace AgroBase.Models AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.Continuo, AtuModoControle = ModoControleBomba.PID, + AtuModeloCabeca = CabecasModelo.Target, + AtuClasseAlvo = "erva" }; op.Parametros.ModulosMandatorios.AddRange(new List() { @@ -782,6 +786,8 @@ namespace AgroBase.Models AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, AtuModoControle = ModoControleBomba.PID, + AtuModeloCabeca = CabecasModelo.Target, + AtuClasseAlvo = "erva" }; op.Parametros.ModulosMandatorios.AddRange(new List() { @@ -922,6 +928,8 @@ namespace AgroBase.Models AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, AtuModoControle = ModoControleBomba.PID, + AtuModeloCabeca = CabecasModelo.Target, + AtuClasseAlvo = "erva" }; op.Parametros.ModulosMandatorios.AddRange(new List() { @@ -1022,6 +1030,8 @@ namespace AgroBase.Models AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, AtuModoControle = ModoControleBomba.PID, + AtuModeloCabeca = CabecasModelo.Target, + AtuClasseAlvo = "erva" }; op.Parametros.ModulosMandatorios.AddRange(new List() { diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs index 78b4197e1..328fc93b7 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoParametrosModel.cs @@ -549,6 +549,34 @@ namespace AgroBase.Models.Operacoes } } + private CabecasModelo _atuModeloCabeca; + public CabecasModelo AtuModeloCabeca + { + get => _atuModeloCabeca; + set + { + if (_atuModeloCabeca != value) + { + _atuModeloCabeca = value; + OnPropertyChanged(nameof(AtuModeloCabeca)); + } + } + } + + private string _atuClasseAlvo; + public string AtuClasseAlvo + { + get => _atuClasseAlvo; + set + { + if (_atuClasseAlvo != value) + { + _atuClasseAlvo = value; + OnPropertyChanged(nameof(AtuClasseAlvo)); + } + } + } + private bool _movAuxilioSonar; public bool MovAuxilioSonar { diff --git a/AgroBase/AgroBase/Models/TrajetoriaMapaOperacaoModel.cs b/AgroBase/AgroBase/Models/TrajetoriaMapaOperacaoModel.cs index 0f1c0fffd..9944fc636 100644 --- a/AgroBase/AgroBase/Models/TrajetoriaMapaOperacaoModel.cs +++ b/AgroBase/AgroBase/Models/TrajetoriaMapaOperacaoModel.cs @@ -1014,8 +1014,6 @@ namespace AgroBase.Models double distanciaUltimoPonto = GPSUtils.DistanciaEntrePontos(_posicaoAtual, ultimoPonto.Posicao); - //int idxCorredorDentro = VerificaEquipamentoDentroCorredor(_posicaoAtual); - if (!double.IsNaN(distanciaUltimoPonto) && !double.IsInfinity(distanciaUltimoPonto) && distanciaUltimoPonto < 5.0) { msg = $"Início no meio da rua ignorado: robô está a {distanciaUltimoPonto:0.00}m do fim. Nova operação começará do zero."; @@ -1028,6 +1026,7 @@ namespace AgroBase.Models (bool noCoredor, var idxPontoMaisProximo) = VerificaEquipamentoDentroCorredorMontado(_posicaoAtual); if (noCoredor && idxPontoMaisProximo >= 0 && idxPontoMaisProximo < _TrajetoriaFixa.Count) { + int idxCorredorDentro = VerificaEquipamentoDentroCorredor(_posicaoAtual); for (int i = 0; i <= idxPontoMaisProximo; i++) { _TrajetoriaFixa[i].Visitado = true; @@ -2289,6 +2288,22 @@ namespace AgroBase.Models if (!PontoInicialAdicionado) { _trajetoriaFixa.Add(PontoInicial); + if (false) + { + GPSModel SegundoPonto = GPSUtils.ProjetarPontoDeslocado(CorredorAtual.FirstOrDefault(), (VariaveisEquipamento.DistanciaEntreBicosCm / 100.0 / 2.0), anguloProjetar1); + PontoTrajetoriaModel PontoSecundario = new PontoTrajetoriaModel(TipoPontoRua.LigacaoEntrada) + { + idxCorredor = idx, + idxPonto = _trajetoriaFixa.Count(), + idxPontoCorredor = _trajetoriaFixa.Count(x => x.idxCorredor == idx), + Posicao = SegundoPonto, + Direcao = direcaoAtual, + Orientacao = GPSUtils.CalcularOrientacao(ultimoPontoTrajetoria.Posicao, SegundoPonto), + Visitado = false, + LarguraCorredor = larguraCorredorMenor + }; + _trajetoriaFixa.Add(PontoSecundario); + } } foreach (GPSModel pontoAtual in CorredorAtual) @@ -2399,6 +2414,8 @@ namespace AgroBase.Models AutonomiaCorredor.Iniciar(); + VerificacaoInicialMeioRuaConcluida = false; + AtualizarDadosTrajetoria(); LoopAtualizaDados(); diff --git a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs index 5042ed234..162436984 100644 --- a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs +++ b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using static AgroBase.Models.Enums; using static AgroBase.Models.Operadores.OperadoresModels; @@ -303,6 +304,7 @@ namespace AgroBase.Services.Operadores RedisService.AtualizarCampos( CtxKey.DadosOperacao, + ("id", op.ID ?? ""), ("configurado", true), ("modo", op.Parametros?.Modo ?? ModoOperacao.NaoDefinido), ("tempo_aguardar_inicio_operacao", op.TempoIniciarOperacao), @@ -355,7 +357,9 @@ namespace AgroBase.Services.Operadores percent_vertical_deteccao = pControle.AtuPercentualInicioPulverizacao / 100.0, height_area_deteccao = pControle.AtuAlturaAreaPulverizacao / 100.0, pct_erva_bico_on = pControle.AtuPercentualErvasBicoOn / 100.0, - pct_erva_bico_off = pControle.AtuPercentualErvasBicoOff / 100.0 + pct_erva_bico_off = pControle.AtuPercentualErvasBicoOff / 100.0, + modelo_cabeca = pControle.AtuModeloCabeca.ToString().ToLower(), + modelo_classe_alvo = pControle.AtuClasseAlvo, } ), ("Snr", new @@ -367,14 +371,13 @@ namespace AgroBase.Services.Operadores ) ); - if ((op.Parametros?.ControleAutomatico ?? false) && (op.Trajetoria?._TrajetoriaFixa?.Any() ?? false)) - { - var comando = new + _ = SolicitarAtualizacaoMapaAsync(op).ContinueWith( + task => { - cmd = HealthWorkerCommandType.AtualizarPontosMapa - }; - RedisService.Publish(CmdKey.HealthWorkerRx, JsonConvert.SerializeObject(comando)); - } + Variaveis.MostrarLog($"[HealthWorkerService] Erro atualizando mapa: {task.Exception?.GetBaseException().Message}"); + }, + TaskContinuationOptions.OnlyOnFaulted + ); //Console.WriteLine("Dados de operacao atualizados com sucesso!"); @@ -386,6 +389,99 @@ namespace AgroBase.Services.Operadores } } + private static readonly SemaphoreSlim _mapaSync = new SemaphoreSlim(1, 1); + + private static async Task SolicitarAtualizacaoMapaAsync(OperacaoModel op) + { + Variaveis.MostrarLog($"[HealthWorkerService] SolicitarAtualizacaoMapaAsync solicitado"); + if (!await _mapaSync.WaitAsync(0)) + return false; + + try + { + for (int tentativa = 1; tentativa <= 3; tentativa++) + { + if (!ReferenceEquals(op, Variaveis.OperacaoEmAndamento)) + return false; + + bool atualizado = await AtualizarPontosMapa(op); + + if (atualizado) + return true; + + if (tentativa < 3) + await Task.Delay(200); + Variaveis.MostrarLog($"[HealthWorkerService] SolicitarAtualizacaoMapaAsync. Tentativa={tentativa}"); + } + + return false; + } + finally + { + _mapaSync.Release(); + } + } + + private static async Task AtualizarPontosMapa(OperacaoModel op) + { + if (!(op?.Parametros?.ControleAutomatico ?? false) || !(op?.Trajetoria?._TrajetoriaFixa?.Any() ?? false)) + return false; + + var commandId = Guid.NewGuid().ToString("N"); + var operationId = op?.ID ?? ""; + var mapRevision = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + RedisService.AtualizarCampos( + CtxKey.DadosOperacao, + ("mapa_revisao_solicitada", mapRevision), + ("mapa_operacao_id", operationId), + ("mapa_command_id", commandId), + ("mapa_status", "pendente") + ); + + var comando = new + { + cmd = HealthWorkerCommandType.AtualizarPontosMapa, + command_id = commandId, + operation_id = operationId, + map_revision = mapRevision + }; + + long assinantes = RedisService.Publish( + CmdKey.HealthWorkerRx, + JsonConvert.SerializeObject(comando) + ); + + bool sucesso = await AguardarMapaAplicadoAsync(commandId, mapRevision, TimeSpan.FromSeconds(3)); + return assinantes > 0 && sucesso; + } + + private static async Task AguardarMapaAplicadoAsync(string commandId, long revision, TimeSpan timeout) + { + var limite = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < limite) + { + string ultimoCommandId = RedisService.GetField(CtxKey.DadosHealthWorker, "ultimo_comando_mapa", ""); + long revisaoAplicada = RedisService.GetField(CtxKey.DadosHealthWorker, "mapa_revisao_aplicada", 0L); + string status = RedisService.GetField(CtxKey.DadosHealthWorker, "mapa_status", ""); + + if (ultimoCommandId == commandId && revisaoAplicada == revision && status == "aplicado") + { + return true; + } + + if (ultimoCommandId == commandId && status == "erro") + { + return false; + } + + await Task.Delay(100); + } + + return false; + } + public static void AtualizarDadosControleOperacao() { try diff --git a/AgroBase/AgroBase/Services/RedisService.cs b/AgroBase/AgroBase/Services/RedisService.cs index 39f3756bb..cf20ca9d3 100644 --- a/AgroBase/AgroBase/Services/RedisService.cs +++ b/AgroBase/AgroBase/Services/RedisService.cs @@ -145,10 +145,23 @@ namespace AgroBase.Services } } - public static void Publish(string canal, string mensagem) + public static long Publish(string canal, string mensagem) { - var pub = _redis.GetSubscriber(); - pub.Publish(canal, mensagem); + try + { + if (_redis == null || !_redis.IsConnected) + return 0; + + var pub = _redis.GetSubscriber(); + return pub.Publish(canal, mensagem); + } + catch (Exception ex) + { + Console.WriteLine( + $"❌ Erro ao publicar em '{canal}': {ex.Message}" + ); + return 0; + } } public static void Subscribe(string canal, Action callback) diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py index ef45ae4ba..885efd622 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py @@ -4,6 +4,8 @@ from __future__ import annotations import time +import threading +import unicodedata from pathlib import Path from typing import Dict, List, Tuple @@ -18,9 +20,9 @@ except Exception: DEFAULT_CHANNEL_ORDER = ["R", "G", "B", "RE", "NIR"] -TARGET_COLORS_RGB = { +HEAD_COLORS_RGB = { 0: (30, 30, 30), # background - 1: (255, 70, 30), # target / alvo pulverizável + 1: (255, 70, 30), # classe positiva da cabeça binária selecionada 255: (0, 0, 0), # ignore/fallback } @@ -50,7 +52,7 @@ def get_input_channel_indices(config: dict) -> List[int]: class MultiSpecSegformerService: """ - Runtime oficial Weed Worker v1. + Runtime oficial Weed Worker multi-head. Contrato: input: @@ -66,20 +68,67 @@ class MultiSpecSegformerService: - SegFormer multi-head - resize fullres - argmax - e expõe saída: + e expõe as saídas multi-head. Este serviço executa somente uma das + cabeças operacionais por inferência: - target_mask + - vegetation_mask + - cana_mask + - semantic_mask (convertida para binário pela classe escolhida) output: np.ndarray uint8 HxW - 0 = fundo / não pulverizar - 1 = alvo pulverizável + máscara binária uint8 HxW da cabeça selecionada + 0 = classe negativa + 1 = classe positiva + + semantic_mask nunca é entregue diretamente ao WeedDetector. Quando + selecionada, ela é convertida para 0/1 usando semantic_target_class. """ - TARGET_MODES = ("target_direct", "direct_target", "target_head", "target", "spray", "operational") + MODE_ALIASES = { + "target": "target", + "target_direct": "target", + "direct_target": "target", + "target_head": "target", + "spray": "target", + "operational": "target", + + "vegetation": "vegetation", + "vegetation_direct": "vegetation", + "direct_vegetation": "vegetation", + "vegetation_head": "vegetation", + "vegetacao": "vegetation", + + "cana": "cana", + "cana_direct": "cana", + "direct_cana": "cana", + "cana_head": "cana", + + "semantic": "semantic", + "semantic_direct": "semantic", + "direct_semantic": "semantic", + "semantic_head": "semantic", + "segmentacao": "semantic", + } + + OUTPUT_BY_HEAD = { + "target": "target_mask", + "vegetation": "vegetation_mask", + "cana": "cana_mask", + "semantic": "semantic_mask", + } + + SEMANTIC_CLASS_IDS = { + "chao": 0, + "cana": 1, + "erva": 2, + } def __init__(self, model_config: dict, mostrar_log=print): self.config = model_config or {} self.mostrar_log = mostrar_log + self._runtime_lock = threading.RLock() + self._runtime_generation = 0 self.input_channel_names = get_input_channel_names(self.config) self.input_channel_indices = get_input_channel_indices(self.config) @@ -87,8 +136,22 @@ class MultiSpecSegformerService: self.runtime_backend = str(self.config.get("runtime_backend", "onnx")).lower() self.onnx_provider = str(self.config.get("onnx_provider", "tensorrt")).lower() - self.runtime_mode = str(self.config.get("runtime_mode", "target_direct")).lower() - self.onnx_output_mode = str(self.config.get("onnx_output_mode", self.runtime_mode)).lower() + # runtime_mode é a fonte única da verdade. onnx_output_mode permanece + # apenas como fallback para configs antigas que não possuem runtime_mode. + configured_mode = self.config.get( + "runtime_mode", + self.config.get("onnx_output_mode", "target"), + ) + self.runtime_mode = str(configured_mode).strip().lower() + self.selected_head = self._normalizar_head(self.runtime_mode) + self.selected_output_name = self.OUTPUT_BY_HEAD[self.selected_head] + self.semantic_target_class = self._normalizar_semantic_target_class( + self.config.get("semantic_target_class", "erva") + ) + self.semantic_target_id = self.SEMANTIC_CLASS_IDS[self.semantic_target_class] + + # Mantido para compatibilidade com logs/código externo antigo. + self.onnx_output_mode = self.runtime_mode self.onnx_output_kind = str(self.config.get("onnx_output_kind", "mask")).lower() # No contrato v1, a normalização está dentro do ONNX. @@ -121,6 +184,8 @@ class MultiSpecSegformerService: f"[WEED_ONNX] backend=onnx " f"provider={self.onnx_provider} " f"runtime_mode={self.runtime_mode} " + f"selected_head={self.selected_head} " + f"selected_output={self.selected_output_name} " f"output_kind={self.onnx_output_kind} " f"preprocess_norm={self.onnx_preprocess_norm}" ) @@ -129,6 +194,40 @@ class MultiSpecSegformerService: # Inicialização / contrato # ============================================================ + @classmethod + def _normalizar_head(cls, mode: str) -> str: + normalized = str(mode).strip().lower() + head = cls.MODE_ALIASES.get(normalized) + + if head is None: + raise RuntimeError( + f"runtime_mode inválido para Weed Worker: {mode!r}. " + "Use 'target', 'vegetation', 'cana' ou 'semantic'." + ) + + return head + + @staticmethod + def _normalizar_texto(value) -> str: + text = unicodedata.normalize("NFKD", str(value).strip().lower()) + return "".join(ch for ch in text if not unicodedata.combining(ch)) + + @classmethod + def _normalizar_semantic_target_class(cls, value) -> str: + normalized = cls._normalizar_texto(value) + aliases = { + "0": "chao", "chao": "chao", "solo": "chao", "background": "chao", "bg": "chao", + "1": "cana", "cana": "cana", "sugarcane": "cana", + "2": "erva", "erva": "erva", "weed": "erva", + } + result = aliases.get(normalized) + if result is None: + raise RuntimeError( + f"semantic_target_class inválida: {value!r}. " + "Use 'chao', 'cana' ou 'erva'." + ) + return result + def _validar_contrato_runtime(self): if self.runtime_backend not in ("onnx", "tensorrt", "trt"): raise RuntimeError( @@ -136,17 +235,9 @@ class MultiSpecSegformerService: "Use runtime_backend='onnx'." ) - if self.runtime_mode not in self.TARGET_MODES: - raise RuntimeError( - f"runtime_mode inválido para Weed Worker v1: {self.runtime_mode}. " - "Use runtime_mode='target_direct'." - ) - - if self.onnx_output_mode not in self.TARGET_MODES: - raise RuntimeError( - f"onnx_output_mode inválido para Weed Worker v1: {self.onnx_output_mode}. " - "Use onnx_output_mode='target_direct'." - ) + # A normalização no __init__ já valida o modo e define a cabeça. + if self.selected_head not in self.OUTPUT_BY_HEAD: + raise RuntimeError(f"Cabeça operacional inválida: {self.selected_head}") if self.onnx_output_kind != "mask": raise RuntimeError( @@ -249,23 +340,91 @@ class MultiSpecSegformerService: self.mostrar_log(f"[WEED_ONNX] providers ativos={self.onnx_session.get_providers()}") def _selecionar_outputs_runtime(self, output_names: List[str]) -> List[str]: - target_outputs = [ - n for n in output_names - if "target" in str(n).lower() - ] + output_lookup = {str(name).lower(): str(name) for name in output_names} + expected = self.selected_output_name.lower() - if target_outputs: - return target_outputs - - # Fallback seguro: se o ONNX tiver só uma saída, usa ela. - if len(output_names) == 1: - return list(output_names) + if expected in output_lookup: + return [output_lookup[expected]] raise RuntimeError( - "Não encontrei saída target no ONNX. " - f"Outputs disponíveis: {output_names}" + f"A saída necessária {self.selected_output_name!r} não existe no ONNX. " + f"Cabeça selecionada={self.selected_head!r}; " + f"outputs disponíveis={output_names}." ) + def atualizar_runtime_mode(self, runtime_mode, semantic_target_class=None) -> bool: + """Troca a cabeça operacional sem recriar a sessão ONNX. + + Retorna True quando a configuração efetivamente mudou. Uma inferência + que já estava em andamento é descartada e o novo modo vale no frame + seguinte. + """ + new_mode = str(runtime_mode).strip().lower() + new_head = self._normalizar_head(new_mode) + new_output = self.OUTPUT_BY_HEAD[new_head] + + if semantic_target_class is None: + semantic_target_class = self.semantic_target_class + new_semantic_class = self._normalizar_semantic_target_class(semantic_target_class) + new_semantic_id = self.SEMANTIC_CLASS_IDS[new_semantic_class] + + output_lookup = {str(n).lower(): str(n) for n in self.onnx_output_names} + actual_output = output_lookup.get(new_output.lower()) + if actual_output is None: + raise RuntimeError( + f"A saída necessária {new_output!r} não existe no ONNX. " + f"Outputs disponíveis={self.onnx_output_names}." + ) + + with self._runtime_lock: + changed = ( + new_head != self.selected_head + or (new_head == "semantic" and new_semantic_id != self.semantic_target_id) + ) + if not changed: + return False + + old_desc = self._runtime_description_unlocked() + self.runtime_mode = new_head + self.onnx_output_mode = new_head + self.selected_head = new_head + self.selected_output_name = actual_output + self.onnx_run_output_names = [actual_output] + self.semantic_target_class = new_semantic_class + self.semantic_target_id = new_semantic_id + self._runtime_generation += 1 + self._limpar_cache_unlocked() + new_desc = self._runtime_description_unlocked() + + self.mostrar_log(f"[WEED_ONNX][MODE] {old_desc} -> {new_desc}") + return True + + def atualizar_config(self, config: dict) -> bool: + config = config or {} + mode = config.get("runtime_mode", config.get("onnx_output_mode", self.runtime_mode)) + semantic_class = config.get("semantic_target_class", self.semantic_target_class) + changed = self.atualizar_runtime_mode(mode, semantic_class) + self.config.update(config) + return changed + + def _runtime_description_unlocked(self) -> str: + if self.selected_head == "semantic": + return f"semantic[{self.semantic_target_class}={self.semantic_target_id}]" + return self.selected_head + + def _limpar_cache_unlocked(self): + self._ultimo_predictions = None + self._ultimo_probs = None + self._ultimo_predictions_full = {} + + def limpar_cache_runtime(self): + with self._runtime_lock: + self._limpar_cache_unlocked() + + def get_runtime_generation(self) -> int: + with self._runtime_lock: + return int(self._runtime_generation) + # ============================================================ # Inferência # ============================================================ @@ -295,8 +454,16 @@ class MultiSpecSegformerService: t_forward0 = time.perf_counter() + with self._runtime_lock: + run_output_names = list(self.onnx_run_output_names) + selected_head = self.selected_head + selected_output_name = self.selected_output_name + semantic_target_class = self.semantic_target_class + semantic_target_id = self.semantic_target_id + runtime_generation = self._runtime_generation + outputs = self.onnx_session.run( - self.onnx_run_output_names, + run_output_names, {self.onnx_input_name: x}, ) @@ -307,22 +474,39 @@ class MultiSpecSegformerService: if len(outputs) < 1: raise RuntimeError("ONNX não retornou nenhuma saída.") - target_mask = self._onnx_output_to_mask(outputs[0], out_hw) + raw_mask = self._onnx_output_to_mask(outputs[0], out_hw) + + if selected_head == "semantic": + selected_mask = (raw_mask == semantic_target_id).astype(np.uint8) + else: + selected_mask = raw_mask + + # Defesa do contrato do WeedDetector: a saída final deste serviço é + # sempre binária. As cabeças binárias já devem conter 0/1; semantic + # foi explicitamente convertida acima. + invalid_values = np.logical_and(selected_mask != 0, selected_mask != 1) + if np.any(invalid_values): + values = np.unique(selected_mask[invalid_values])[:10].tolist() + raise RuntimeError( + f"A saída {selected_output_name!r} não é binária. " + f"Valores inválidos encontrados: {values}" + ) post_ms = (time.perf_counter() - t_post0) * 1000.0 total_ms = (time.perf_counter() - t_total0) * 1000.0 - self._ultimo_tensor = chw - self._ultimo_predictions = target_mask - self._ultimo_probs = None - - self._ultimo_predictions_full = { - "target": target_mask, - "target_head": target_mask, - "target_op": target_mask, - "semantic": None, - "vegetation": None, - "cana": None, + predictions_full = { + "selected": selected_mask, + "selected_head": selected_head, + "selected_output": selected_output_name, + "semantic_target_class": semantic_target_class if selected_head == "semantic" else None, + "semantic_target_id": semantic_target_id if selected_head == "semantic" else None, + "target": selected_mask if selected_head == "target" else None, + "target_head": selected_mask if selected_head == "target" else None, + "target_op": selected_mask if selected_head == "target" else None, + "semantic": raw_mask if selected_head == "semantic" else None, + "vegetation": selected_mask if selected_head == "vegetation" else None, + "cana": selected_mask if selected_head == "cana" else None, "probs": None, "infer_ms": total_ms, @@ -330,17 +514,29 @@ class MultiSpecSegformerService: "forward_ms": forward_ms, "post_ms": post_ms, - "runtime_mode": self.runtime_mode, + "runtime_mode": selected_head, + "runtime_generation": runtime_generation, "backend": "onnx", "providers": self.onnx_session.get_providers(), "outputs": self.onnx_output_names, - "outputs_executados": self.onnx_run_output_names, + "outputs_executados": run_output_names, } - if return_full: - return dict(self._ultimo_predictions_full) + # A configuração pode mudar enquanto o provider executa. A verificação + # e a publicação do cache ficam no mesmo lock para impedir que um frame + # antigo reapareça depois de atualizar_runtime_mode() limpar o estado. + with self._runtime_lock: + if runtime_generation != self._runtime_generation: + return None + self._ultimo_tensor = chw + self._ultimo_predictions = selected_mask + self._ultimo_probs = None + self._ultimo_predictions_full = predictions_full - return target_mask + if return_full: + return dict(predictions_full) + + return selected_mask def _prepare_input_numpy_onnx(self, tensor5_chw: np.ndarray): if tensor5_chw is None: @@ -435,7 +631,7 @@ class MultiSpecSegformerService: Não roda inferência aqui. Usa: - tensor multiespectral cacheado - - target_mask cacheada + - máscara binária cacheada da cabeça selecionada """ tensor = tensor5_chw if tensor5_chw is not None else self._ultimo_tensor pred = predictions if predictions is not None else self._ultimo_predictions @@ -449,7 +645,7 @@ class MultiSpecSegformerService: if pred is None: return rgb_bgr, None, rgb_bgr, None, None - seg_rgb = self.ids_to_rgb(pred, TARGET_COLORS_RGB) + seg_rgb = self.ids_to_rgb(pred, HEAD_COLORS_RGB) overlay_rgb = cv2.addWeighted(rgb, 1.0 - alpha, seg_rgb, alpha, 0.0) seg_bgr = cv2.cvtColor(seg_rgb, cv2.COLOR_RGB2BGR) diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py index eed422331..673191eb0 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py @@ -80,14 +80,59 @@ def main(): time.sleep(0.1) def redis_callback(dados): - acao = HealthWorkerCommandType(dados.get("cmd", 0)) + try: + acao = HealthWorkerCommandType(int(dados.get("cmd", 0))) + except Exception as e: + mostrar_log(f"Comando inválido: {dados} | {e}") + return - if acao == HealthWorkerCommandType.AtualizarPontosMapa: - try: - ContextoGlobalRedis._atualizar_pontos_mapa() - except Exception as e: - mostrar_log(f"Erro ao atualizar pontos do mapa: {e}") - mostrar_log("Dados de mapa da Operacao atualizados com sucesso!") + if acao != HealthWorkerCommandType.AtualizarPontosMapa: + return + + command_id = str(dados.get("command_id", "")) + operation_id = str(dados.get("operation_id", "")) + map_revision = dados.get("map_revision") + + try: + resultado = ContextoGlobalRedis._atualizar_pontos_mapa( + operation_id=operation_id, + map_revision=map_revision, + ) + + if not resultado.get("sucesso", False): + raise RuntimeError(resultado.get("erro", "Falha desconhecida")) + + ContextoGlobalRedis.atualizar_ctx_dict( + CtxKey.DadosHealthWorker, + ultimo_comando_mapa=command_id, + mapa_operacao_id=operation_id, + mapa_revisao_aplicada=map_revision, + mapa_pontos_aplicados=resultado["quantidade"], + mapa_status="aplicado", + mapa_erro="", + mapa_aplicado_em=time.time(), + ) + + mostrar_log( + f"Mapa aplicado | command_id={command_id} | " + f"operacao={operation_id} | " + f"revisao={map_revision} | " + f"pontos={resultado['quantidade']}" + ) + + except Exception as e: + ContextoGlobalRedis.atualizar_ctx_dict( + CtxKey.DadosHealthWorker, + ultimo_comando_mapa=command_id, + mapa_operacao_id=operation_id, + mapa_revisao_aplicada=map_revision, + mapa_status="erro", + mapa_erro=str(e), + ) + + mostrar_log( + f"Erro ao aplicar mapa | command_id={command_id} | {e}" + ) def inicializar(): mostrar_log("🚀 Iniciando Health Worker...") diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py index 0eca11707..8410ccad8 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py @@ -1330,11 +1330,60 @@ class ContextoGlobalRedis: return False @classmethod - def _atualizar_pontos_mapa(cls): + def _atualizar_pontos_mapa(cls, operation_id=None, map_revision=None): + try: + operacao = cls.get_operacao() + + operacao_atual = str(operacao.get("mapa_operacao_id", "")) + + if operation_id and operacao_atual != operation_id: + return { + "sucesso": False, + "erro": ( + f"Operação divergente: esperado={operation_id}, " + f"atual={operacao_atual}" + ), + } + + # Conversão atual dos pontos... + pontos_info = cls._converter_pontos_mapa(operacao) + + if not pontos_info: + return { + "sucesso": False, + "erro": "Nenhum ponto válido foi produzido", + } + + atualizado = cls.atualizar_ctx_dict( + CtxKey.DadosOperacao, + pontos_mapa=pontos_info, + mapa_revisao_processada=map_revision, + ) + + cls._atualizar_mpc(do_manager=False, iniciar_operacao=False) + + if not atualizado: + return { + "sucesso": False, + "erro": "Falha ao gravar pontos_mapa no Redis", + } + + return { + "sucesso": True, + "quantidade": len(pontos_info), + } + + except Exception as e: + return { + "sucesso": False, + "erro": str(e), + } + + @classmethod + def _converter_pontos_mapa(cls, operacao): pontos_info = [] try: - operacao = cls.get_operacao() dados_dir = cls._dict(operacao.get("Dir", {})) tipo_controle = cls._int( dados_dir.get("tipo_controle", TiposControladorDirecional.Manual.value), @@ -1343,7 +1392,7 @@ class ContextoGlobalRedis: if tipo_controle != TiposControladorDirecional.MPC.value: cls.atualizar_ctx_dict(CtxKey.DadosOperacao, pontos_mapa=[]) - return + return pontos_info dados_mpc = cls._dict(dados_dir.get("mpc", {})) pontos = dados_mpc.get("pontos", []) or [] @@ -1351,12 +1400,12 @@ class ContextoGlobalRedis: if not pontos: cls.atualizar_ctx_dict(CtxKey.DadosOperacao, pontos_mapa=[]) - return + return pontos_info if not isinstance(p_ref, (list, tuple)) or len(p_ref) < 2: mostrar_log("Ponto de referência do mapa ausente/inválido. Não foi possível converter pontos MPC.") cls.atualizar_ctx_dict(CtxKey.DadosOperacao, pontos_mapa=[]) - return + return pontos_info from shared.gps_handler import GPSHandler @@ -1378,18 +1427,12 @@ class ContextoGlobalRedis: except Exception as e: mostrar_log(f"Erro ao converter ponto MPC: {p} | {e}") - cls.atualizar_ctx_dict( - CtxKey.DadosOperacao, - pontos_mapa=pontos_info, - ) - - if pontos_info: - cls._atualizar_mpc(do_manager=False, iniciar_operacao=False) - except Exception as e: mostrar_log(f"Erro ao atualizar pontos do mapa: {e}") cls.atualizar_ctx_dict(CtxKey.DadosOperacao, pontos_mapa=pontos_info) + return pontos_info + # ============================================================ # Mapeamento de parâmetros # ============================================================ 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 cb48fbd8c..6fd671b90 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 @@ -861,37 +861,35 @@ class CameraManager: self._ultima_saude_ts = time.time() try: - # O lock protege somente a identidade da sessão da câmera. + # 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 - generation = self._camera_generation - if camera_atual is not None: - if pipeline_ia is None: - pipeline_ia = self._avaliar_saude_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) - 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}") def _atualizar_config_dinamica_detector(self, intervalo_s=0.20): """ - Atualiza config do detector em baixa frequência. + Atualiza config do detector e a cabeça ONNX em baixa frequência. Isso permite refletir: - velocidade atual; @@ -917,6 +915,32 @@ class CameraManager: if self.weed_detector is not None: self.weed_detector.atualizar_config(cfg) + mode_changed = False + if self.model_svc is not None: + mode_changed = self.model_svc.atualizar_config(cfg) + + if mode_changed: + # Remove qualquer máscara/detecção gerada no modo anterior. + with self._pred_lock: + self._pred_cache = {} + self._pred_consumido_ts = 0.0 + self._ultimo_predictions = None + self._ultimo_predictions_full = None + + # O detector não deve manter na memória espacial regiões + # derivadas da máscara velha. + if self.weed_detector is not None: + method = getattr(self.weed_detector, "limpar_memoria_alvo", None) + if not callable(method): + method = getattr(self.weed_detector, "_inicializar_estado", None) + if callable(method): + method() + + self.mostrar_log( + "[weed][MODEL] modo atualizado dinamicamente; " + "caches anteriores descartados" + ) + self._ultimo_config_update_ts = agora except Exception as e: @@ -1537,6 +1561,16 @@ class CameraManager: pred_ts = time.time() with self._pred_lock: + infer_mode_generation = int( + infer_full.get("runtime_generation", -1) + ) + current_mode_generation = int( + self.model_svc.get_runtime_generation() + ) + if infer_mode_generation != current_mode_generation: + self.perf.inc("infer_descartada_troca_modo") + continue + if ( self._pred_cache.get("predictions") is not None and float(self._pred_cache.get("ts", 0.0) or 0.0) @@ -2880,4 +2914,4 @@ class CameraManager: def _finalizar_salvamento_posprocessamento(self): with self._posproc_save_lock: - self._posproc_salvando = False + self._posproc_salvando = False \ No newline at end of file diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py index f562f1e72..5c7cc41c4 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py @@ -90,9 +90,13 @@ WEED_DEFAULT_CONFIG = { "runtime_backend": "onnx", "onnx_provider": "tensorrt", - # Modo operacional: usar diretamente a cabeça target. - "runtime_mode": "target_direct", - "onnx_output_mode": "target_direct", + # Cabeça utilizada: + # target | vegetation | cana | semantic + "runtime_mode": "target", + + # Usado somente quando runtime_mode = semantic: + # chao | cana | erva + "semantic_target_class": "erva", # Contrato do ONNX novo: # entrada 0..1 crua -> normalização interna -> resize -> argmax -> target_mask. @@ -249,6 +253,9 @@ def aplicar_overrides_redis(cfg: dict) -> dict: contexto = ContextoGlobalRedis.get_contexto() equipamento = ContextoGlobalRedis.get_equipamento() + cfg["runtime_mode"] = str(dados_atu.get("modelo_cabeca") or "target").strip().lower() + cfg["semantic_target_class"] = str(dados_atu.get("modelo_classe_alvo") or "erva").strip().lower() + cfg["qtd_bicos"] = int(equipamento.get("qtd_bicos") or cfg.get("qtd_bicos", 7) or 7) # Modelo ONNX full-runtime validado para o Weed Worker. diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/weed_detector.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/weed_detector.py index ad7afd4d6..d38488675 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/weed_detector.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/weed_detector.py @@ -124,6 +124,10 @@ class WeedDetector: self._last_update_ts: Optional[float] = None self._distancia_deslocada_total_cm = 0.0 + def limpar_memoria_alvo(self): + """Descarta evidências e estados de bico do modo de máscara anterior.""" + self._inicializar_estado() + def _atualizar_geometria_memoria(self): cfg = self.config diff --git a/AgroBase/OperationControl/OperationControl_ftisshyg_wpftmp.csproj b/AgroBase/OperationControl/OperationControl_ftisshyg_wpftmp.csproj new file mode 100644 index 000000000..7b2269e08 --- /dev/null +++ b/AgroBase/OperationControl/OperationControl_ftisshyg_wpftmp.csproj @@ -0,0 +1,374 @@ + + + OperationControl + obj\Debug\ + obj\ + C:\ZendionInc\agrobot_base\AgroBase\OperationControl\obj\ + <_TargetAssemblyProjectName>OperationControl + + + + WinExe + net8.0-windows7.0 + enable + enable + true + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AgroBase/OperationControl/Views/Operacao/Monitoramento/ParametrizacaoView.xaml b/AgroBase/OperationControl/Views/Operacao/Monitoramento/ParametrizacaoView.xaml index 651959a7b..549e52bf5 100644 --- a/AgroBase/OperationControl/Views/Operacao/Monitoramento/ParametrizacaoView.xaml +++ b/AgroBase/OperationControl/Views/Operacao/Monitoramento/ParametrizacaoView.xaml @@ -19,6 +19,11 @@ + + + + + @@ -177,6 +182,12 @@ + + + + + +