Visual e Weed Workers refatorados V1

This commit is contained in:
Diego Freitas 2026-05-22 19:31:00 -03:00
parent 2a5bb43232
commit b74555301c
13 changed files with 6570 additions and 6681 deletions

View File

@ -3,58 +3,67 @@
"id": 1,
"Arquivo": "model",
"Diretorio": "C:\\AgroBaseModels\\Ervas\\",
"Extensao": ".pt",
"Versao": "3_1",
"Extensao": ".onnx",
"Versao": "4_0",
"TipoArquivo": 1,
"ArquivoDownload": "models/weed_detector_model-3_1.pt"
"ArquivoDownload": "models/weed_detector_model-4_0.onnx"
},
{
"id": 2,
"Arquivo": "model",
"Diretorio": "C:\\AgroBaseModels\\Ervas\\",
"Extensao": ".txt",
"Versao": "3_1",
"Versao": "4_0",
"TipoArquivo": 1,
"ArquivoDownload": "models/weed_detector_labelmap-3_1.txt"
"ArquivoDownload": "models/weed_detector_labelmap-4_0.txt"
},
{
"id": 3,
"Arquivo": "model",
"Diretorio": "C:\\AgroBaseModels\\Ervas\\",
"Extensao": ".json",
"Versao": "1_1",
"Versao": "4_0",
"TipoArquivo": 1,
"ArquivoDownload": "models/weed_detector_normstats-1_1.json"
"ArquivoDownload": "models/weed_detector_normstats-4_0.json"
},
{
"id": 4,
"Arquivo": "modelseg",
"Diretorio": "C:\\AgroBaseModels\\Ruas\\",
"Extensao": ".pt",
"Versao": "1_3",
"TipoArquivo": 0,
"ArquivoDownload": "models/street_detector_model_seg-1_3.pt",
"Arquivo": "modelmp",
"Diretorio": "C:\\AgroBaseModels\\Ervas\\",
"Extensao": ".json",
"Versao": "4_0",
"TipoArquivo": 1,
"ArquivoDownload": "models/weed_detector_moduleparams-4_0.json"
},
{
"id": 5,
"Arquivo": "modelseg",
"Diretorio": "C:\\AgroBaseModels\\Ruas\\",
"Extensao": ".txt",
"Versao": "1_3",
"Extensao": ".onnx",
"Versao": "2_0",
"TipoArquivo": 0,
"ArquivoDownload": "models/street_detector_labelmap_seg-1_3.txt"
"ArquivoDownload": "models/street_detector_model_seg-2_0.onnx",
},
{
"id": 6,
"Arquivo": "modelseg",
"Diretorio": "C:\\AgroBaseModels\\Ruas\\",
"Extensao": ".json",
"Versao": "1_1",
"Extensao": ".txt",
"Versao": "2_0",
"TipoArquivo": 0,
"ArquivoDownload": "models/street_detector_normstats-1_1.json"
"ArquivoDownload": "models/street_detector_labelmap_seg-2_0.txt"
},
{
"id": 7,
"Arquivo": "modelseg",
"Diretorio": "C:\\AgroBaseModels\\Ruas\\",
"Extensao": ".json",
"Versao": "1_4",
"TipoArquivo": 0,
"ArquivoDownload": "models/street_detector_normstats-1_4.json"
},
{
"id": 8,
"Arquivo": "modeldet",
"Diretorio": "C:\\AgroBaseModels\\Ruas\\",
"Extensao": ".blob",
@ -63,7 +72,7 @@
"ArquivoDownload": "models/street_detector_model_det-1_0.blob",
},
{
"id": 8,
"id": 9,
"Arquivo": "parametersAtu",
"Diretorio": "Parametros/",
"Extensao": ".par",
@ -72,7 +81,7 @@
"ArquivoDownload": "parameters/parametersAtu-2_0.par",
},
{
"id": 9,
"id": 10,
"Arquivo": "parametersMvd",
"Diretorio": "Parametros/",
"Extensao": ".par",
@ -81,7 +90,7 @@
"ArquivoDownload": "parameters/parametersMvd-2_0.par"
},
{
"id": 10,
"id": 11,
"Arquivo": "parametersSen",
"Diretorio": "Parametros/",
"Extensao": ".par",
@ -90,7 +99,7 @@
"ArquivoDownload": "parameters/parametersSen-2_0.par"
},
{
"id": 11,
"id": 12,
"Arquivo": "pinoutAtu",
"Diretorio": "Parametros/",
"Extensao": ".pin",
@ -99,7 +108,7 @@
"ArquivoDownload": "parameters/pinoutAtu-2_0.pin"
},
{
"id": 12,
"id": 13,
"Arquivo": "pinoutSen",
"Diretorio": "Parametros/",
"Extensao": ".pin",
@ -108,7 +117,7 @@
"ArquivoDownload": "parameters/pinoutSen-2_0.pin"
},
{
"id": 13,
"id": 14,
"Arquivo": "weed_detector_oak",
"Diretorio": "Python\\Scripts\\",
"Extensao": ".py",
@ -117,7 +126,7 @@
"ArquivoDownload": "weed_detector_oak-1_0.py"
},
{
"id": 14,
"id": 15,
"Arquivo": "map_load",
"Diretorio": "Python\\Scripts\\",
"Extensao": ".py",
@ -126,7 +135,7 @@
"ArquivoDownload": "scripts/map_load-1_0.py"
},
{
"id": 15,
"id": 16,
"Arquivo": "map_follow",
"Diretorio": "Python\\Scripts\\",
"Extensao": ".py",
@ -135,7 +144,7 @@
"ArquivoDownload": "scripts/map_follow-1_0.py"
},
{
"id": 16,
"id": 17,
"Arquivo": "gps_viewer",
"Diretorio": "Python\\Scripts\\",
"Extensao": ".py",
@ -144,7 +153,7 @@
"ArquivoDownload": "scripts/gps_viewer-1_0.py"
},
{
"id": 17,
"id": 18,
"Arquivo": "modelo_3d",
"Diretorio": "Python\\Output\\",
"Extensao": ".obj",
@ -153,7 +162,7 @@
"ArquivoDownload": "modelo_3d-1_0.obj"
},
{
"id": 18,
"id": 19,
"Arquivo": "modelo_3d",
"Diretorio": "Python\\Output\\",
"Extensao": ".mtl",

View File

@ -1,284 +1,509 @@
import json
import os
import time
from typing import Any, Dict, List, Optional, Sequence, Tuple
import cv2
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import SegformerConfig, SegformerForSemanticSegmentation
class LabelHead(nn.Module):
def __init__(self, feat_ch: int, num_seg_classes: int, num_label_classes: int, hidden: int = 256, dropout: float = 0.2):
super().__init__()
in_ch = feat_ch + num_seg_classes
self.pool = nn.AdaptiveAvgPool2d((1, 1))
self.net = nn.Sequential(
nn.Linear(in_ch, hidden),
nn.ReLU(inplace=True),
nn.Dropout(dropout),
nn.Linear(hidden, num_label_classes),
)
def forward(self, feat: torch.Tensor, logits_seg: torch.Tensor) -> torch.Tensor:
if feat.shape[-2:] != logits_seg.shape[-2:]:
feat = F.interpolate(feat, size=logits_seg.shape[-2:], mode="bilinear", align_corners=False)
x = torch.cat([feat, logits_seg], dim=1)
x = self.pool(x).flatten(1)
return self.net(x)
import onnxruntime as ort
class SegformerNavRunner:
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
"""
Runner ONNX/TensorRT para segmentação navegável do Visual Worker.
def __init__(self, seg_config, device="cuda"):
Contrato novo:
- Não carrega .pt.
- Não usa Torch.
- Não usa HuggingFace.
- Não norm_stats para normalizar fora.
- Assume que o ONNX contém resize/normalização quando onnx_has_preprocess=True.
Entrada principal:
frame_rgb: np.ndarray HxWx3, RGB, uint8
Retorno de infer_ids:
pred_ids_np, ts, roi_resized, roi_info, aux_result
Onde:
pred_ids_np : máscara HxW com ids de classe
ts : timestamp da inferência
roi_resized : ROI RGB redimensionada para debug/visualização
roi_info : (y_fim, y_inicio) no frame original
aux_result : dict de label/status, se o ONNX exportar saída auxiliar
"""
def __init__(self, seg_config: Dict[str, Any]):
from shared.utils import carregar_labelmap_completo
self.device = torch.device(device if torch.cuda.is_available() else "cpu")
self.config = dict(seg_config)
self.use_amp = bool(seg_config.get("use_amp", True))
self.use_channels_last = bool(seg_config.get("use_channels_last", True))
self.use_compact_aux = bool(seg_config.get("use_compact_aux", True))
self.debug_timing = bool(seg_config.get("debug_timing", False))
self.debug_session = bool(seg_config.get("debug_session", True))
self.use_compact_aux = bool(seg_config.get("use_compact_aux", True))
self.resolucao = self._read_resolution(seg_config)
self.roi_inicio = float(seg_config.get("ia_roi_begin", 0.0))
self.roi_tamanho = float(seg_config.get("ia_roi_size", 1.0))
self.onnx_path = (
seg_config.get("ia_onnx_path")
or seg_config.get("onnx_path")
or seg_config.get("ia_model_path")
)
if not self.onnx_path:
raise ValueError(
"SegformerNavRunner ONNX precisa de 'ia_onnx_path', 'onnx_path' "
"ou 'ia_model_path' apontando para o arquivo .onnx."
)
if not os.path.isfile(self.onnx_path):
raise FileNotFoundError(f"Arquivo ONNX não encontrado: {self.onnx_path}")
self.provider_mode = str(seg_config.get("onnx_provider", "tensorrt")).lower()
self.onnx_has_preprocess = bool(seg_config.get("onnx_has_preprocess", True))
# Formatos aceitos:
# - auto: tenta inferir pela saída
# - ids: saída já é máscara de ids
# - logits: saída é [N,C,H,W] ou [C,H,W]
self.seg_output_format = str(seg_config.get("onnx_seg_output_format", "auto")).lower()
# Formato de entrada quando o ONNX já tem preprocess embutido.
# Recomendado para seu contrato novo: nhwc_uint8.
#
# Opções:
# - nhwc_uint8 -> [1,H,W,3] uint8
# - nhwc_float -> [1,H,W,3] float32, 0..255 ou 0..1 conforme input_scale
# - nchw_float -> [1,3,H,W] float32
self.input_layout = str(seg_config.get("onnx_input_layout", "nhwc_uint8")).lower()
self.input_scale = float(seg_config.get("onnx_input_scale", 1.0))
self.input_name_forced = seg_config.get("onnx_input_name")
self.seg_output_name_forced = seg_config.get("onnx_seg_output_name")
self.aux_output_name_forced = seg_config.get("onnx_aux_output_name")
self.label_names = self._load_label_names(seg_config)
self._last_aux_result = None
self._last_aux_ts = 0.0
self.last_infer = None
self.cor_para_id, self.colormap_rgb, self.classes, self.ignore_rgb = carregar_labelmap_completo(
seg_config["ia_labelmap_path"]
)
self.resolucao = tuple(seg_config["ia_resolution"]) # [W,H]
self.roi_inicio = seg_config["ia_roi_begin"]
self.roi_tamanho = seg_config["ia_roi_size"]
self.last_infer = None
self.session = self._create_session()
self.input_name = self._resolve_input_name()
self.output_names = [o.name for o in self.session.get_outputs()]
self.mode = seg_config.get("ia_mode", "dual_label")
self.model = None
self.aux_head = None
self.label_names = {}
self.input_meta = next(i for i in self.session.get_inputs() if i.name == self.input_name)
self.input_type = str(self.input_meta.type)
self.input_shape = self.input_meta.shape
self._load_dual_checkpoint(seg_config)
self.seg_output_name = self._resolve_seg_output_name()
self.aux_output_name = self._resolve_aux_output_name()
norm_mean = self.IMAGENET_MEAN
norm_std = self.IMAGENET_STD
if self.debug_session:
self._print_session_summary()
norm_stats_path = seg_config.get("ia_norm_stats_path")
if norm_stats_path and os.path.isfile(norm_stats_path):
with open(norm_stats_path, "r", encoding="utf-8") as f:
norm_stats = json.load(f)
# -------------------------------------------------------------------------
# Inicialização ONNX
# -------------------------------------------------------------------------
stats_channels = norm_stats.get("channels", [])
stats_mean = norm_stats.get("mean", [])
stats_std = norm_stats.get("std", [])
def _create_session(self) -> ort.InferenceSession:
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
idx_by_name = {name: i for i, name in enumerate(stats_channels)}
intra_threads = int(self.config.get("onnx_intra_op_num_threads", 1))
inter_threads = int(self.config.get("onnx_inter_op_num_threads", 1))
if all(ch in idx_by_name for ch in ["R", "G", "B"]):
norm_mean = [
stats_mean[idx_by_name["R"]],
stats_mean[idx_by_name["G"]],
stats_mean[idx_by_name["B"]],
if intra_threads > 0:
sess_options.intra_op_num_threads = intra_threads
if inter_threads > 0:
sess_options.inter_op_num_threads = inter_threads
providers = self._build_providers()
try:
return ort.InferenceSession(
self.onnx_path,
sess_options=sess_options,
providers=providers,
)
except Exception as e:
raise RuntimeError(
f"Falha ao carregar ONNX: {self.onnx_path} | "
f"provider_mode={self.provider_mode} | erro={e}"
) from e
def _build_providers(self) -> List[Any]:
available = set(ort.get_available_providers())
if self.provider_mode in ("tensorrt", "trt"):
providers: List[Any] = []
if "TensorrtExecutionProvider" in available:
trt_options = {
"trt_fp16_enable": bool(self.config.get("trt_fp16_enable", True)),
"trt_engine_cache_enable": bool(self.config.get("trt_engine_cache_enable", True)),
"trt_engine_cache_path": str(
self.config.get("trt_engine_cache_path", "./trt_cache_visual_worker")
),
}
max_workspace = self.config.get("trt_max_workspace_size")
if max_workspace is not None:
trt_options["trt_max_workspace_size"] = int(max_workspace)
providers.append(("TensorrtExecutionProvider", trt_options))
if "CUDAExecutionProvider" in available:
providers.append("CUDAExecutionProvider")
providers.append("CPUExecutionProvider")
return providers
if self.provider_mode in ("cuda", "gpu"):
providers = []
if "CUDAExecutionProvider" in available:
providers.append("CUDAExecutionProvider")
providers.append("CPUExecutionProvider")
return providers
return ["CPUExecutionProvider"]
def _resolve_input_name(self) -> str:
inputs = self.session.get_inputs()
if not inputs:
raise RuntimeError("ONNX não possui entradas.")
if self.input_name_forced:
names = [i.name for i in inputs]
if self.input_name_forced not in names:
raise RuntimeError(
f"onnx_input_name='{self.input_name_forced}' não encontrado. "
f"Entradas disponíveis: {names}"
)
return str(self.input_name_forced)
return inputs[0].name
def _resolve_seg_output_name(self) -> str:
if self.seg_output_name_forced:
if self.seg_output_name_forced not in self.output_names:
raise RuntimeError(
f"onnx_seg_output_name='{self.seg_output_name_forced}' não encontrado. "
f"Saídas disponíveis: {self.output_names}"
)
return str(self.seg_output_name_forced)
# Preferências comuns.
preferred = [
"seg_ids",
"pred_ids",
"mask_ids",
"segmentation",
"seg",
"logits",
"seg_logits",
"output",
]
norm_std = [
stats_std[idx_by_name["R"]],
stats_std[idx_by_name["G"]],
stats_std[idx_by_name["B"]],
lower_by_name = {name.lower(): name for name in self.output_names}
for p in preferred:
if p in lower_by_name:
return lower_by_name[p]
return self.output_names[0]
def _resolve_aux_output_name(self) -> Optional[str]:
if self.aux_output_name_forced:
if self.aux_output_name_forced not in self.output_names:
raise RuntimeError(
f"onnx_aux_output_name='{self.aux_output_name_forced}' não encontrado. "
f"Saídas disponíveis: {self.output_names}"
)
return str(self.aux_output_name_forced)
if len(self.output_names) <= 1:
return None
preferred = [
"label_logits",
"aux_logits",
"status_logits",
"label",
"aux",
"status",
]
print(f"[NORM] usando stats fixos de: {norm_stats_path}")
else:
print("[NORM] norm_stats não contém R,G,B. Usando ImageNet.")
else:
print(f"[NORM] norm_stats não encontrado em {norm_stats_path}. Usando ImageNet.")
self.set_norm_stats(norm_mean, norm_std)
lower_by_name = {name.lower(): name for name in self.output_names}
def set_norm_stats(self, mean, std):
self._norm_mean = torch.tensor(mean, dtype=torch.float32, device=self.device).view(3, 1, 1)
self._norm_std = torch.tensor(std, dtype=torch.float32, device=self.device).view(3, 1, 1).clamp_min(1e-6)
for p in preferred:
if p in lower_by_name:
return lower_by_name[p]
def normalize_img(self, img):
return (img - self._norm_mean) / self._norm_std
for name in self.output_names:
if name != self.seg_output_name:
return name
def _build_base_model(self, backbone: str, num_classes: int):
config = SegformerConfig.from_pretrained(
backbone,
local_files_only=True
)
return None
config.num_labels = int(num_classes)
config.output_hidden_states = True
def _print_session_summary(self):
print("[SEG_ONNX] Runner carregado")
print(f"[SEG_ONNX] modelo : {self.onnx_path}")
print(f"[SEG_ONNX] provider_mode : {self.provider_mode}")
print(f"[SEG_ONNX] providers ativos: {self.session.get_providers()}")
print(f"[SEG_ONNX] input : {self.input_name}")
print(f"[SEG_ONNX] outputs : {self.output_names}")
print(f"[SEG_ONNX] seg_output : {self.seg_output_name}")
print(f"[SEG_ONNX] aux_output : {self.aux_output_name}")
print(f"[SEG_ONNX] resolução : {self.resolucao}")
print(f"[SEG_ONNX] input_layout : {self.input_layout}")
print(f"[SEG_ONNX] has_preprocess : {self.onnx_has_preprocess}")
input_meta = self.session.get_inputs()[0]
print(f"[SEG_ONNX] input type : {input_meta.type}")
print(f"[SEG_ONNX] input shape : {input_meta.shape}")
model = SegformerForSemanticSegmentation(config)
return model
# -------------------------------------------------------------------------
# API principal
# -------------------------------------------------------------------------
def _load_dual_checkpoint(self, seg_config):
pt_path = seg_config["ia_model_path"]
backbone = seg_config["ia_backbone"]
num_classes = len(self.classes)
ckpt = torch.load(pt_path, map_location="cpu", weights_only=False)
self.model = self._build_base_model(backbone, num_classes)
self.model.load_state_dict(ckpt["model"], strict=True)
self.model.to(self.device).eval()
if self.use_channels_last and self.device.type == "cuda":
self.model.to(memory_format=torch.channels_last)
torch.backends.cudnn.benchmark = True
extra = ckpt.get("extra", {}) or {}
label_names_raw = extra.get("label_name_by_id", {}) or {}
self.label_names = {int(k): str(v) for k, v in label_names_raw.items()} if label_names_raw else {}
if self.mode == "dual_label":
aux_sd = ckpt.get("aux_head")
if aux_sd is None:
raise RuntimeError("Checkpoint não possui aux_head. Este arquivo parece não ser dual_head_label.")
max_label_id = -1
for k, v in aux_sd.items():
if k.endswith("net.3.weight") or k.endswith("net.3.bias"):
max_label_id = int(v.shape[0]) - 1
break
if max_label_id < 0:
raise RuntimeError("Não consegui inferir número de classes da LabelHead.")
num_label_classes = max_label_id + 1
for i in range(num_label_classes):
self.label_names.setdefault(i, f"label_{i}")
feat_ch = int(self.model.config.hidden_sizes[-1])
self.aux_head = LabelHead(
feat_ch=feat_ch,
num_seg_classes=num_classes,
num_label_classes=num_label_classes,
hidden=256,
dropout=0.2,
)
self.aux_head.load_state_dict(aux_sd, strict=True)
self.aux_head.to(self.device).eval()
if self.use_channels_last and self.device.type == "cuda":
# Linear não usa channels_last, mas manter aqui não atrapalha.
pass
print(f"[DUAL] LabelHead carregada: classes={num_label_classes} names={self.label_names}")
else:
self.aux_head = None
print("[SEG] Modo single carregado.")
print(f"[MODEL] ckpt={pt_path}")
print(f"[MODEL] epoch={ckpt.get('epoch')} bests={ckpt.get('bests')}")
def compute_roi_indices(self, H: int, zona_inicio: float, faixa_atuacao: float):
y_inicio = int((1.0 - zona_inicio) * H)
y_fim = int((1.0 - (zona_inicio + faixa_atuacao)) * H)
y_fim = max(0, min(H, y_fim))
y_inicio = max(0, min(H, y_inicio))
if y_fim >= y_inicio:
y_fim = max(0, y_inicio - 1)
return y_fim, y_inicio
def resize_keep_width(self, img: np.ndarray, new_w: int, min_h: int, interpolation: int) -> np.ndarray:
h, w = img.shape[:2]
new_h = int(round(new_w * (h / max(1, w))))
if min_h is not None and new_h < min_h:
new_h = min_h
return cv2.resize(img, (new_w, new_h), interpolation=interpolation)
def _preprocess_roi(self, roi_rgb: np.ndarray):
roi_resized = self.resize_keep_width(
roi_rgb,
self.resolucao[0],
self.resolucao[1],
cv2.INTER_AREA,
)
# Garante array contínuo para reduzir cópia torta no torch.from_numpy
roi_resized = np.ascontiguousarray(roi_resized)
img_tensor = torch.from_numpy(roi_resized).to(
device=self.device,
dtype=torch.float32,
non_blocking=True
)
# HWC -> NCHW
img_tensor = img_tensor.permute(2, 0, 1).unsqueeze(0)
img_tensor = img_tensor.div_(255.0)
img_tensor = self.normalize_img(img_tensor)
if self.use_channels_last and self.device.type == "cuda":
img_tensor = img_tensor.contiguous(memory_format=torch.channels_last)
return img_tensor, roi_resized
@torch.inference_mode()
def infer_ids(self, frame_rgb):
def infer_ids(self, frame_rgb: np.ndarray):
if frame_rgb is None or not hasattr(frame_rgb, "shape") or frame_rgb.size == 0:
return None, None, None, None, None
t0 = time.perf_counter()
H, W = frame_rgb.shape[:2]
y_fim, y_inicio = self.compute_roi_indices(H, self.roi_inicio, self.roi_tamanho)
y_fim, y_inicio = self.compute_roi_indices(
H,
self.roi_inicio,
self.roi_tamanho,
)
roi_rgb = frame_rgb[y_fim:y_inicio, 0:W]
t_crop = time.perf_counter()
if roi_rgb is None or roi_rgb.size == 0:
return None, None, None, (y_fim, y_inicio), None
img_tensor, roi_resized = self._preprocess_roi(roi_rgb)
input_tensor, roi_resized = self._prepare_input(roi_rgb)
t_pre = time.perf_counter()
use_amp_now = self.use_amp and self.device.type == "cuda"
with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=use_amp_now):
out = self.model(pixel_values=img_tensor, output_hidden_states=True)
requested_outputs = [self.seg_output_name]
if self.aux_output_name:
requested_outputs.append(self.aux_output_name)
outputs = self.session.run(
requested_outputs,
{self.input_name: input_tensor},
)
t_model = time.perf_counter()
logits_seg = out.logits
seg_out = outputs[0]
pred_ids_np = self._decode_seg_output(seg_out)
t_arg0 = time.perf_counter()
pred_low = torch.argmax(logits_seg, dim=1)[0]
pred_low_np = pred_low.detach().cpu().numpy().astype(np.uint8)
expected_w, expected_h = self.resolucao
if pred_ids_np.shape[:2] != (expected_h, expected_w):
pred_ids_np = cv2.resize(
pred_low_np,
(roi_resized.shape[1], roi_resized.shape[0]),
interpolation=cv2.INTER_NEAREST
pred_ids_np,
(expected_w, expected_h),
interpolation=cv2.INTER_NEAREST,
)
t_arg1 = time.perf_counter()
pred_ids_np = np.ascontiguousarray(pred_ids_np.astype(np.uint8, copy=False))
aux_result = None
if self.aux_output_name and len(outputs) > 1:
aux_result = self._decode_aux_output(outputs[1])
t_aux0 = time.perf_counter()
if self.mode == "dual_label" and self.aux_head is not None:
feat = out.hidden_states[-1]
self.last_infer = time.time()
t_end = time.perf_counter()
if feat.shape[-2:] != logits_seg.shape[-2:]:
feat = F.interpolate(
feat,
size=logits_seg.shape[-2:],
mode="bilinear",
align_corners=False,
if self.debug_timing:
print(
f"RUNNER_ONNX | "
f"crop={(t_crop - t0) * 1000:.1f}ms | "
f"pre={(t_pre - t_crop) * 1000:.1f}ms | "
f"model={(t_model - t_pre) * 1000:.1f}ms | "
f"post={(t_end - t_model) * 1000:.1f}ms | "
f"total={(t_end - t0) * 1000:.1f}ms"
)
with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=use_amp_now):
logits_label = self.aux_head(feat, logits_seg)
return pred_ids_np, self.last_infer, roi_resized, (y_fim, y_inicio), aux_result
probs = torch.softmax(logits_label, dim=1)[0].detach().cpu().numpy()
def infer_ids_seg_only(self, frame_rgb: np.ndarray):
"""
Mantido como alias para o novo contrato.
Como este runner é ONNX puro, a segmentação é o caminho principal.
"""
pred_ids_np, ts, roi_resized, roi_info, _aux = self.infer_ids(frame_rgb)
aux_result = self._last_aux_result
if aux_result is not None:
aux_result = dict(aux_result)
aux_result["stale"] = True
aux_result["age_ms"] = (time.time() - self._last_aux_ts) * 1000.0
return pred_ids_np, ts, roi_resized, roi_info, aux_result
# -------------------------------------------------------------------------
# Preprocess
# -------------------------------------------------------------------------
def _prepare_input(self, roi_rgb: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
roi_rgb = np.ascontiguousarray(roi_rgb)
# Mesmo quando o ONNX tem preprocess embutido, mantemos uma ROI redimensionada
# para debug/visualização e compatibilidade com o retorno.
roi_resized = cv2.resize(
roi_rgb,
self.resolucao,
interpolation=cv2.INTER_AREA,
)
roi_resized = np.ascontiguousarray(roi_resized)
if self.onnx_has_preprocess:
base = roi_rgb
else:
base = roi_resized
if self.input_layout == "nhwc_uint8":
inp = base[None, ...]
if inp.dtype != np.uint8:
inp = inp.astype(np.uint8, copy=False)
return np.ascontiguousarray(inp), roi_resized
if self.input_layout == "nhwc_float":
inp = base.astype(np.float32, copy=False)
if self.input_scale != 1.0:
inp = inp * self.input_scale
inp = inp[None, ...]
return np.ascontiguousarray(inp), roi_resized
if self.input_layout == "nchw_float":
inp = base.astype(np.float32, copy=False)
if self.input_scale != 1.0:
inp = inp * self.input_scale
inp = np.transpose(inp, (2, 0, 1))[None, ...]
return np.ascontiguousarray(inp), roi_resized
raise ValueError(
f"onnx_input_layout inválido: {self.input_layout}. "
"Use 'nhwc_uint8', 'nhwc_float' ou 'nchw_float'."
)
# -------------------------------------------------------------------------
# Pós-processamento
# -------------------------------------------------------------------------
def _decode_seg_output(self, out: np.ndarray) -> np.ndarray:
arr = np.asarray(out)
fmt = self.seg_output_format
if fmt == "ids":
return self._squeeze_ids(arr)
if fmt == "logits":
return self._logits_to_ids(arr)
if fmt != "auto":
raise ValueError(
f"onnx_seg_output_format inválido: {fmt}. "
"Use 'auto', 'ids' ou 'logits'."
)
# AUTO:
# [N,C,H,W] com C pequeno normalmente é logits.
if arr.ndim == 4:
if arr.shape[1] > 1:
return self._logits_to_ids(arr)
return self._squeeze_ids(arr)
# [C,H,W] com C pequeno também pode ser logits.
if arr.ndim == 3:
num_classes = len(getattr(self, "classes", []) or [])
if num_classes > 1 and arr.shape[0] == num_classes:
return self._logits_to_ids(arr)
return self._squeeze_ids(arr)
if arr.ndim == 2:
return self._squeeze_ids(arr)
raise RuntimeError(f"Formato de saída de segmentação não suportado: shape={arr.shape}")
def _squeeze_ids(self, arr: np.ndarray) -> np.ndarray:
arr = np.asarray(arr)
# [1,1,H,W] -> [H,W]
if arr.ndim == 4 and arr.shape[0] == 1 and arr.shape[1] == 1:
return arr[0, 0]
# [1,H,W] -> [H,W]
if arr.ndim == 3 and arr.shape[0] == 1:
return arr[0]
# [H,W,1] -> [H,W]
if arr.ndim == 3 and arr.shape[-1] == 1:
return arr[:, :, 0]
# [H,W]
if arr.ndim == 2:
return arr
# fallback seguro
return np.squeeze(arr)
def _logits_to_ids(self, arr: np.ndarray) -> np.ndarray:
arr = np.asarray(arr)
# [N,C,H,W] -> [H,W]
if arr.ndim == 4:
return np.argmax(arr, axis=1)[0]
# [C,H,W] -> [H,W]
if arr.ndim == 3:
return np.argmax(arr, axis=0)
raise RuntimeError(f"Formato de logits não suportado: shape={arr.shape}")
def _decode_aux_output(self, out: np.ndarray) -> Optional[Dict[str, Any]]:
arr = np.asarray(out)
if arr.size == 0:
return None
values = np.squeeze(arr).astype(np.float32)
if values.ndim != 1:
values = values.reshape(-1).astype(np.float32)
aux_format = str(
self.config.get("onnx_aux_output_format", "")
).lower()
if not aux_format:
name = str(getattr(self, "aux_output_name", "") or "").lower()
aux_format = "probs" if "prob" in name else "logits"
if aux_format == "probs":
probs = values
s = float(np.sum(probs))
if s > 1e-6:
probs = probs / s
else:
probs = self._softmax_np(values)
label_id = int(np.argmax(probs))
label_conf = float(probs[label_id])
@ -300,116 +525,105 @@ class SegformerNavRunner:
"label_probs": probs.astype(float).tolist(),
"label_names": self.label_names,
}
if aux_result is not None:
self._last_aux_result = aux_result
self._last_aux_ts = time.time()
t_aux1 = time.perf_counter()
return aux_result
self.last_infer = time.time()
@staticmethod
def _softmax_np(x: np.ndarray) -> np.ndarray:
x = x.astype(np.float32, copy=False)
x = x - np.max(x)
exp = np.exp(x)
denom = float(np.sum(exp))
t_end = time.perf_counter()
if denom <= 1e-12:
return np.zeros_like(exp, dtype=np.float32)
if self.debug_timing:
print(
f"RUNNER | crop={(t_crop-t0)*1000:.1f}ms | "
f"pre={(t_pre-t_crop)*1000:.1f}ms | "
f"model={(t_model-t_pre)*1000:.1f}ms | "
f"arg={(t_arg1-t_arg0)*1000:.1f}ms | "
f"aux={(t_aux1-t_aux0)*1000:.1f}ms | "
f"total={(t_end-t0)*1000:.1f}ms"
)
return exp / denom
return pred_ids_np, self.last_infer, roi_resized, (y_fim, y_inicio), aux_result
# -------------------------------------------------------------------------
# Utilitários
# -------------------------------------------------------------------------
@torch.inference_mode()
def infer_ids_seg_only(self, frame_rgb):
@staticmethod
def _read_resolution(seg_config: Dict[str, Any]) -> Tuple[int, int]:
res = seg_config.get("ia_resolution", [1024, 576])
if not isinstance(res, Sequence) or len(res) != 2:
raise ValueError(f"ia_resolution inválida: {res}")
w = int(res[0])
h = int(res[1])
if w <= 0 or h <= 0:
raise ValueError(f"ia_resolution precisa ser positiva: {res}")
return w, h
@staticmethod
def compute_roi_indices(H: int, zona_inicio: float, faixa_atuacao: float) -> Tuple[int, int]:
zona_inicio = float(zona_inicio)
faixa_atuacao = float(faixa_atuacao)
zona_inicio = max(0.0, min(1.0, zona_inicio))
faixa_atuacao = max(0.0, min(1.0, faixa_atuacao))
y_inicio = int((1.0 - zona_inicio) * H)
y_fim = int((1.0 - (zona_inicio + faixa_atuacao)) * H)
y_fim = max(0, min(H, y_fim))
y_inicio = max(0, min(H, y_inicio))
if y_fim >= y_inicio:
y_fim = max(0, y_inicio - 1)
return y_fim, y_inicio
def _load_label_names(self, seg_config: Dict[str, Any]) -> Dict[int, str]:
"""
Inferência rápida apenas da cabeça de segmentação.
Carrega nomes do label/status auxiliar, se existir.
Diferenças para infer_ids():
- não pede hidden_states;
- não roda aux_head;
- reutiliza self._last_aux_result, se existir;
- mantém o mesmo formato de retorno:
pred_ids_np, ts, roi_resized, roi_info, aux_result
Aceita:
- label_name_by_id direto no config
- ia_label_names_path apontando para JSON
- onnx_label_names_path apontando para JSON
O JSON pode ser:
- {"0": "Parado", "1": "CaminhandoRua"}
- {"label_name_by_id": {"0": "Parado", "1": "CaminhandoRua"}}
- ["Parado", "CaminhandoRua"]
"""
if frame_rgb is None or not hasattr(frame_rgb, "shape") or frame_rgb.size == 0:
return None, None, None, None, None
raw = seg_config.get("label_name_by_id")
t0 = time.perf_counter()
H, W = frame_rgb.shape[:2]
y_fim, y_inicio = self.compute_roi_indices(
H,
self.roi_inicio,
self.roi_tamanho
path = (
seg_config.get("ia_label_names_path")
or seg_config.get("onnx_label_names_path")
)
roi_rgb = frame_rgb[y_fim:y_inicio, 0:W]
t_crop = time.perf_counter()
if raw is None and path and os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if roi_rgb is None or roi_rgb.size == 0:
return None, None, None, (y_fim, y_inicio), None
if isinstance(data, dict) and "label_name_by_id" in data:
raw = data["label_name_by_id"]
else:
raw = data
img_tensor, roi_resized = self._preprocess_roi(roi_rgb)
t_pre = time.perf_counter()
if raw is None:
return {}
use_amp_now = getattr(self, "use_amp", True) and self.device.type == "cuda"
# Aqui está o ponto principal do teste:
# NÃO pede hidden_states, então o modelo só precisa entregar logits de segmentação.
with torch.autocast(
device_type="cuda",
dtype=torch.float16,
enabled=use_amp_now
):
out = self.model(
pixel_values=img_tensor,
output_hidden_states=False
)
t_model = time.perf_counter()
logits_seg = out.logits
t_arg0 = time.perf_counter()
pred_low = torch.argmax(logits_seg, dim=1)[0]
pred_low_np = pred_low.detach().cpu().numpy().astype(np.uint8)
pred_ids_np = cv2.resize(
pred_low_np,
(roi_resized.shape[1], roi_resized.shape[0]),
interpolation=cv2.INTER_NEAREST
)
t_arg1 = time.perf_counter()
# Reaproveita o último status conhecido.
# Para o teste inicial, pode ser None mesmo.
aux_result = getattr(self, "_last_aux_result", None)
if aux_result is not None:
aux_result = dict(aux_result)
aux_result["stale"] = True
aux_result["age_ms"] = (
time.time() - getattr(self, "_last_aux_ts", time.time())
) * 1000.0
self.last_infer = time.time()
t_end = time.perf_counter()
if getattr(self, "debug_timing", False):
print(
f"RUNNER_SEG_ONLY | "
f"crop={(t_crop - t0) * 1000:.1f}ms | "
f"pre={(t_pre - t_crop) * 1000:.1f}ms | "
f"model={(t_model - t_pre) * 1000:.1f}ms | "
f"arg={(t_arg1 - t_arg0) * 1000:.1f}ms | "
f"total={(t_end - t0) * 1000:.1f}ms"
)
return pred_ids_np, self.last_infer, roi_resized, (y_fim, y_inicio), aux_result
if isinstance(raw, list):
return {i: str(name) for i, name in enumerate(raw)}
if isinstance(raw, dict):
result = {}
for k, v in raw.items():
try:
result[int(k)] = str(v)
except Exception:
pass
return result
return {}

View File

@ -37,6 +37,8 @@ class GpuPriorityController:
good_cycles_to_recover: int = 5,
min_data_age_s: float = 0.0,
max_data_age_s: float = 3.0,
ignore_inactive_weed: bool = True,
weed_inactive_age_s: float = 5.0,
log_periodic: bool = False,
log_warnings: bool = True,
):
@ -118,6 +120,9 @@ class GpuPriorityController:
"data_ok": False,
}
self.ignore_inactive_weed = bool(ignore_inactive_weed)
self.weed_inactive_age_s = float(weed_inactive_age_s)
self.log_periodic = bool(log_periodic)
self.log_warnings = bool(log_warnings)
self._last_warning_reason = None
@ -264,6 +269,18 @@ class GpuPriorityController:
now = time.time()
if not isinstance(ctx, dict) or not ctx:
if self.ignore_inactive_weed:
return {
"health": 1.0,
"reason": "weed_inativo_sem_ctx",
"fps": {},
"ratios": {},
"gpu_ms": 0.0,
"data_age_s": None,
"data_ok": False,
"weed_active": False,
}
return {
"health": 0.0,
"reason": "sem_ctx_weed",
@ -272,6 +289,7 @@ class GpuPriorityController:
"gpu_ms": 0.0,
"data_age_s": None,
"data_ok": False,
"weed_active": False,
}
perf = self._extract_perf_root(ctx)
@ -282,6 +300,36 @@ class GpuPriorityController:
if ts:
data_age_s = max(0.0, now - float(ts))
# Se tem timestamp velho demais, considera weed parado/inativo.
if (
self.ignore_inactive_weed
and data_age_s is not None
and data_age_s > self.weed_inactive_age_s
):
return {
"health": 1.0,
"reason": "weed_inativo_timestamp_velho",
"fps": {},
"ratios": {},
"gpu_ms": 0.0,
"data_age_s": data_age_s,
"data_ok": False,
"weed_active": False,
}
# Se não tem timestamp, mas tem contexto velho/estranho, também não deve derrubar visual.
if self.ignore_inactive_weed and ts is None:
return {
"health": 1.0,
"reason": "weed_inativo_sem_timestamp",
"fps": {},
"ratios": {},
"gpu_ms": 0.0,
"data_age_s": None,
"data_ok": False,
"weed_active": False,
}
data_ok = True
if data_age_s is not None:
data_ok = self.min_data_age_s <= data_age_s <= self.max_data_age_s
@ -304,7 +352,6 @@ class GpuPriorityController:
target = max(float(target), 0.01)
ratios[name] = max(0.0, min(1.5, float(real) / target))
# O gargalo manda. Se um deles despencou, saúde despenca.
valid_ratios = [v for v in ratios.values() if v is not None]
health = min(valid_ratios) if valid_ratios else 0.0
@ -318,8 +365,6 @@ class GpuPriorityController:
health = 0.0
reason = "weed_sem_inferencia"
# Latência GPU como alarme extra.
# Não derruba direto para zero, mas limita a saúde.
if gpu_ms >= 120:
health = min(health, 0.35)
reason = "gpu_ms_muito_alto"
@ -338,6 +383,7 @@ class GpuPriorityController:
"gpu_ms": float(gpu_ms or 0.0),
"data_age_s": data_age_s,
"data_ok": bool(data_ok),
"weed_active": True,
}
def _extract_perf_root(self, ctx: Dict[str, Any]) -> Dict[str, Any]:
@ -507,6 +553,11 @@ class GpuPriorityController:
def _log_warning_if_needed(self):
h = self._last_health
if h.get("weed_active") is False:
self._last_warning_reason = None
return
reason = h.get("reason", "ok")
health = float(h.get("health", 1.0) or 0.0)

View File

@ -1,142 +1,665 @@
import os
import threading
import time
from visual_worker.camera_manager import CameraManager
from shared.contexto_global_redis import ContextoGlobalRedis
module_id = "visual"
topico_tx = f"operador/{module_id}/tx"
topico_rx = f"operador/{module_id}/rx"
debug = True
ultima_atualizacao = time.time()
def mostrar_log(mensagem):
if debug:
print(f"{time.time()} - [{module_id}] {mensagem}")
manager = CameraManager(mostrar_log)
def get_camera_manager():
return manager
def iniciar_camera_manager(mx_id):
global manager, ultima_atualizacao
if mx_id is None:
return
agora = time.time()
if (not manager.iniciando and (agora - ultima_atualizacao) > 5 and (manager.camera is None or manager.mx_id != mx_id)):
if (
not manager.iniciando
and (agora - ultima_atualizacao) > 5
and (manager.camera is None or manager.mx_id != mx_id)
):
ultima_atualizacao = agora
mostrar_log("Iniciando Camera Manager...")
manager.inicializar(mx_id=mx_id)
if manager.camera is not None and manager.operante:
mostrar_log(f"✅ Camera manager iniciado, com MX_ID: {mx_id}")
_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
_CONFIG_CACHE = None
_CONFIG_MTIME = None
_CONFIG_LOCK = threading.Lock()
def load_seg_config(force_reload=False):
global _CONFIG_CACHE, _CONFIG_MTIME
with _CONFIG_LOCK:
_CONFIG_CACHE = {
# ============================================================
# CONFIG V1 - VISUAL WORKER
# ============================================================
def construir_mode_configs_adaptativos(cfg: dict) -> dict:
gpu = cfg.get("gpu_priority", {}) or {}
base = {
"segmentacao": float(cfg.get("inferencia_fps", 8.0)),
"grid": float(cfg.get("grid_fps", 6.0)),
"deteccao": float(cfg.get("deteccao_fps", 5.0)),
"publicacao": float(cfg.get("publicacao_fps", 15.0)),
"analise": float(cfg.get("analise_fps", 8.0)),
}
if not bool(gpu.get("adaptive_mode_configs", True)):
return gpu.get("mode_configs", {
"normal": base,
"eco": base,
"safe": base,
"critical": gpu.get("critical_mode", base),
})
scales = gpu.get("mode_scales", {}) or {}
floors = gpu.get("mode_floors", {}) or {}
def scaled_mode(mode_name: str, scale: float) -> dict:
floor = floors.get(mode_name, {}) or {}
return {
nome: max(
float(floor.get(nome, 0.0)),
round(float(valor) * float(scale), 3),
)
for nome, valor in base.items()
}
critical = gpu.get("critical_mode", {}) or {
"segmentacao": 0.5,
"grid": 1.0,
"deteccao": 2.0,
"publicacao": 5.0,
"analise": 2.0,
}
return {
"normal": dict(base),
"eco": scaled_mode("eco", float(scales.get("eco", 0.65))),
"safe": scaled_mode("safe", float(scales.get("safe", 0.35))),
"critical": {
"segmentacao": float(critical.get("segmentacao", 0.5)),
"grid": float(critical.get("grid", 1.0)),
"deteccao": float(critical.get("deteccao", 2.0)),
"publicacao": float(critical.get("publicacao", 5.0)),
"analise": float(critical.get("analise", 2.0)),
},
}
VISUAL_DEFAULT_CONFIG = {
# ============================================================
# 1) Debug e telemetria
# ============================================================
# Mostra janelas OpenCV/debug visual. Não usar em runtime de campo.
"debug_visual": False,
"debug_perf": False,
# Publica logs de performance no console.
"debug_perf": True,
# Tamanho dos frames de preview/debug enviados ao C#.
"preview_size": (1280, 720),
# ============================================================
# 2) Frequências dos loops
# ============================================================
# Loop supervisor: saúde, performance e GPU controller.
"analise_fps": 15.0,
# Loop de segmentação ONNX/TensorRT.
"inferencia_fps": 15.0,
# Loop de detecção onboard MobileNet-SSD na OAK.
"deteccao_fps": 5.0,
# Loop de construção da matriz de confiança/custo.
"grid_fps": 15.0,
# Loop de publicação Redis.
"publicacao_fps": 5.0,
# ============================================================
# 3) Modelo ONNX/TensorRT - segmentação de ruas/corredor
# ============================================================
# Runtime oficial da v1.
"runtime_backend": "onnx",
"onnx_provider": "tensorrt",
# Resolução esperada pelo ONNX: [W, H].
"ia_resolution": [1024, 576],
# ROI vertical da segmentação.
# 0.0 + 1.0 = frame inteiro.
"ia_roi_begin": 0.0,
"ia_roi_size": 1.0,
"analise_fps": 8.0,
"grid_fps": 5.0,
"inferencia_fps": 8.0,
"deteccao_fps": 5.0,
"publicacao_fps": 15.0,
# Contrato validado do modelo modelseg-2_0.onnx:
# entrada: float32 [1, 3, 576, 1024], RGB 0..1, NCHW
# saída 1: semantic_logits
# saída 2: label_probs
"onnx_has_preprocess": False,
"onnx_input_layout": "nchw_float",
"onnx_input_scale": 1.0 / 255.0,
"onnx_seg_output_format": "logits",
# Nomes das entradas/saídas.
# input_name None deixa o runner detectar automaticamente.
"onnx_input_name": None,
"onnx_seg_output_name": "semantic_logits",
"onnx_aux_output_name": "label_probs",
"onnx_aux_output_format": "probs",
# Auxiliar do status de corredor.
# True = retorna label_id, label_name, label_conf.
# False = inclui também vetor de probabilidades.
"use_compact_aux": True,
# Logs internos do runner ONNX.
"debug_timing": False,
"debug_session": True,
# ============================================================
# 4) TensorRT
# ============================================================
"trt_fp16_enable": True,
"trt_engine_cache_enable": True,
"trt_engine_cache_path": "./trt_cache_visual_worker",
# Deixe None salvo se não quiser fixar workspace.
"trt_max_workspace_size": None,
# Threads do ONNX Runtime.
# Para TensorRT/CUDA, 1 costuma ser suficiente e evita ruído.
"onnx_intra_op_num_threads": 1,
"onnx_inter_op_num_threads": 1,
# ============================================================
# 5) SegmentacaoManager v1
# ============================================================
# Este bloco controla apenas a análise da máscara:
# pred_ids + label_probs -> dados_visuais.
"segmentacao": {
# Inclui debug textual no payload de segmentação.
# Não gera imagem.
"include_debug": False,
# Inclui timing interno do SegmentacaoManager.
"debug_timing": False,
# ID das classes no modelo de segmentação.
"id_nao_navegavel": 0,
"id_navegavel": 1,
# Frações verticais usadas para estimar centro/ângulo do corredor.
# 0.0 = topo, 1.0 = base.
"scanline_fracs": (0.96, 0.86, 0.74, 0.62, 0.50, 0.38, 0.26),
# A região próxima ao robô está na base da imagem.
"near_is_bottom": True,
# Suavização das saídas usadas pelo controle.
"ema_alpha_ang": 0.25,
"ema_alpha_lat": 0.25,
"ema_alpha_conf": 0.20,
# Histerese temporal do status do corredor.
"status_window_s": 1.5,
"status_expected_fps": 10.0,
# Confiança da cabeça auxiliar ONNX.
# >= accept: modelo manda.
# >= soft: modelo ajuda quando heurística está indefinida.
"model_conf_accept": 0.70,
"model_conf_soft": 0.45,
# Grid leve interna para centro/fallback do corredor.
"corridor_grid_rows": 6,
"corridor_grid_cols": 21,
"corridor_jump_penalty": 0.60,
# Runs navegáveis nas scanlines.
"min_run_width_frac": 0.035,
"prefer_center_weight": 1.10,
"prefer_prev_weight": 0.65,
"prefer_width_weight": 1.00,
# Heurística de status quando o modelo auxiliar não está confiante.
"thr_parado_global": 0.30,
"thr_direcionando_global": 0.82,
"thr_caminhando_score": 0.48,
"thr_entrando_near": 0.78,
"thr_saindo_far": 0.70,
},
# ============================================================
# 6) Grid de confiança/custo
# ============================================================
"grid": {
# Formato global da grid: (cols, rows).
"grid_shape": (15, 10),
# Geometria da câmera para gerar grid_ref por linha.
"geometry": {
"grid_shape": (15, 10),
# Inclinação fixa da câmera em relação ao solo.
"camera_pitch_deg": 28.91,
# Altura física da câmera.
"camera_height_m": 0.74,
# FOV vertical usado na aproximação geométrica.
"fov_v_deg": 43.28,
# Correção dinâmica usando IMU.
"pitch_gain": 1.0,
"pitch_limit_deg": 10.0,
# Saturação de distâncias plausíveis.
"min_dist_m": 0.2,
"max_dist_m": 20.0,
},
# Construção da matriz de confiança/custo por célula.
"confidence": {
# Faixa válida do depth em mm.
"valid_mm": (300, 10000),
# Fração mínima de pixels depth válidos na célula.
"min_valid_frac": 0.30,
# Mapeamento de fração válida de depth para confiança.
"conf_params": (0.30, 0.80),
# Pesos do custo:
# nao_navegavel, anomalia, incerteza.
"weights": (0.65, 0.25, 0.10),
# Usa depth para anomalia física.
"usar_depth": True,
# Obstáculo/saliência: algo mais perto que o esperado.
"anom_tau_up": 0.15,
"anom_satur_up_m": 0.35,
# Buraco/queda: algo mais longe que o esperado.
"anom_tau_down": 0.30,
"anom_satur_down_m": 0.55,
"anom_down_weight": 0.65,
# Regras para célula navegável.
"min_pct_navegavel": 0.55,
"max_anom_navegavel": 0.45,
"min_conf_navegavel": 0.35,
# Integração de detecções onboard na grid.
"det": {
# Peso da detecção no custo suave.
"w4": 0.18,
# Acima disso, detecção crítica pode bloquear navegabilidade.
"thr_det_soft": 0.45,
# Cobertura mínima da bbox sobre uma célula.
"min_cell_coverage": 0.10,
# Confiança mínima da detecção.
"min_det_conf": 0.45,
# Pesos por classe.
"class_weights": {
"person": 1.0,
"dog": 0.7,
"cat": 0.5,
},
# Classes críticas para segurança.
"veto_labels": {"person"},
# Como combinar múltiplas detecções na mesma célula.
# "max" ou "sum_clamped".
"combine": "max",
# v1: detecção não derruba confiança, só custo/navegabilidade.
"conf_drop_alpha": 0.0,
# Se True, só classes veto bloqueiam navegabilidade.
"only_veto_blocks_nav": True,
# Classes não críticas pesam menos no custo.
"non_veto_cost_scale": 0.50,
},
},
# Fusão temporal e decisão de bloqueio.
"fuser": {
"K": 3,
"M": 2,
"central_cols": None,
"y_range_m": (0.5, 5.0),
"near_is_bottom": True,
"margin_m": 0.12,
"fuse_cost": "ema",
"fuse_anom": "ema",
"fuse_conf": "mean",
"fuse_det": "ema",
"ema_alpha": 0.60,
"thr_anom_block": 0.50,
"thr_cost_block": 0.70,
"thr_conf_low": 0.35,
"thr_nav_low": 0.45,
"rho_block_central": 0.70,
"rho_conf_blackout_global": 0.40,
"thr_det_consider": 0.25,
"thr_det_block": 0.45,
# MobileNet-SSD: person normalmente é ID 15 no labelmap usado.
"det_veto_label_ids": {15},
"use_persistence": True,
"a_max_freio": 0.20,
"margem_parada_m": 0.60,
"stop_on_frames": 2,
"stop_off_frames": 3,
"use_trend": True,
"trend_alpha": 0.35,
"trend_watch": 0.38,
"trend_slowdown": 0.58,
"trend_prepare_stop": 0.78,
"include_debug": True,
},
},
# ============================================================
# 7) GPU Priority Controller
# ============================================================
# Controla FPS do Visual Worker conforme saúde do Weed Worker.
"gpu_priority": {
"enabled": True,
"update_interval_s": 1.0,
"log_interval_s": 3.0,
"log_periodic": False,
"log_warnings": True,
# Se o weed estiver parado/inativo, não rebaixa o visual.
"ignore_inactive_weed": True,
"weed_inactive_age_s": 5.0,
# Histerese dos modos.
"bad_cycles_to_degrade": 3,
"good_cycles_to_recover": 5,
"max_data_age_s": 3.0,
"mode_configs": {
"normal": {
"segmentacao": 8.0,
"grid": 6.0,
"deteccao": 5.0,
"publicacao": 15.0,
"analise": 8.0
# FPS por modo do Visual Worker.
# Se adaptive_mode_configs=True, os modos normal/eco/safe
# são calculados a partir dos FPS principais definidos no bloco 2.
"adaptive_mode_configs": True,
# Multiplicadores dos modos intermediários.
# normal sempre usa os FPS principais.
"mode_scales": {
"eco": 0.65,
"safe": 0.35,
},
# Pisos mínimos dos modos intermediários.
"mode_floors": {
"eco": {
"segmentacao": 4.0,
"grid": 4.0,
"deteccao": 5.0,
"publicacao": 10.0,
"analise": 6.0
"deteccao": 3.0,
"publicacao": 5.0,
"analise": 6.0,
},
"safe": {
"segmentacao": 2.0,
"grid": 2.0,
"deteccao": 3.0,
"deteccao": 2.0,
"publicacao": 5.0,
"analise": 4.0
"analise": 4.0,
},
"critical": {
},
# Modo crítico fica fixo, como piso de sobrevivência.
"critical_mode": {
"segmentacao": 0.5,
"grid": 1.0,
"deteccao": 2.0,
"publicacao": 5.0,
"analise": 2.0
}
}
"analise": 2.0,
},
},
}
"ia_resolution": [1024,576],
"seg_every_n": 1,
"det_every_n": 1,
"use_amp": True,
"use_channels_last": True,
"use_compact_aux": True,
"debug_timing": False,
"runtime_fast": True,
"gerar_mask_color": False,
"gerar_debug_status": False,
"usar_connected_components": True,
"usar_corridor_grid": True
}
_equipamento = ContextoGlobalRedis.get_equipamento()
_CONFIG_CACHE["ia_mode"] = _equipamento.get("ia_mode_ruas")
_CONFIG_CACHE["ia_backbone"] = _equipamento.get("ia_backbone_ruas_seg")
_CONFIG_CACHE["ia_model_path"] = _equipamento.get("path_ia_model_ruas_seg")
_CONFIG_CACHE["ia_labelmap_path"] = _equipamento.get("path_ia_labelmap_ruas_seg")
_CONFIG_CACHE["ia_norm_stats_path"] = _equipamento.get("path_ia_norm_stats_ruas_seg")
return _CONFIG_CACHE
def reload_seg_config():
return load_seg_config(force_reload=True)
def load_det_config():
_CONFIG_DET = {
DET_DEFAULT_CONFIG = {
# ============================================================
# 1) Debug
# ============================================================
"debug_visual": False,
# Tracker onboard/offboard. Desligado no baseline.
"com_track": False,
# ============================================================
# 2) Detector OAK MobileNet-SSD
# ============================================================
"ia_roi_begin": 0.0,
"ia_roi_size": 1.0,
"ia_resolution": [300,300],
"seg_every_n": 1,
"det_every_n": 1,
# Resolução do modelo detector.
"ia_resolution": [300, 300],
# Confiança mínima do detector.
"ia_conf": 0.5,
# Classes do MobileNet-SSD.
"classes": [
"background",
"aeroplane","bicycle","bird","boat","bottle",
"bus","car","cat","chair","cow",
"diningtable","dog","horse","motorbike","person",
"pottedplant","sheep","sofa","train","tvmonitor",
]
}
_CONFIG_DET["ia_model_path"] = ContextoGlobalRedis.get_equipamento().get("path_ia_model_ruas_det", "C:/AgroBaseModels/Ruas/modeldet-1_0.blob")
return _CONFIG_DET
"aeroplane", "bicycle", "bird", "boat", "bottle",
"bus", "car", "cat", "chair", "cow",
"diningtable", "dog", "horse", "motorbike", "person",
"pottedplant", "sheep", "sofa", "train", "tvmonitor",
],
}
# ============================================================
# Overrides Redis / runtime
# ============================================================
def aplicar_overrides_redis_seg(cfg: dict) -> dict:
equipamento = ContextoGlobalRedis.get_equipamento()
# Modelo ONNX oficial de ruas/corredor.
cfg["ia_onnx_path"] = equipamento.get("path_ia_model_ruas_seg")
# Labelmap da segmentação.
cfg["ia_labelmap_path"] = equipamento.get("path_ia_labelmap_ruas_seg")
return cfg
def normalizar_config_runtime_seg(cfg: dict) -> dict:
# Alias único interno, para logs e validações.
cfg["onnx_model_path"] = cfg.get("ia_onnx_path")
if not cfg.get("ia_onnx_path"):
mostrar_log("[WARN] path do modelo ONNX de ruas não definido no Redis/equipamento.")
if not cfg.get("ia_labelmap_path"):
mostrar_log("[WARN] path do labelmap de ruas não definido no Redis/equipamento.")
# Garante tuplas onde as dataclasses esperam tuplas.
cfg["preview_size"] = tuple(cfg.get("preview_size", (1280, 720)))
grid = cfg.get("grid", {}) or {}
if "grid_shape" in grid:
grid["grid_shape"] = tuple(grid["grid_shape"])
geometry = grid.get("geometry", {}) or {}
if "grid_shape" in geometry:
geometry["grid_shape"] = tuple(geometry["grid_shape"])
confidence = grid.get("confidence", {}) or {}
if "valid_mm" in confidence:
confidence["valid_mm"] = tuple(confidence["valid_mm"])
if "conf_params" in confidence:
confidence["conf_params"] = tuple(confidence["conf_params"])
if "weights" in confidence:
confidence["weights"] = tuple(confidence["weights"])
det = confidence.get("det", {}) or {}
if "veto_labels" in det and not isinstance(det["veto_labels"], set):
det["veto_labels"] = set(det["veto_labels"])
fuser = grid.get("fuser", {}) or {}
if "y_range_m" in fuser:
fuser["y_range_m"] = tuple(fuser["y_range_m"])
cfg["grid"] = grid
segmentacao = cfg.get("segmentacao", {}) or {}
if "scanline_fracs" in segmentacao:
segmentacao["scanline_fracs"] = tuple(segmentacao["scanline_fracs"])
cfg["segmentacao"] = segmentacao
gpu = cfg.get("gpu_priority", {}) or {}
gpu["mode_configs"] = construir_mode_configs_adaptativos(cfg)
cfg["gpu_priority"] = gpu
fuser = grid.get("fuser", {}) or {}
if "y_range_m" in fuser:
fuser["y_range_m"] = tuple(fuser["y_range_m"])
if "central_cols" in fuser and fuser["central_cols"] is not None:
fuser["central_cols"] = tuple(fuser["central_cols"])
if "det_veto_label_ids" in fuser and not isinstance(fuser["det_veto_label_ids"], set):
fuser["det_veto_label_ids"] = set(fuser["det_veto_label_ids"])
return cfg
def aplicar_overrides_redis_det(cfg: dict) -> dict:
equipamento = ContextoGlobalRedis.get_equipamento()
cfg["ia_model_path"] = equipamento.get(
"path_ia_model_ruas_det",
"C:/AgroBaseModels/Ruas/modeldet-1_0.blob",
)
return cfg
def normalizar_config_runtime_det(cfg: dict) -> dict:
if not cfg.get("ia_model_path"):
mostrar_log("[WARN] path do modelo detector de ruas não definido.")
return cfg
# ============================================================
# Loaders públicos
# ============================================================
def load_seg_config():
global _CONFIG_CACHE
with _CONFIG_LOCK:
cfg = dict(VISUAL_DEFAULT_CONFIG)
# Cópia profunda simples dos blocos aninhados.
# Evita compartilhar dict interno entre chamadas.
cfg["segmentacao"] = dict(VISUAL_DEFAULT_CONFIG["segmentacao"])
cfg["grid"] = {
"grid_shape": VISUAL_DEFAULT_CONFIG["grid"]["grid_shape"],
"geometry": dict(VISUAL_DEFAULT_CONFIG["grid"]["geometry"]),
"confidence": dict(VISUAL_DEFAULT_CONFIG["grid"]["confidence"]),
"fuser": dict(VISUAL_DEFAULT_CONFIG["grid"]["fuser"]),
}
cfg["grid"]["confidence"]["det"] = dict(
VISUAL_DEFAULT_CONFIG["grid"]["confidence"]["det"]
)
cfg["grid"]["confidence"]["det"]["class_weights"] = dict(
VISUAL_DEFAULT_CONFIG["grid"]["confidence"]["det"]["class_weights"]
)
cfg["grid"]["confidence"]["det"]["veto_labels"] = set(
VISUAL_DEFAULT_CONFIG["grid"]["confidence"]["det"]["veto_labels"]
)
cfg["gpu_priority"] = dict(VISUAL_DEFAULT_CONFIG["gpu_priority"])
cfg["gpu_priority"]["mode_scales"] = dict(
VISUAL_DEFAULT_CONFIG["gpu_priority"].get("mode_scales", {})
)
cfg["gpu_priority"]["mode_floors"] = {
k: dict(v)
for k, v in VISUAL_DEFAULT_CONFIG["gpu_priority"].get("mode_floors", {}).items()
}
cfg["gpu_priority"]["critical_mode"] = dict(
VISUAL_DEFAULT_CONFIG["gpu_priority"].get("critical_mode", {})
)
if "mode_configs" in VISUAL_DEFAULT_CONFIG["gpu_priority"]:
cfg["gpu_priority"]["mode_configs"] = {
k: dict(v)
for k, v in VISUAL_DEFAULT_CONFIG["gpu_priority"]["mode_configs"].items()
}
cfg = aplicar_overrides_redis_seg(cfg)
cfg = normalizar_config_runtime_seg(cfg)
_CONFIG_CACHE = cfg
return _CONFIG_CACHE
def load_det_config():
cfg = dict(DET_DEFAULT_CONFIG)
cfg = aplicar_overrides_redis_det(cfg)
cfg = normalizar_config_runtime_det(cfg)
return cfg

View File

@ -0,0 +1,285 @@
from __future__ import annotations
from typing import Any, Dict, Optional, Sequence, Tuple
import cv2
import numpy as np
from shared.enums import TipoFrameCamera
from shared.utils import converter_mask_ids_para_bgr
from visual_worker.processamento.costmap_fuser import unpack_snapshot
from visual_worker.utils import gerar_heatmap
class VisualDebugRenderer:
def __init__(self, color_map=None, preview_size=(1280, 720)):
self.color_map = color_map
self.preview_size = tuple(preview_size)
self._ultimo_preview_ts = 0.0
self._ultimo_rgb = None
self._ultimo_seg = None
self._ultimo_overlay = None
self._ultimo_heatmap = None
self._ultimo_grid = None
self._ultimo_dets = None
def get_selected_frame(
self,
frame_type: TipoFrameCamera,
rgb_frame=None,
pred_ids=None,
depth_frame=None,
detections=None,
snapshot=None,
camera_params=None,
alpha=0.50,
):
if frame_type == TipoFrameCamera.Rgb:
return self._resize_rgb(rgb_frame)
if frame_type == TipoFrameCamera.Segmentacao:
return self.build_segmentation_preview(pred_ids)
if frame_type == TipoFrameCamera.Overlay:
return self.build_overlay(rgb_frame, pred_ids, alpha=alpha)
if frame_type == TipoFrameCamera.Heatmap:
distancia_maxima = 10000
if camera_params:
distancia_maxima = camera_params.get("distancia_maxima", distancia_maxima)
return self.build_heatmap(depth_frame, distancia_maxima=distancia_maxima)
if frame_type == TipoFrameCamera.MatrizCusto:
return self.build_costmap_debug(rgb_frame, snapshot)
if frame_type == TipoFrameCamera.Deteccoes:
return self.build_detection_overlay(rgb_frame, detections)
if frame_type == TipoFrameCamera.Debug:
overlay = self.build_overlay(rgb_frame, pred_ids, alpha=alpha)
grid = self.build_costmap_debug(rgb_frame, snapshot)
return grid if grid is not None else overlay
return None
def _resize_rgb(self, rgb_frame):
if rgb_frame is None:
return None
img = rgb_frame.copy()
if img.shape[1::-1] != self.preview_size:
img = cv2.resize(img, self.preview_size, interpolation=cv2.INTER_AREA)
return img
def build_segmentation_preview(self, pred_ids):
if pred_ids is None or self.color_map is None:
return None
pred_ids = np.asarray(pred_ids)
if pred_ids.ndim == 3 and pred_ids.shape[-1] == 1:
pred_ids = pred_ids[..., 0]
seg_bgr = converter_mask_ids_para_bgr(pred_ids, self.color_map)
if seg_bgr.shape[1::-1] != self.preview_size:
seg_bgr = cv2.resize(seg_bgr, self.preview_size, interpolation=cv2.INTER_NEAREST)
self._ultimo_seg = seg_bgr
return seg_bgr
def build_overlay(self, rgb_frame, pred_ids, alpha=0.50):
if rgb_frame is None or pred_ids is None or self.color_map is None:
return None
rgb = self._resize_rgb(rgb_frame)
seg = self.build_segmentation_preview(pred_ids)
if rgb is None or seg is None:
return None
a = float(np.clip(alpha, 0.0, 1.0))
overlay = cv2.addWeighted(rgb, 1.0 - a, seg, a, 0)
self._ultimo_overlay = overlay
return overlay
def build_heatmap(self, depth_frame, distancia_maxima=10000):
if depth_frame is None:
return None
heatmap = gerar_heatmap(depth_frame, distancia_maxima)
if heatmap.shape[1::-1] != self.preview_size:
heatmap = cv2.resize(heatmap, self.preview_size, interpolation=cv2.INTER_AREA)
self._ultimo_heatmap = heatmap
return heatmap
def build_detection_overlay(self, rgb_frame, dets, conf_thr=0.5):
if rgb_frame is None:
return None
img = self._resize_rgb(rgb_frame)
if img is None:
return None
if not dets:
self._ultimo_dets = img
return img
H, W = img.shape[:2]
palette = [
(255, 56, 56), (255, 157, 151), (72, 249, 10),
(0, 255, 0), (0, 0, 255), (255, 0, 255),
(0, 255, 255), (255, 191, 0), (52, 148, 230),
(147, 112, 219),
]
for d in dets:
if float(d.get("conf", 0.0)) < conf_thr:
continue
bbox = d.get("bbox_norm")
bbox_full = d.get("bbox_full") or d.get("bbox_px")
if bbox_full:
x0, y0, x1, y1 = bbox_full
# Assume origem 1920x1080 quando bbox vem do frame cheio.
sx = W / 1920.0
sy = H / 1080.0
x0 = int(round(x0 * sx))
x1 = int(round(x1 * sx))
y0 = int(round(y0 * sy))
y1 = int(round(y1 * sy))
elif bbox:
x0n, y0n, x1n, y1n = bbox
x0 = int(round(x0n * W))
x1 = int(round(x1n * W))
y0 = int(round(y0n * H))
y1 = int(round(y1n * H))
else:
continue
x0 = max(0, min(W - 1, x0))
x1 = max(0, min(W - 1, x1))
y0 = max(0, min(H - 1, y0))
y1 = max(0, min(H - 1, y1))
if x1 <= x0 or y1 <= y0:
continue
lid = int(d.get("label_id", -1))
color = palette[lid % len(palette)] if lid >= 0 else (0, 255, 0)
cv2.rectangle(img, (x0, y0), (x1, y1), color, 2)
label = d.get("label") or f"id:{lid}"
conf = float(d.get("conf", 0.0))
txt = f"{label} {conf:.2f}"
dist = d.get("distancia_m")
if dist is not None:
txt += f" {float(dist):.2f}m"
self._put_label(img, txt, x0, y0, color)
self._ultimo_dets = img
return img
def build_costmap_debug(self, rgb_frame, snapshot, alpha=0.35):
if rgb_frame is None or snapshot is None:
return None
try:
img = self._resize_rgb(rgb_frame)
custo, conf, anom, nav = unpack_snapshot(snapshot)
mask = self._colorize_costmap(anom, custo, conf)
mask = cv2.resize(mask, self.preview_size, interpolation=cv2.INTER_NEAREST)
vis = cv2.addWeighted(img, 1.0, mask, alpha, 0)
metrics = snapshot.get("block", {}) or {}
blocked = bool(metrics.get("blocked", False))
reason = str(metrics.get("reason", "free"))
d_obs = metrics.get("d_obs_true_min_m")
status_txt = f"{'PARAR' if blocked else 'LIVRE'} | {reason}"
if d_obs is not None:
status_txt += f" | d={float(d_obs):.2f}m"
color = (0, 0, 255) if blocked else (0, 220, 0)
cv2.putText(
vis,
status_txt,
(20, 35),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(0, 0, 0),
4,
cv2.LINE_AA,
)
cv2.putText(
vis,
status_txt,
(20, 35),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
color,
2,
cv2.LINE_AA,
)
self._ultimo_grid = vis
return vis
except Exception:
return None
@staticmethod
def _colorize_costmap(anom_f, custo_f, conf_f, thr_anom=0.50, thr_cost=0.65, thr_conf=0.35):
H, W = anom_f.shape
over = np.zeros((H, W, 3), np.uint8)
masks = [
(anom_f >= thr_anom, (255, 0, 255)),
(custo_f >= thr_cost, (0, 165, 255)),
(conf_f < thr_conf, (255, 0, 0)),
]
for mask, bgr in masks:
if np.any(mask):
tmp = over[mask].astype(np.int16)
tmp += np.array(bgr, dtype=np.int16)
np.clip(tmp, 0, 255, out=tmp)
over[mask] = tmp.astype(np.uint8)
return over
@staticmethod
def _put_label(img, text, x, y, bg):
(tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
y0 = max(0, y - th - 6)
cv2.rectangle(img, (x, y0), (x + tw + 6, y), bg, -1)
cv2.putText(
img,
text,
(x + 3, y - 4),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 0, 0),
1,
cv2.LINE_AA,
)

View File

@ -0,0 +1,456 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Sequence, Tuple
import cv2
import numpy as np
from visual_worker.processamento.segmentacao_semantica import ClassesSegmentacao
@dataclass
class GridGeometryConfig:
grid_shape: Tuple[int, int] = (15, 10) # (cols, rows)
camera_pitch_deg: float = 28.91
camera_height_m: float = 0.74
fov_v_deg: float = 43.28
pitch_gain: float = 1.0
pitch_limit_deg: float = 10.0
min_dist_m: float = 0.2
max_dist_m: float = 20.0
@dataclass
class DetectionGridConfig:
w4: float = 0.18
thr_det_soft: float = 0.45
min_cell_coverage: float = 0.10
min_det_conf: float = 0.45
class_weights: Dict[str, float] = field(default_factory=lambda: {
"person": 1.0,
"dog": 0.7,
"cat": 0.5,
})
veto_labels: set[str] = field(default_factory=lambda: {"person"})
combine: str = "max"
conf_drop_alpha: float = 0.0
only_veto_blocks_nav: bool = True
non_veto_cost_scale: float = 0.50
@dataclass
class GridConfidenceConfig:
valid_mm: Tuple[int, int] = (300, 10000)
min_valid_frac: float = 0.30
conf_params: Tuple[float, float] = (0.30, 0.80)
weights: Tuple[float, float, float] = (0.65, 0.25, 0.10)
usar_depth: bool = True
anom_tau_up: float = 0.15
anom_satur_up_m: float = 0.35
anom_tau_down: float = 0.30
anom_satur_down_m: float = 0.55
anom_down_weight: float = 0.65
min_pct_navegavel: float = 0.55
max_anom_navegavel: float = 0.45
min_conf_navegavel: float = 0.35
det: DetectionGridConfig = field(default_factory=DetectionGridConfig)
class GridReferenceBuilder:
def __init__(self, config: Optional[GridGeometryConfig | Dict[str, Any]] = None):
self.config = self._normalizar_config(config)
self.grid_ref_base = self.gerar_grid_ref(pitch_graus=0.0)
self.grid_ref_atual = self.grid_ref_base.copy()
@staticmethod
def _normalizar_config(config):
if config is None:
return GridGeometryConfig()
if isinstance(config, GridGeometryConfig):
return config
if isinstance(config, dict):
allowed = set(GridGeometryConfig.__dataclass_fields__.keys())
return GridGeometryConfig(**{k: v for k, v in config.items() if k in allowed})
raise TypeError("config inválido para GridReferenceBuilder")
@property
def grid_shape(self) -> Tuple[int, int]:
return self.config.grid_shape
def gerar_grid_ref(self, pitch_graus: float = 0.0) -> np.ndarray:
cfg = self.config
grid_w, grid_h = cfg.grid_shape
pitch_corr = float(np.clip(
pitch_graus * cfg.pitch_gain,
-cfg.pitch_limit_deg,
cfg.pitch_limit_deg,
))
incl_deg = cfg.camera_pitch_deg + pitch_corr
dist = []
for i in range(grid_h):
alpha_v = ((i + 0.5) / grid_h - 0.5) * np.radians(abs(cfg.fov_v_deg))
gamma = np.radians(incl_deg) + alpha_v
gamma = max(gamma, np.radians(2.0))
d = cfg.camera_height_m / np.tan(gamma)
d = float(np.clip(d, cfg.min_dist_m, cfg.max_dist_m))
dist.append(d)
return np.asarray(dist, dtype=np.float32)
def atualizar_por_pitch(self, pitch_graus: float) -> np.ndarray:
try:
ref = self.gerar_grid_ref(pitch_graus=pitch_graus)
grid_h = self.config.grid_shape[1]
if ref is None or len(ref) != grid_h:
return self.grid_ref_atual
if not np.all(np.isfinite(ref)) or np.any(ref <= 0):
return self.grid_ref_atual
self.grid_ref_atual = ref
return self.grid_ref_atual
except Exception:
return self.grid_ref_atual
class VisualGridBuilder:
"""
Constrói a grid de confiança/custo usada pelo CostmapFuser.
Entrada:
- depth_mm
- seg_ids
- grid_ref
- detecções
Saída:
dict com pct_navegavel, conf, anom, custo, navegavel e canais de detecção.
"""
def __init__(self, config: Optional[GridConfidenceConfig | Dict[str, Any]] = None):
self.config = self._normalizar_config(config)
@staticmethod
def _normalizar_config(config):
if config is None:
return GridConfidenceConfig()
if isinstance(config, GridConfidenceConfig):
return config
if isinstance(config, dict):
allowed = set(GridConfidenceConfig.__dataclass_fields__.keys())
data = {k: v for k, v in config.items() if k in allowed}
if isinstance(data.get("det"), dict):
det_allowed = set(DetectionGridConfig.__dataclass_fields__.keys())
data["det"] = DetectionGridConfig(**{
k: v for k, v in data["det"].items() if k in det_allowed
})
return GridConfidenceConfig(**data)
raise TypeError("config inválido para VisualGridBuilder")
def build(
self,
depth_mm: Optional[np.ndarray],
seg_ids: np.ndarray,
grid_ref: np.ndarray,
grid_shape: Tuple[int, int],
deteccoes: Optional[Sequence[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
try:
cfg = self.config
det_cfg = cfg.det
seg_ids = np.asarray(seg_ids)
if seg_ids.ndim != 2:
raise ValueError(f"seg_ids precisa ser 2D, veio {seg_ids.shape}")
grid_w, grid_h = grid_shape
H1, W1 = seg_ids.shape
d_small = None
if cfg.usar_depth and depth_mm is not None:
d_small = cv2.resize(
depth_mm,
(W1, H1),
interpolation=cv2.INTER_NEAREST,
).astype(np.float32)
d_small[
(d_small < cfg.valid_mm[0]) |
(d_small > cfg.valid_mm[1])
] = np.nan
x_edges = np.linspace(0, W1, grid_w + 1, dtype=int)
y_edges = np.linspace(0, H1, grid_h + 1, dtype=int)
pct_navegavel = np.zeros((grid_h, grid_w), np.float32)
pct_nao_navegavel = np.zeros((grid_h, grid_w), np.float32)
z_med = np.full((grid_h, grid_w), np.nan, np.float32)
depth_valid_frac = np.zeros((grid_h, grid_w), np.float32)
det_cov_max = np.zeros((grid_h, grid_w), np.float32)
det_conf_max = np.zeros((grid_h, grid_w), np.float32)
det_score = np.zeros((grid_h, grid_w), np.float32)
det_top_label_id = -np.ones((grid_h, grid_w), np.int32)
det_top_conf = np.zeros((grid_h, grid_w), np.float32)
det_is_veto = np.zeros((grid_h, grid_w), np.float32)
grid_ref = np.asarray(grid_ref, dtype=np.float32)
if grid_ref.ndim == 1:
if grid_ref.shape[0] != grid_h:
raise ValueError(f"grid_ref 1D deve ter len={grid_h}, veio {grid_ref.shape}")
z_ref = np.repeat(grid_ref[:, None], grid_w, axis=1)
else:
z_ref = grid_ref
if z_ref.shape != (grid_h, grid_w):
raise ValueError(f"grid_ref 2D deve ser {(grid_h, grid_w)}, veio {z_ref.shape}")
for j in range(grid_h):
y0, y1 = int(y_edges[j]), int(y_edges[j + 1])
seg_row = seg_ids[y0:y1, :]
depth_row = d_small[y0:y1, :] if d_small is not None else None
for i in range(grid_w):
x0, x1 = int(x_edges[i]), int(x_edges[i + 1])
seg_block = seg_row[:, x0:x1]
n = seg_block.size
if n == 0:
continue
n_nav = np.count_nonzero(seg_block == ClassesSegmentacao.NAVEGAVEL.value)
n_naonav = np.count_nonzero(seg_block == ClassesSegmentacao.NAONAVEGAVEL.value)
pct_navegavel[j, i] = n_nav / n
pct_nao_navegavel[j, i] = n_naonav / n
if depth_row is not None:
depth_block = depth_row[:, x0:x1]
vals = depth_block[~np.isnan(depth_block)]
valid = vals.size
depth_valid_frac[j, i] = valid / n
if valid >= max(int(cfg.min_valid_frac * n), 1):
z_med[j, i] = np.nanmedian(vals) / 1000.0
t0, t1 = cfg.conf_params
conf_seg = np.maximum(pct_navegavel, pct_nao_navegavel)
if cfg.usar_depth and d_small is not None:
conf_dep = np.clip(
(depth_valid_frac - t0) / max(1e-6, (t1 - t0)),
0.0,
1.0,
)
conf_cell = 0.65 * conf_seg + 0.35 * conf_dep
else:
conf_dep = np.zeros_like(conf_seg, dtype=np.float32)
conf_cell = conf_seg.copy()
if deteccoes:
self._rasterizar_deteccoes(
deteccoes=deteccoes,
x_edges=x_edges,
y_edges=y_edges,
W1=W1,
H1=H1,
det_cfg=det_cfg,
det_cov_max=det_cov_max,
det_conf_max=det_conf_max,
det_score=det_score,
det_top_label_id=det_top_label_id,
det_top_conf=det_top_conf,
det_is_veto=det_is_veto,
)
if cfg.usar_depth and d_small is not None:
delta_signed = z_ref - z_med
delta_signed = np.where(np.isnan(z_med), 0.0, delta_signed)
delta_up = np.maximum(delta_signed, 0.0)
anom_up_raw = np.clip(delta_up / max(1e-6, cfg.anom_satur_up_m), 0.0, 1.0)
anom_up = (
anom_up_raw *
(delta_up > cfg.anom_tau_up).astype(np.float32) *
np.maximum(conf_dep, 0.25)
)
delta_down = np.maximum(-delta_signed, 0.0)
anom_down_raw = np.clip(delta_down / max(1e-6, cfg.anom_satur_down_m), 0.0, 1.0)
anom_down = (
anom_down_raw *
(delta_down > cfg.anom_tau_down).astype(np.float32) *
np.maximum(conf_dep, 0.25)
)
anom = np.clip(
np.maximum(anom_up, cfg.anom_down_weight * anom_down),
0.0,
1.0,
)
else:
anom_up = np.zeros_like(pct_navegavel, dtype=np.float32)
anom_down = np.zeros_like(pct_navegavel, dtype=np.float32)
anom = np.zeros_like(pct_navegavel, dtype=np.float32)
nao_navegavel = 1.0 - pct_navegavel
w1, w2, w3 = cfg.weights
custo_base = (
w1 * nao_navegavel +
w2 * anom +
w3 * (1.0 - conf_cell)
)
custo = np.clip(custo_base + det_cfg.w4 * det_score, 0.0, 1.0)
veto_soft_block = (det_is_veto > 0.5) & (det_score >= det_cfg.thr_det_soft)
navegavel = (
(pct_navegavel >= cfg.min_pct_navegavel) &
(anom < cfg.max_anom_navegavel) &
(conf_cell >= cfg.min_conf_navegavel) &
(
~veto_soft_block
if det_cfg.only_veto_blocks_nav
else (det_score < det_cfg.thr_det_soft)
)
)
return {
"pct_navegavel": np.clip(pct_navegavel, 0.0, 1.0),
"pct_nao_navegavel": np.clip(pct_nao_navegavel, 0.0, 1.0),
"z_med": z_med,
"z_ref": z_ref,
"depth_valid_frac": np.clip(depth_valid_frac, 0.0, 1.0),
"conf": np.clip(conf_cell, 0.0, 1.0),
"anom_up": np.clip(anom_up, 0.0, 1.0),
"anom_down": np.clip(anom_down, 0.0, 1.0),
"anom": np.clip(anom, 0.0, 1.0),
"custo": np.clip(custo, 0.0, 1.0),
"navegavel": navegavel.astype(np.uint8),
"det_cov_max": np.clip(det_cov_max, 0.0, 1.0),
"det_conf_max": np.clip(det_conf_max, 0.0, 1.0),
"det_score": np.clip(det_score, 0.0, 1.0),
"det_top_label_id": det_top_label_id,
"det_top_conf": np.clip(det_top_conf, 0.0, 1.0),
"det_is_veto": det_is_veto.astype(np.float32),
}
except Exception:
return None
@staticmethod
def _rasterizar_deteccoes(
deteccoes,
x_edges,
y_edges,
W1,
H1,
det_cfg,
det_cov_max,
det_conf_max,
det_score,
det_top_label_id,
det_top_conf,
det_is_veto,
):
grid_h, grid_w = det_score.shape
for det in deteccoes:
conf = float(det.get("conf", 0.0))
if conf < det_cfg.min_det_conf:
continue
label = str(det.get("label", "")).strip()
label_id = int(det.get("label_id", -1))
is_veto = label in det_cfg.veto_labels
w_class = float(det_cfg.class_weights.get(label, 1.0))
if not is_veto:
w_class *= float(det_cfg.non_veto_cost_scale)
if "bbox_px" in det and det["bbox_px"]:
x0p, y0p, x1p, y1p = det["bbox_px"]
else:
bbox = det.get("bbox_norm")
if not bbox:
continue
x0n, y0n, x1n, y1n = bbox
x0p = int(np.clip(x0n * W1, 0, W1 - 1))
x1p = int(np.clip(x1n * W1, 0, W1))
y0p = int(np.clip(y0n * H1, 0, H1 - 1))
y1p = int(np.clip(y1n * H1, 0, H1))
if x1p <= x0p or y1p <= y0p:
continue
i0 = max(0, np.searchsorted(x_edges, x0p, side="right") - 1)
i1 = min(grid_w - 1, np.searchsorted(x_edges, x1p, side="left"))
j0 = max(0, np.searchsorted(y_edges, y0p, side="right") - 1)
j1 = min(grid_h - 1, np.searchsorted(y_edges, y1p, side="left"))
for j in range(j0, j1 + 1):
y0c, y1c = int(y_edges[j]), int(y_edges[j + 1])
for i in range(i0, i1 + 1):
x0c, x1c = int(x_edges[i]), int(x_edges[i + 1])
ix0 = max(x0c, x0p)
ix1 = min(x1c, x1p)
iy0 = max(y0c, y0p)
iy1 = min(y1c, y1p)
if ix1 <= ix0 or iy1 <= iy0:
continue
inter = float((ix1 - ix0) * (iy1 - iy0))
cell_area = float((x1c - x0c) * (y1c - y0c))
if cell_area <= 0:
continue
cov = inter / cell_area
if cov < det_cfg.min_cell_coverage:
continue
score_local = conf * cov * w_class
det_cov_max[j, i] = max(det_cov_max[j, i], cov)
det_conf_max[j, i] = max(det_conf_max[j, i], conf)
if det_cfg.combine == "sum_clamped":
det_score[j, i] = np.clip(det_score[j, i] + score_local, 0.0, 1.0)
else:
det_score[j, i] = max(det_score[j, i], score_local)
priority = (2.0 if is_veto else 1.0) * conf * cov
if priority > det_top_conf[j, i]:
det_top_conf[j, i] = priority
det_top_label_id[j, i] = label_id
det_is_veto[j, i] = 1.0 if is_veto else 0.0

View File

@ -1,7 +1,5 @@
import time
import json
import threading
import os
from weed_worker.camera_manager import CameraManager
from shared.contexto_global_redis import ContextoGlobalRedis
@ -34,147 +32,209 @@ def iniciar_camera_manager(mx_id):
mostrar_log(f"✅ Camera manager iniciado, com MX_ID: {mx_id}")
_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
_CONFIG_CACHE = None
_CONFIG_MTIME = None
_CONFIG_LOCK = threading.Lock()
def load_seg_config(force_reload=False):
global _CONFIG_CACHE, _CONFIG_MTIME
with _CONFIG_LOCK:
_CONFIG_CACHE = {
WEED_DEFAULT_CONFIG = {
# ============================================================
# 1) Debug e telemetria
# ============================================================
# Mostra janela OpenCV/debug visual. Não usar em runtime de campo.
"debug_visual": False,
"debug_perf": False,
"frames_consecutivos": 3,
"frames_histerese": 2,
"min_area_px": 400,
"max_area_frac": 0.2,
"ia_roi_begin": 0.0,
"ia_roi_size": 1.0,
# Publica logs de performance no console.
"debug_perf": True,
# Inclui tempos internos do WeedDetector no payload de análise.
# Barato e útil nesta fase; pode desligar na versão final final.
"detector_debug_perf": True,
# ============================================================
# 2) Frequências dos loops
# ============================================================
# Pipeline oficial validado em ~25 FPS.
"tensor_fps": 25.0,
"inferencia_fps": 25.0,
"deteccao_fps": 25.0,
# Supervisor/performance. Não precisa ser igual ao pipeline.
"analise_fps": 15.0,
"inferencia_fps": 15.0,
"deteccao_fps": 15.0,
"tensor_fps": 18.0,
"publicacao_fps": 15.0,
"tipo_camera_solo": "multispectral",
# Publicação Redis. Mantém baixo para não virar ruído.
"publicacao_fps": 5.0,
# ============================================================
# 3) Câmera multiespectral
# ============================================================
"camera_width": 1280,
"camera_height": 800,
"fps": 40,
"ia_resolution": [1024,640],
"ia_channels": 5,
"ia_input_channels": ["R", "G", "B", "RE", "NIR"],
"ia_use_ndvi": False,
"amp": True,
"fold_input_norm": True,
# FPS solicitado na câmera/OAK. Pode ser maior que o pipeline.
"camera_fps": 40,
# Tamanho final do tensor entregue ao modelo: [W, H].
"ia_resolution": [1024, 640],
# Ordem oficial do tensor multiespectral.
# Deve bater com o modelo ONNX exportado.
"input_channels": ["R", "G", "B", "RE", "NIR"],
# ============================================================
# 4) Modelo ONNX/TensorRT
# ============================================================
# Runtime oficial da primeira versão.
"runtime_backend": "onnx",
"onnx_provider": "tensorrt",
# Modo operacional: usar diretamente a cabeça target.
"runtime_mode": "target_direct",
"prediction_contract": "target_binary",
"output_mask_fullres": False,
"lowres_argmax": True,
"onnx_output_mode": "target_direct",
# Contrato do ONNX novo:
# entrada 0..1 crua -> normalização interna -> resize -> argmax -> target_mask.
"onnx_output_kind": "mask",
"onnx_preprocess_norm": False,
# TensorRT FP16 validado.
"trt_fp16": True,
# Assume que o tensor já vem float32 CHW 0..1, contíguo e limpo.
"trust_input": True,
"channels_last": False,
"model_half": True,
"sync_for_timing": False,
"torch_compile": False,
"torch_compile_mode": "reduce-overhead",
# Não retornar dict completo no caminho quente.
"return_full_fast": False,
"erva_top_band_frac": 0.30,
"erva_frac_ema": 0.3,
"erva_thresh_vel_gain": 0.4,
# Sincronização CUDA só para debug fino. Deixar False no runtime.
"sync_for_timing": False,
# ============================================================
# 5) Contrato da máscara para o WeedDetector
# ============================================================
# target_binary:
# 0 = fundo / não pulverizar
# 1 = alvo pulverizável
"prediction_contract": "target_binary",
# ============================================================
# 6) Radar global de alvo
# ============================================================
# Gate global: só libera bicos quando existe alvo suficiente no frame.
"usar_radar_global_gate": True,
# Histerese global da fração de alvo no frame completo.
"min_frac_erva_global_on": 0.0020,
"min_frac_erva_global_off": 0.0015,
"min_frac_erva_top_on": 0.0015,
"min_frac_erva_top_off": 0.0010,
"min_frac_erva_por_bico": 0.02,
"usar_morfologia": True,
"kernel_morf": 3,
"usar_radar_global_gate": True,
"max_frac_cana_por_bico": 0.009,
# Suavização temporal da fração global.
"erva_frac_ema": 0.30,
# Ajuste do limiar global pela velocidade.
# 0.0 desativa; valores maiores deixam o gate mais sensível com velocidade.
"erva_thresh_vel_gain": 0.40,
# ============================================================
# 7) Controle por bico
# ============================================================
"qtd_bicos": 7,
# Suavização temporal por bico.
"ema_frac_bico": 0.35,
# Debounce temporal por bico.
"on_frames_required": 3,
"off_frames_required": 2,
"cana_halo_px": 5,
"min_area_erva_px": 80,
"erva_thresh_vel_gain_local": 0.6,
# Ajuste local do limiar por velocidade.
"erva_thresh_vel_gain_local": 0.60,
# Desloca a ROI vertical para compensar latência em movimento.
"k_roi_shift_px_per_vnorm": 24.0,
"heads": {
"semantic": {
"enabled": True,
"type": "multiclass",
"num_classes": 3,
"classes": {"chao": 0, "cana": 1, "erva": 2},
"ignore_index": 255
},
"vegetation": {
"enabled": True,
"type": "binary",
"num_classes": 2,
"classes": {"background": 0, "vegetation": 1},
"ignore_index": 255
},
"cana": {
"enabled": True,
"type": "binary",
"num_classes": 2,
"classes": {"not_cana": 0, "cana": 1},
"ignore_index": 255
},
"target": {
"enabled": True,
"type": "binary",
"num_classes": 2,
"classes": {"background": 0, "target": 1},
"ignore_index": 255
},
}
}
dadosAtu = ContextoGlobalRedis.get_operacao().get("Atu", {})
# ============================================================
# 8) Filtros opcionais da máscara
# ============================================================
# No contrato target_binary, a própria IA já entrega o alvo final.
# Deixar desligado no baseline oficial.
"usar_morfologia": False,
"kernel_morf": 3,
# Remove componentes pequenos. 0 desativa.
"min_area_erva_px": 0,
}
def aplicar_overrides_redis(cfg: dict) -> dict:
dados_atu = ContextoGlobalRedis.get_operacao().get("Atu", {})
contexto = ContextoGlobalRedis.get_contexto()
equipamento = ContextoGlobalRedis.get_equipamento()
_CONFIG_CACHE["qtd_bicos"] = equipamento.get("qtd_bicos")
_CONFIG_CACHE["velocidade_robo"] = contexto.get("Gerais", {}).get("velocidade_ms", 0.0)
_CONFIG_CACHE["ia_model_path"] = ContextoGlobalRedis.get_equipamento().get("path_ia_model_ervas")
_CONFIG_CACHE["ia_labelmap_path"] = ContextoGlobalRedis.get_equipamento().get("path_ia_labelmap_ervas")
_CONFIG_CACHE["ia_norm_stats_path"] = ContextoGlobalRedis.get_equipamento().get("path_ia_norm_stats_ervas")
_CONFIG_CACHE["ia_module_params_path"] = ContextoGlobalRedis.get_equipamento().get("path_ia_module_params_ervas")
_CONFIG_CACHE["ia_backbone"] = ContextoGlobalRedis.get_equipamento().get("ia_backbone_ervas")
cfg["qtd_bicos"] = int(equipamento.get("qtd_bicos") or cfg.get("qtd_bicos", 7) or 7)
_CONFIG_CACHE["faixa_atuacao_bicos"] = dadosAtu.get("percent_vertical_deteccao", 0.7)
_CONFIG_CACHE["area_atuacao_bicos"] = dadosAtu.get("height_area_deteccao", 0.1)
_CONFIG_CACHE["min_frac_erva_por_bico_on"] = dadosAtu.get("pct_erva_bico_on", 0.02)
_CONFIG_CACHE["min_frac_erva_por_bico_off"] = dadosAtu.get("pct_erva_bico_off", 0.01)
# Modelo ONNX full-runtime validado para o Weed Worker.
cfg["ia_model_path"] = equipamento.get("path_ia_model_ervas")
cfg["ia_module_params_path"] = equipamento.get("path_ia_module_params_ervas")
# ============================================================
# Compatibilidade MultiSpecSegformerService
# ============================================================
input_channels = _CONFIG_CACHE.get("ia_input_channels", ["R", "G", "B", "RE", "NIR"])
cfg["velocidade_robo"] = float(
contexto.get("Gerais", {}).get("velocidade_ms", 0.0) or 0.0
)
cfg["faixa_atuacao_bicos"] = float(
dados_atu.get("percent_vertical_deteccao", 0.7)
)
cfg["area_atuacao_bicos"] = float(
dados_atu.get("height_area_deteccao", 0.1)
)
cfg["min_frac_erva_por_bico_on"] = float(
dados_atu.get("pct_erva_bico_on", 0.02)
)
cfg["min_frac_erva_por_bico_off"] = float(
dados_atu.get("pct_erva_bico_off", 0.01)
)
return cfg
def normalizar_config_runtime(cfg: dict) -> dict:
cfg["onnx_model_path"] = cfg.get("ia_model_path")
cfg["module_calibration_json"] = cfg.get("ia_module_params_path")
cfg["camera_fps"] = int(cfg.get("camera_fps", 40))
input_channels = cfg.get("input_channels", ["R", "G", "B", "RE", "NIR"])
if isinstance(input_channels, str):
input_channels = [c.strip().upper() for c in input_channels.split(",") if c.strip()]
else:
input_channels = [str(c).upper() for c in input_channels]
_CONFIG_CACHE["input_channels"] = input_channels
_CONFIG_CACHE["channels"] = int(_CONFIG_CACHE.get("ia_channels") or len(input_channels))
cfg["input_channels"] = input_channels
if _CONFIG_CACHE["channels"] != len(input_channels):
mostrar_log(
f"[WARN] ia_channels={_CONFIG_CACHE['channels']} diferente de "
f"len(input_channels)={len(input_channels)}. Usando len(input_channels)."
)
_CONFIG_CACHE["channels"] = len(input_channels)
if not cfg.get("onnx_model_path"):
mostrar_log("[WARN] path do modelo ONNX de ervas não definido no Redis/equipamento.")
_CONFIG_CACHE["backbone"] = _CONFIG_CACHE.get("ia_backbone") or "nvidia/mit-b1"
_CONFIG_CACHE["ckpt"] = _CONFIG_CACHE.get("ia_model_path")
_CONFIG_CACHE["norm_stats_path"] = _CONFIG_CACHE.get("ia_norm_stats_path")
_CONFIG_CACHE["module_calibration_json"] = (_CONFIG_CACHE.get("ia_module_params_path"))
_CONFIG_CACHE["camera_fps"] = int(_CONFIG_CACHE.get("fps"))
if not cfg.get("module_calibration_json"):
mostrar_log("[WARN] path de calibração/module_params do WeedWorker não definido.")
if cfg["runtime_backend"] == "onnx" and cfg["onnx_preprocess_norm"]:
mostrar_log("[WARN] onnx_preprocess_norm=True não é permitido no modelo full-runtime.")
return cfg
def load_seg_config():
global _CONFIG_CACHE
with _CONFIG_LOCK:
cfg = dict(WEED_DEFAULT_CONFIG)
cfg = aplicar_overrides_redis(cfg)
cfg = normalizar_config_runtime(cfg)
_CONFIG_CACHE = cfg
return _CONFIG_CACHE
def reload_seg_config():
return load_seg_config(force_reload=True)