This commit is contained in:
Diego Freitas 2026-09-16 15:39:38 -03:00
commit deff4dc2db
36 changed files with 30543 additions and 904 deletions

4
.gitignore vendored
View File

@ -93,3 +93,7 @@ AgroBase/AgroBase/bin/x64/Debug/Python/venv/
/Exemplos
/Python/raspi/cam_2/imx296_pi/
/Python/raspi/cam_3/imx296_pi/
Python/OAK/datasets/oak-fcc-3/audit/
/Python/OAK/datasets/oak-fcc-3/benchmarks_async
/Python/OAK/datasets/oak-d/benchmark_corridor
/AgroBase/livox_visual_debugger/x64/Debug

View File

@ -105,7 +105,7 @@ namespace AgroBase.Forms.IHM.Operacao.Parametros
_mapa.TipoMapa = op.Parametros.TipoMapa;
await _mapa.DefinirRuasSelecionadasAsync(ruasSelecionadas, false);
await _mapa.DefinirRuasSelecionadasAsync(op, ruasSelecionadas, false);
lblMapaPlaceholder.Visible = false;

View File

@ -169,7 +169,7 @@ namespace AgroBase.Forms.IHM.Operacao
mapa.TipoMapa = op.Parametros?.TipoMapa ?? Enums.TipoMapaOperacao.Indefinido;
await mapa.DefinirRuasSelecionadasAsync(ruasSelecionadas, false);
await mapa.DefinirRuasSelecionadasAsync(op, ruasSelecionadas, false);
}
else
{

View File

@ -197,7 +197,7 @@ namespace AgroBase.Forms.IHM.Operacao
if (mapaAtual != null)
{
novaOperacao.Mapa = mapaAtual;
await mapaAtual.LimparParametrizacaoMapaAsync();
await mapaAtual.LimparParametrizacaoMapaAsync(novaOperacao);
}
/*

View File

@ -367,6 +367,11 @@
this.tabOrientacao = new System.Windows.Forms.TabPage();
this.pnlInclinacao = new System.Windows.Forms.Panel();
this.pnl3D = new System.Windows.Forms.Panel();
this.pnlOrientacaoDados = new System.Windows.Forms.Panel();
this.txtImuLateral = new System.Windows.Forms.TextBox();
this.txtImuFrontal = new System.Windows.Forms.TextBox();
this.label147 = new System.Windows.Forms.Label();
this.label146 = new System.Windows.Forms.Label();
this.tabSonar = new System.Windows.Forms.TabPage();
this.chbSonarDeteccoes = new System.Windows.Forms.CheckBox();
this.tabControl3 = new System.Windows.Forms.TabControl();
@ -531,11 +536,7 @@
this.label36 = new System.Windows.Forms.Label();
this.txtMomento = new System.Windows.Forms.TextBox();
this.btnPlay = new System.Windows.Forms.Button();
this.pnlOrientacaoDados = new System.Windows.Forms.Panel();
this.label146 = new System.Windows.Forms.Label();
this.label147 = new System.Windows.Forms.Label();
this.txtImuFrontal = new System.Windows.Forms.TextBox();
this.txtImuLateral = new System.Windows.Forms.TextBox();
this.btnCarregarMapa = new System.Windows.Forms.Button();
((System.ComponentModel.ISupportInitialize)(this.trbMomento)).BeginInit();
this.panel1.SuspendLayout();
this.tabControl4.SuspendLayout();
@ -590,6 +591,7 @@
((System.ComponentModel.ISupportInitialize)(this.picRobo)).BeginInit();
this.tabOrientacao.SuspendLayout();
this.pnl3D.SuspendLayout();
this.pnlOrientacaoDados.SuspendLayout();
this.tabSonar.SuspendLayout();
this.tabControl3.SuspendLayout();
this.tabSonarRGB.SuspendLayout();
@ -616,7 +618,6 @@
this.panel6.SuspendLayout();
this.pnlInformacoesOperacao.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.picCameraCaminho)).BeginInit();
this.pnlOrientacaoDados.SuspendLayout();
this.SuspendLayout();
//
// trbMomento
@ -4131,6 +4132,51 @@
this.pnl3D.Size = new System.Drawing.Size(486, 512);
this.pnl3D.TabIndex = 1;
//
// pnlOrientacaoDados
//
this.pnlOrientacaoDados.Controls.Add(this.txtImuLateral);
this.pnlOrientacaoDados.Controls.Add(this.txtImuFrontal);
this.pnlOrientacaoDados.Controls.Add(this.label147);
this.pnlOrientacaoDados.Controls.Add(this.label146);
this.pnlOrientacaoDados.Location = new System.Drawing.Point(1, 362);
this.pnlOrientacaoDados.Name = "pnlOrientacaoDados";
this.pnlOrientacaoDados.Size = new System.Drawing.Size(150, 150);
this.pnlOrientacaoDados.TabIndex = 0;
//
// txtImuLateral
//
this.txtImuLateral.Location = new System.Drawing.Point(6, 87);
this.txtImuLateral.Name = "txtImuLateral";
this.txtImuLateral.Size = new System.Drawing.Size(100, 20);
this.txtImuLateral.TabIndex = 3;
this.txtImuLateral.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
//
// txtImuFrontal
//
this.txtImuFrontal.Location = new System.Drawing.Point(6, 32);
this.txtImuFrontal.Name = "txtImuFrontal";
this.txtImuFrontal.Size = new System.Drawing.Size(100, 20);
this.txtImuFrontal.TabIndex = 1;
this.txtImuFrontal.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
//
// label147
//
this.label147.AutoSize = true;
this.label147.Location = new System.Drawing.Point(3, 71);
this.label147.Name = "label147";
this.label147.Size = new System.Drawing.Size(39, 13);
this.label147.TabIndex = 2;
this.label147.Text = "Lateral";
//
// label146
//
this.label146.AutoSize = true;
this.label146.Location = new System.Drawing.Point(3, 16);
this.label146.Name = "label146";
this.label146.Size = new System.Drawing.Size(39, 13);
this.label146.TabIndex = 1;
this.label146.Text = "Frontal";
//
// tabSonar
//
this.tabSonar.Controls.Add(this.chbSonarDeteccoes);
@ -5875,10 +5921,10 @@
//
// btnCarregarOperacao
//
this.btnCarregarOperacao.Location = new System.Drawing.Point(1180, 587);
this.btnCarregarOperacao.Location = new System.Drawing.Point(1138, 587);
this.btnCarregarOperacao.Margin = new System.Windows.Forms.Padding(2);
this.btnCarregarOperacao.Name = "btnCarregarOperacao";
this.btnCarregarOperacao.Size = new System.Drawing.Size(128, 28);
this.btnCarregarOperacao.Size = new System.Drawing.Size(115, 28);
this.btnCarregarOperacao.TabIndex = 4;
this.btnCarregarOperacao.Text = "Carregar Opereação";
this.btnCarregarOperacao.UseVisualStyleBackColor = true;
@ -5907,7 +5953,7 @@
//
// btnPlay
//
this.btnPlay.Location = new System.Drawing.Point(1120, 587);
this.btnPlay.Location = new System.Drawing.Point(1078, 587);
this.btnPlay.Margin = new System.Windows.Forms.Padding(2);
this.btnPlay.Name = "btnPlay";
this.btnPlay.Size = new System.Drawing.Size(56, 28);
@ -5916,56 +5962,23 @@
this.btnPlay.UseVisualStyleBackColor = true;
this.btnPlay.Click += new System.EventHandler(this.btnPlay_Click);
//
// pnlOrientacaoDados
// btnCarregarMapa
//
this.pnlOrientacaoDados.Controls.Add(this.txtImuLateral);
this.pnlOrientacaoDados.Controls.Add(this.txtImuFrontal);
this.pnlOrientacaoDados.Controls.Add(this.label147);
this.pnlOrientacaoDados.Controls.Add(this.label146);
this.pnlOrientacaoDados.Location = new System.Drawing.Point(1, 362);
this.pnlOrientacaoDados.Name = "pnlOrientacaoDados";
this.pnlOrientacaoDados.Size = new System.Drawing.Size(150, 150);
this.pnlOrientacaoDados.TabIndex = 0;
//
// label146
//
this.label146.AutoSize = true;
this.label146.Location = new System.Drawing.Point(3, 16);
this.label146.Name = "label146";
this.label146.Size = new System.Drawing.Size(39, 13);
this.label146.TabIndex = 1;
this.label146.Text = "Frontal";
//
// label147
//
this.label147.AutoSize = true;
this.label147.Location = new System.Drawing.Point(3, 71);
this.label147.Name = "label147";
this.label147.Size = new System.Drawing.Size(39, 13);
this.label147.TabIndex = 2;
this.label147.Text = "Lateral";
//
// txtImuFrontal
//
this.txtImuFrontal.Location = new System.Drawing.Point(6, 32);
this.txtImuFrontal.Name = "txtImuFrontal";
this.txtImuFrontal.Size = new System.Drawing.Size(100, 20);
this.txtImuFrontal.TabIndex = 1;
this.txtImuFrontal.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
//
// txtImuLateral
//
this.txtImuLateral.Location = new System.Drawing.Point(6, 87);
this.txtImuLateral.Name = "txtImuLateral";
this.txtImuLateral.Size = new System.Drawing.Size(100, 20);
this.txtImuLateral.TabIndex = 3;
this.txtImuLateral.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
this.btnCarregarMapa.Location = new System.Drawing.Point(1257, 587);
this.btnCarregarMapa.Margin = new System.Windows.Forms.Padding(2);
this.btnCarregarMapa.Name = "btnCarregarMapa";
this.btnCarregarMapa.Size = new System.Drawing.Size(77, 28);
this.btnCarregarMapa.TabIndex = 96;
this.btnCarregarMapa.Text = "Mapa";
this.btnCarregarMapa.UseVisualStyleBackColor = true;
this.btnCarregarMapa.Click += new System.EventHandler(this.btnCarregarMapa_Click);
//
// frmResultadosOperacao
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(1345, 632);
this.Controls.Add(this.btnCarregarMapa);
this.Controls.Add(this.btnPlay);
this.Controls.Add(this.txtMomento);
this.Controls.Add(this.label36);
@ -6061,6 +6074,8 @@
((System.ComponentModel.ISupportInitialize)(this.picRobo)).EndInit();
this.tabOrientacao.ResumeLayout(false);
this.pnl3D.ResumeLayout(false);
this.pnlOrientacaoDados.ResumeLayout(false);
this.pnlOrientacaoDados.PerformLayout();
this.tabSonar.ResumeLayout(false);
this.tabSonar.PerformLayout();
this.tabControl3.ResumeLayout(false);
@ -6096,8 +6111,6 @@
this.pnlInformacoesOperacao.ResumeLayout(false);
this.pnlInformacoesOperacao.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.picCameraCaminho)).EndInit();
this.pnlOrientacaoDados.ResumeLayout(false);
this.pnlOrientacaoDados.PerformLayout();
this.ResumeLayout(false);
this.PerformLayout();
@ -6588,5 +6601,6 @@
private System.Windows.Forms.TextBox txtImuFrontal;
private System.Windows.Forms.Label label147;
private System.Windows.Forms.Label label146;
private System.Windows.Forms.Button btnCarregarMapa;
}
}

View File

@ -1,5 +1,6 @@
using AgroBase.Models;
using AgroBase.Models.Modules;
using AgroBase.Models.Operacoes;
using AgroBase.Models.Operadores;
using AgroBase.Properties;
using AgroBase.Services;
@ -12,6 +13,7 @@ using System.Drawing.Drawing2D;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
@ -53,9 +55,14 @@ namespace AgroBase.Forms.Operacoes
DateTime MomentoAtual = DateTime.Now;
AsyncTaskTimerModel tmrPlay;
private List<List<GPSModel>> _ruasMapaReplay = new List<List<GPSModel>>();
private List<PontoTrajetoriaModel> _trajetoriaFixaReplay = new List<PontoTrajetoriaModel>();
MapasModel Mapa = new MapasModel();
private Visualizador3DService visualizador3D;
//TrajetoriaMapaOperacaoModel Trajetoria = new TrajetoriaMapaOperacaoModel(new List<List<GPSModel>>());
private MapaDinamicoModel MapaDinamico;
private Visualizador3DService visualizador3D;
private int idxMomentoAtual
{
get
@ -165,12 +172,46 @@ namespace AgroBase.Forms.Operacoes
LogsOperacao = JsonConvert.DeserializeObject<OperacaoSensoriamentoLogModel[]>(OperacaoModel.DeserializarDadosOperacao(ofd.FileName, data.operacao)).ToList();
LogsGPS = JsonConvert.DeserializeObject<GPSModel[]>(OperacaoModel.DeserializarDadosOperacao(ofd.FileName, Enums.T_Code.Gps.ToString())).ToList();
GPSModel pos = GPSService.UltimaLeitura.Clone();
GPSService.UltimaLeitura = LogsGPS.FirstOrDefault();
bool sucesso = op.CarregarParametrizacaoOperacao(null, null, ofd.FileName.Replace("data.lgop", "operacao.opr"));
bool condicaoOperacaoCarregada() => op.Trajetoria?.CorredorAtual != null;
bool resultado = await FuncoesGlobais.AguardarCondicaoAsync(condicaoOperacaoCarregada, 15000, 200);
GPSService.UltimaLeitura = pos;
string caminhoOpr = Path.Combine(
Path.GetDirectoryName(ofd.FileName),
"operacao.opr"
);
OperacaoParametrosModel parametrosOperacao = null;
if (File.Exists(caminhoOpr))
{
parametrosOperacao =
JsonConvert.DeserializeObject<OperacaoParametrosModel>(
File.ReadAllText(caminhoOpr, Encoding.UTF8)
);
if (parametrosOperacao != null)
{
GPSModel posAnterior =
GPSService.UltimaLeitura?.Clone();
try
{
// Importantíssimo:
// a trajetória será projetada tomando como referência
// a posição inicial real daquela operação.
GPSService.UltimaLeitura =
LogsGPS.FirstOrDefault()?.Clone();
Variaveis.OperacaoEmAndamento = OperacaoModel.CarregarParamerosOperacaoBase(
parametrosOperacao
);
}
finally
{
if (posAnterior != null)
GPSService.UltimaLeitura = posAnterior;
}
}
}
LogsControle = JsonConvert.DeserializeObject<OperacaoControleModel[]>(OperacaoModel.DeserializarDadosOperacao(ofd.FileName, "controle")).ToList();
LogsCooler = JsonConvert.DeserializeObject<CoolerControlDataModel[]>(OperacaoModel.DeserializarDadosOperacao(ofd.FileName, "cooler")).ToList();
@ -1309,28 +1350,60 @@ namespace AgroBase.Forms.Operacoes
pnlOrientacaoGPS.Invalidate();
var _trajaetoria = LogsGPS.Select(x => new PontoTrajetoriaModel(Enums.TipoPontoRua.Indefinido)
{
Posicao = x
})
.ToList();
var LogOpe = LogsOperacao[idxMomentoAtual];
var LogSnr = LogsVisualWorker[idxMomentoAtual];
var LogTrj = LogsTrajetoria[idxMomentoAtual];
var LogCtrl = LogsControle[idxMomentoAtual];
var trajetoriaFixa =
(_trajetoriaFixaReplay?.Count ?? 0) > 0
? _trajetoriaFixaReplay
: Variaveis.OperacaoEmAndamento
?.Trajetoria
?._TrajetoriaFixa
?? new List<PontoTrajetoriaModel>();
var trajetoriaDinamica =
MontarTrajetoriaDinamicaReplay(
Log,
LogTrj
);
var ruasMapa =
(_ruasMapaReplay?.Count ?? 0) > 0
? _ruasMapaReplay
: Variaveis.OperacaoEmAndamento
?.Mapa
?.TrajetoriaMapa
?? new List<List<GPSModel>>();
var rastroRover =
LogsGPS
.Take(idxMomentoAtual + 1)
.ToList();
Variaveis.OperacaoEmAndamento.Trajetoria.LoopAtualizaDados();
MapaDinamico.AtualizarDados(
//(LogSnr?.Iniciado ?? false) && LogSnr.Resumo.DirecaoDesvio != Enums.Direcao.Frente ? LogSnr.Resumo.ObstaculoCritico : null,
null,
(float)LogTrj.AnguloCaminho,
(float)Log.AnguloCarroDefinido,
Log,
_trajaetoria,
Variaveis.OperacaoEmAndamento.Trajetoria?._TrajetoriaFixa,
new List<GPSModel>(LogsGPS.Take(LogsGPS.IndexOf(Log) + 1)),
Variaveis.OperacaoEmAndamento.Mapa.TrajetoriaMapa,
// Roxo: trajetória ainda restante naquele instante
trajetoriaDinamica,
// Verde/azul: trajetória fixa completa projetada
trajetoriaFixa,
// Laranja: caminho realmente percorrido até aquele instante
rastroRover,
// Azul/preto: linhas originais do mapa
ruasMapa,
// Verde tracejado: simulação MPC daquele frame
LogCtrl.SimulacaoMPC
);
}
@ -2334,6 +2407,48 @@ namespace AgroBase.Forms.Operacoes
}
}
}
private async void btnCarregarMapa_Click(object sender, EventArgs e)
{
}
private List<PontoTrajetoriaModel> MontarTrajetoriaDinamicaReplay(GPSModel posicaoAtual, OperacaoSensoriamentoLogTrajetoriaModel logTrajetoria)
{
var resultado = new List<PontoTrajetoriaModel>();
if (posicaoAtual != null)
{
resultado.Add(
new PontoTrajetoriaModel(Enums.TipoPontoRua.PosicaoRobo)
{
Posicao = posicaoAtual,
LarguraCorredor = 1.0,
idxCorredor = logTrajetoria?.CorredorAtual?.Idx ?? 0,
Visitado = true
}
);
}
if (_trajetoriaFixaReplay == null ||
_trajetoriaFixaReplay.Count == 0)
{
return resultado;
}
int idxProximo =
logTrajetoria?.ProximoPonto?.idxPonto ?? 0;
resultado.AddRange(
_trajetoriaFixaReplay
.Where(x =>
x != null &&
x.idxPonto >= idxProximo)
);
return resultado;
}
}
}

View File

@ -88,7 +88,7 @@ namespace AgroBase.Forms
txtAnguloGPS.Text = anguloInicial.ToString("0.00");
txtDistanciaGPS.Text = distanciaInicial.ToString("0.00");
Variaveis.OperacaoEmAndamento.GPSTrajetoria = new System.Collections.Generic.List<GPSModel>();
Variaveis.OperacaoEmAndamento.Trajetoria = new TrajetoriaMapaOperacaoModel(Variaveis.OperacaoEmAndamento.Mapa.TrajetoriaMapa);
Variaveis.OperacaoEmAndamento.Trajetoria = new TrajetoriaMapaOperacaoModel(Variaveis.OperacaoEmAndamento, Variaveis.OperacaoEmAndamento.Mapa.TrajetoriaMapa);
Variaveis.OperacaoEmAndamento.Trajetoria.ProjetarTrajetoriaFixa();
_robotState = new TreinamentoIAModel();
}

View File

@ -344,7 +344,7 @@ namespace AgroBase.Models
_mapaEnviado = true;
}
public void DefinirRuasSelecionadas(IEnumerable<string> ruas, bool popularTrajetoria = false)
public void DefinirRuasSelecionadas(OperacaoModel op, IEnumerable<string> ruas, bool popularTrajetoria = false)
{
RuasPercorrer =
ruas == null
@ -361,12 +361,12 @@ namespace AgroBase.Models
}
if (popularTrajetoria)
PopularTrajetoriaMapa();
PopularTrajetoriaMapa(op);
}
public async Task DefinirRuasSelecionadasAsync(IEnumerable<string> ruas, bool popularTrajetoria = false)
public async Task DefinirRuasSelecionadasAsync(OperacaoModel op, IEnumerable<string> ruas, bool popularTrajetoria = false)
{
DefinirRuasSelecionadas(ruas, popularTrajetoria);
DefinirRuasSelecionadas(op, ruas, popularTrajetoria);
if (!_paginaCarregada || browser?.CoreWebView2 == null)
{
@ -378,11 +378,11 @@ namespace AgroBase.Models
await browser.CoreWebView2.ExecuteScriptAsync("definirSelecaoRuas(" + ruasJson + ");");
}
public Task DefinirRuasSelecionadasAsync(IEnumerable<int> ruas)
public Task DefinirRuasSelecionadasAsync(OperacaoModel op, IEnumerable<int> ruas)
{
IEnumerable<string> convertidas = ruas == null ? null : ruas.Select(x => x.ToString());
return DefinirRuasSelecionadasAsync(convertidas);
return DefinirRuasSelecionadasAsync(op, convertidas);
}
private bool PodeSelecionarRuas()
@ -424,7 +424,7 @@ namespace AgroBase.Models
.Distinct()
.ToList();
PopularTrajetoriaMapa();
PopularTrajetoriaMapa(Variaveis.OperacaoEmAndamento);
RuasSelecionadasAlteradas?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
@ -433,7 +433,7 @@ namespace AgroBase.Models
}
}
public void PopularTrajetoriaMapa(MapaFeatureCollectionModel dados = null)
public void PopularTrajetoriaMapa(OperacaoModel op, MapaFeatureCollectionModel dados = null)
{
if (dados != null)
mapaService.DefinirMapa(dados, mapaService.NomeArquivos ?? "Mapa");
@ -512,14 +512,12 @@ namespace AgroBase.Models
ruasMontadas.Add(rua);
}
OperacaoModel op = Variaveis.OperacaoEmAndamento;
if (op == null)
throw new InvalidOperationException("Operação indisponível para instalar a trajetória.");
var trajetoriaAnterior = op.Trajetoria;
var mapaAnterior = TrajetoriaMapa;
var novaTrajetoria = new TrajetoriaMapaOperacaoModel(ruasMontadas);
var novaTrajetoria = new TrajetoriaMapaOperacaoModel(op, ruasMontadas);
try
{
@ -647,9 +645,9 @@ namespace AgroBase.Models
pnlMapa = null;
}
public async Task LimparParametrizacaoMapaAsync()
public async Task LimparParametrizacaoMapaAsync(OperacaoModel op)
{
DefinirRuasSelecionadas(new List<string>(), false);
DefinirRuasSelecionadas(op, new List<string>(), false);
if (_paginaCarregada && browser?.CoreWebView2 != null)
{
await browser.CoreWebView2.ExecuteScriptAsync("definirSelecaoRuas([]);");

View File

@ -476,7 +476,6 @@ namespace AgroBase.Models
Simulando = false,
GPSTrajetoria = new List<GPSModel>(),
Mapa = new MapasModel(),
Trajetoria = new TrajetoriaMapaOperacaoModel(new List<List<GPSModel>>()),
ControleAnterior = new OperacaoControleModel(),
Parametros = new OperacaoParametrosModel()
{
@ -499,10 +498,12 @@ namespace AgroBase.Models
}
}
};
op.Trajetoria = new TrajetoriaMapaOperacaoModel(op, new List<List<GPSModel>>());
op.Sensoriamento = new OperacaoSensoriamentoConjuntoModel(op)
{
Operacao = new OperacaoSensoriamentoLogModel()
{
Modo = Modo,
OperacaoIniciada = false,
DataInicio = DateTime.MinValue,
DataFim = DateTime.MinValue
@ -559,7 +560,7 @@ namespace AgroBase.Models
AtuPercentualErvasBicoOff = 2,
AtuPercentualErvasBicoOn = 1,
AtuPercentualInicioPulverizacao = 85,
AtuPressaoLinha = 18,
AtuPressaoLinha = 22,
AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao,
AtuModoControle = ModoControleBomba.PID,
AtuModeloCabeca = CabecasModelo.Target,
@ -666,14 +667,14 @@ namespace AgroBase.Models
DirVelocidadeMovimento = 80,
DirTipoMovimento = TiposControladorDirecional.MPC,
DistanciaManobra = 3.0,
DistanciaManobra = 2.2,
AtuAlturaAreaPulverizacao = 10,
AtuDuracaoAtuacao = 0,
AtuPercentualErvasBicoOff = 2,
AtuPercentualErvasBicoOn = 1,
AtuPercentualInicioPulverizacao = 85,
AtuPressaoLinha = 18,
AtuPressaoLinha = 22,
AtuAgitadorModo = ModoAgitadorCalda.Continuo,
AtuModoControle = ModoControleBomba.PID,
AtuModeloCabeca = CabecasModelo.Target,
@ -1312,9 +1313,9 @@ namespace AgroBase.Models
if (parametros.Mapa != null)
{
novaOp.Mapa = new MapasModel();
novaOp.Mapa.DefinirRuasSelecionadas(ruasSelecionadas);
novaOp.Mapa.DefinirRuasSelecionadas(novaOp, ruasSelecionadas);
novaOp.Mapa.TipoMapa = novaOp.Parametros.TipoMapa;
novaOp.Mapa.PopularTrajetoriaMapa(parametros.Mapa);
novaOp.Mapa.PopularTrajetoriaMapa(novaOp, parametros.Mapa);
}
novaOp?.DispAtu?.Dados?.PrepararAtuadorParaOperacao();

View File

@ -16,8 +16,9 @@ namespace AgroBase.Models
{
public class TrajetoriaMapaOperacaoModel
{
public TrajetoriaMapaOperacaoModel(List<List<GPSModel>> RuasMapa)
public TrajetoriaMapaOperacaoModel(OperacaoModel _op, List<List<GPSModel>> RuasMapa)
{
op = _op;
RuasPlantacao = ClonarRuasMapa(RuasMapa);
AutonomiaCorredor = new AutonomiaCorredorModel();
}
@ -47,12 +48,15 @@ namespace AgroBase.Models
private const double ToleranciaCoordenada = 1e-12;
private const double DistanciaMaximaSaltoMapaM = 25.0;
[JsonIgnore]
private readonly OperacaoModel op;
#region PARAMETROS
public double AnguloAberturaCurva { get; set; } = 25; // Angulo usado para deslocar o ponto de curva
public double DistanciaProjecaoRua =>
(VariaveisEquipamento.DistanciaEntreEixosCm / 100.0 / 2.0) +
(Variaveis.OperacaoEmAndamento?.Parametros?.Controle?.DistanciaManobra ?? 3.0); // Distancia para projetar o primeiro ponto para fora do corredor
(op?.Parametros?.Controle?.DistanciaManobra ?? 3.0); // Distancia para projetar o primeiro ponto para fora do corredor
public static double DistanciaEntrePontos { get; set; } = 0.8; // Distancia entre os pontos dentro do corredor
public static double DistanciaEntrePontosCurva { get; set; } = 0.25; // Distancia entre os pontos durante a curva entre corredores
private double DistanciaManobraEntreRuas { get; set; } = 3.0; // Distancia máxima para gerar a curva de conexão entre os corredores
@ -318,7 +322,7 @@ namespace AgroBase.Models
public static double DistanciaMaximaEntreLeituras { get; private set; }
public void AtualizarDistanciaMaximaEntreLeituras()
{
double percentualVelocidade = Variaveis.OperacaoEmAndamento?.Controle?.PercentualVelocidadeSP ?? 0;
double percentualVelocidade = op?.Controle?.PercentualVelocidadeSP ?? 0;
double velocidadeCarroMs = FuncoesMatematicas.CalculaVelocidadeMsPercentual(percentualVelocidade);
double distanciaMaxima = velocidadeCarroMs / Math.Max(1, GPSService.TaxaAmostragemHz);
@ -673,7 +677,7 @@ namespace AgroBase.Models
if (CorredorAtual != null)
{
bool operacaoEmAndamento =
Variaveis.OperacaoEmAndamento?.Sensoriamento?.Operacao?.StatusOperacaoAtual ==
op?.Sensoriamento?.Operacao?.StatusOperacaoAtual ==
StatusOperacao.EmAndamento;
if ((RetornandoBase && CorredorAtual.Dentro) || (!RetornandoBase && operacaoEmAndamento))
@ -784,8 +788,6 @@ namespace AgroBase.Models
public string TempoEstimadoRestante { get; private set; }
public void AtualizarTempoEstimado(double? velocidadeSemErvasMs = null, double? velocidadeComErvasMs = null)
{
var op = Variaveis.OperacaoEmAndamento;
if (op?.Sensoriamento == null || op?.Parametros?.Controle == null)
{
TempoEstimadoOperacao = "00:00:00";
@ -871,8 +873,6 @@ namespace AgroBase.Models
if (CorredorAtual.Idx > 0) return;
if (!CorredorAtual.Dentro) return;
var op = Variaveis.OperacaoEmAndamento;
if (op?.Sensoriamento == null)
return;
@ -952,7 +952,7 @@ namespace AgroBase.Models
{
MarcarVisitadoAte(_TrajetoriaFixa.Count - 1);
Variaveis.OperacaoEmAndamento.Sensoriamento?.InserirLog(
op.Sensoriamento?.InserirLog(
T_Code.Trj,
StatusModulo.Operante,
100,
@ -976,7 +976,6 @@ namespace AgroBase.Models
}
private void AtualizarErroCombinado()
{
var op = Variaveis.OperacaoEmAndamento;
var gps = GPSPosicaoAtual;
if (op?.Parametros?.Controle == null || gps == null || PontoAtual?.Posicao == null || ProximoPonto?.Posicao == null)
@ -1139,6 +1138,10 @@ namespace AgroBase.Models
for (int i = idxInicial; i <= idxFinal; i++)
{
if (PontoAtual.idxPonto == _TrajetoriaFixa[i].idxPonto)
{
}
_TrajetoriaFixa[i].AtualizarPropriedades(
_gpsAtualCiclo,
_gpsAnteriorCiclo,
@ -1261,7 +1264,7 @@ namespace AgroBase.Models
if (!confirmou)
return;
Variaveis.OperacaoEmAndamento.Sensoriamento?.InserirLog(
op.Sensoriamento?.InserirLog(
T_Code.Trj,
StatusModulo.Operante,
100,
@ -1468,7 +1471,7 @@ namespace AgroBase.Models
private bool VerificaInicioOperacaoMeioRua(bool considerarStatus)
{
if (_TrajetoriaFixaDefinida && !VerificacaoInicialMeioRuaConcluida && ((considerarStatus && new List<StatusOperacao>() { StatusOperacao.Aguardando, StatusOperacao.EmAndamento }.Contains(Variaveis.OperacaoEmAndamento.Sensoriamento.Operacao.StatusOperacaoAtual)) || !considerarStatus))
if (_TrajetoriaFixaDefinida && !VerificacaoInicialMeioRuaConcluida && ((considerarStatus && new List<StatusOperacao>() { StatusOperacao.Aguardando, StatusOperacao.EmAndamento }.Contains(op.Sensoriamento.Operacao.StatusOperacaoAtual)) || !considerarStatus))
{
var _posicaoAtual = GPSPosicaoAtual;
if (_posicaoAtual == null)
@ -1487,7 +1490,7 @@ namespace AgroBase.Models
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.";
Variaveis.OperacaoEmAndamento?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Operante, 100, msg);
op?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Operante, 100, msg);
Variaveis.MostrarLog($"[TRJ] {msg}");
return false;
@ -1504,7 +1507,7 @@ namespace AgroBase.Models
)
{
msg = "Inicio no meio da rua rejeitado: geometria e trajetoria discordam sobre o corredor atual.";
Variaveis.OperacaoEmAndamento?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Falha, 0, msg);
op?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Falha, 0, msg);
Variaveis.MostrarLog("[TRJ] " + msg);
throw new InvalidOperationException(msg);
}
@ -1517,14 +1520,14 @@ namespace AgroBase.Models
CorredorAtual?.AtualizarDados();
msg = $"Início no meio da rua confirmado: idx={idxPontoMaisProximo}/{_TrajetoriaFixa.Count - 1}, marcados={idxPontoMaisProximo + 1}/{_TrajetoriaFixa.Count}.";
Variaveis.OperacaoEmAndamento?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Alerta, 100, msg);
op?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Alerta, 100, msg);
Variaveis.MostrarLog($"[TRJ] {msg}");
return true;
}
msg = $"Início no meio da rua ignorado: robô está fora do corredor. Nova operação começará do zero.";
Variaveis.OperacaoEmAndamento?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Operante, 100, msg);
op?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Operante, 100, msg);
Variaveis.MostrarLog($"[TRJ] {msg}");
}
@ -1534,7 +1537,6 @@ namespace AgroBase.Models
private void VerificaFimCorredorSegmentacao()
{
var op = Variaveis.OperacaoEmAndamento;
double limiteFim = op?.Parametros?.Controle?.AnteciparManobraCorredorM ?? 0;
if (
@ -1972,7 +1974,7 @@ namespace AgroBase.Models
catch (Exception ex)
{
Variaveis.MostrarLog("[TRJ] Falha no ciclo da trajetoria: " + ex.Message);
Variaveis.OperacaoEmAndamento?.Sensoriamento?.InserirLog(
op?.Sensoriamento?.InserirLog(
T_Code.Trj,
StatusModulo.Falha,
0,
@ -2005,8 +2007,6 @@ namespace AgroBase.Models
private void LoopAtualizaDadosCore()
{
var op = Variaveis.OperacaoEmAndamento;
if (!(op?.Parametros?.ControleAutomatico ?? false))
return;
@ -2023,8 +2023,6 @@ namespace AgroBase.Models
private void AtualizarDadosControleRedis()
{
var op = Variaveis.OperacaoEmAndamento;
if (op == null || !op.Sensoriamento.Operacao.OperacaoIniciada)
{
return;
@ -2270,7 +2268,7 @@ namespace AgroBase.Models
DefinirPontoAtual();
DefinirProximoPonto();
Variaveis.OperacaoEmAndamento.Sensoriamento?.InserirLog(
op.Sensoriamento?.InserirLog(
T_Code.Trj,
StatusModulo.Operante,
100,
@ -3203,7 +3201,7 @@ namespace AgroBase.Models
List<List<GPSModel>> ruasParaProjetar;
if (Variaveis.OperacaoEmAndamento?.Mapa?.TipoMapa == TipoMapaOperacao.Corredores)
if (op?.Mapa?.TipoMapa == TipoMapaOperacao.Corredores)
{
throw new NotSupportedException(
"O tipo de mapa Corredores ainda nao possui geracao validada para campo. " +
@ -3679,7 +3677,7 @@ namespace AgroBase.Models
private void ProjetarTrajetoriaFixaCore(GPSModel posicaoRobo)
{
if (Variaveis.OperacaoEmAndamento?.Sensoriamento?.Operacao?.Modo != ModoOperacao.MapaGPS)
if (op?.Parametros?.Modo != ModoOperacao.MapaGPS)
return;
List<PontoTrajetoriaModel> _trajetoriaFixa = new List<PontoTrajetoriaModel>();
@ -3693,7 +3691,7 @@ namespace AgroBase.Models
catch (Exception ex)
{
Variaveis.MostrarLog($"[TRJ] Trajetória rejeitada: {ex.Message}");
Variaveis.OperacaoEmAndamento?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Falha, 0, $"Trajetória rejeitada: {ex.Message}");
op?.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Falha, 0, $"Trajetória rejeitada: {ex.Message}");
throw;
}
@ -3810,7 +3808,8 @@ namespace AgroBase.Models
Corredores[idx] = CorredorAtual;
double anguloProjetar1 = GPSUtils.CalcularOrientacao(CorredorAtual.Skip(1).FirstOrDefault(), CorredorAtual.FirstOrDefault());
GPSModel PrimeiroPonto = GPSUtils.ProjetarPontoDeslocado(CorredorAtual.FirstOrDefault(), DistanciaProjecaoRua * 0.8, anguloProjetar1);
double percentualAcrescimoPontoAberturaCurvaPrimeiroPonto = 0.7;
GPSModel PrimeiroPonto = GPSUtils.ProjetarPontoDeslocado(CorredorAtual.FirstOrDefault(), DistanciaProjecaoRua * percentualAcrescimoPontoAberturaCurvaPrimeiroPonto, anguloProjetar1);
var ultimoPontoTrajetoria = _trajetoriaFixa.LastOrDefault();
@ -3823,7 +3822,7 @@ namespace AgroBase.Models
Direcao = direcaoAtual,
Orientacao = GPSUtils.CalcularOrientacao(ultimoPontoTrajetoria.Posicao, PrimeiroPonto),
Visitado = false,
LarguraCorredor = larguraCorredorMenor
LarguraCorredor = LarguraCorredorPadrao // larguraCorredorMenor
};
_trajetoriaFixa.Add(PontoInicial);
@ -3849,8 +3848,8 @@ namespace AgroBase.Models
_trajetoriaFixa.Add(PontoTrajetoria);
}
double percentualAcrescimoPontoAberturaCurva = 0.8;
double distancia_projetar = !ultimoCorredor ? (DistanciaProjecaoRua * percentualAcrescimoPontoAberturaCurva) : DistanciaProjecaoRua;
double percentualAcrescimoPontoAberturaCurvaUltimoPonto = 0.8;
double distancia_projetar = !ultimoCorredor ? (DistanciaProjecaoRua * percentualAcrescimoPontoAberturaCurvaUltimoPonto) : DistanciaProjecaoRua;
GPSModel ultimoPontoCorredorAtual = CorredorAtual.Last();
double anguloProjetar2 = GPSUtils.CalcularOrientacao(CorredorAtual.Skip(CorredorAtual.Count() - 2).FirstOrDefault(), ultimoPontoCorredorAtual);
@ -3882,7 +3881,7 @@ namespace AgroBase.Models
Direcao = direcaoAtual,
Orientacao = GPSUtils.CalcularOrientacao(_trajetoriaFixa.Last().Posicao, UltimoPonto),
Visitado = false,
LarguraCorredor = larguraCorredorMenor // ultimoCorredor ? larguraCorredorMenor : (larguraCorredorMenor * 0.8)
LarguraCorredor = LarguraCorredorPadrao * 1.1 // larguraCorredorMenor // ultimoCorredor ? larguraCorredorMenor : (larguraCorredorMenor * 0.8)
};
_trajetoriaFixa.Add(PontoFinal);
@ -4376,7 +4375,6 @@ namespace AgroBase.Models
double distanciaTotalAnterior = DistanciaTotal;
double distanciaPercorridaAnterior = DistanciaPercorrida;
var autonomiaAnterior = AutonomiaCorredor?.Clone();
var op = Variaveis.OperacaoEmAndamento;
var parametrosAnteriores = op?.Parametros;
bool simulandoAnterior = op?.Simulando ?? false;
bool? operacaoIniciadaAnterior = op?.Sensoriamento?.Operacao?.OperacaoIniciada;
@ -4407,7 +4405,7 @@ namespace AgroBase.Models
}
Variaveis.MostrarLog("[RETORNO] Trajetoria rejeitada: " + ex.Message);
Variaveis.OperacaoEmAndamento?.Sensoriamento?.InserirLog(
op?.Sensoriamento?.InserirLog(
T_Code.Trj,
StatusModulo.Falha,
0,
@ -4649,7 +4647,7 @@ namespace AgroBase.Models
AtualizarTrajetoriaDinamicaCore();
AtualizarDistanciaRestante();
Variaveis.OperacaoEmAndamento.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Operante, 100, $"[RETORNO] Trajetória aplicada ({motivo}) - Pontos: {traj.Count}");
op.Sensoriamento?.InserirLog(T_Code.Trj, StatusModulo.Operante, 100, $"[RETORNO] Trajetória aplicada ({motivo}) - Pontos: {traj.Count}");
}
private List<GPSModel> CortarFinalDaRota(List<GPSModel> pontos, double distanciaAntesDoFimM)
@ -4689,8 +4687,6 @@ namespace AgroBase.Models
private void AtualizarDadosOperacaoParaRetorno(List<GPSModel> waypointsManual)
{
var op = Variaveis.OperacaoEmAndamento;
if (op == null)
throw new InvalidOperationException("Operacao indisponivel ao iniciar retorno.");
@ -4784,7 +4780,7 @@ namespace AgroBase.Models
*/
if (!ReferenceEquals(
op,
Variaveis.OperacaoEmAndamento))
op))
{
Variaveis.MostrarLog(
"[RETORNO/MAPSYNC] Operação mudou durante sincronização."
@ -5971,7 +5967,7 @@ namespace AgroBase.Models
*/
limiteDistancia = Math.Min(limiteDistancia, 0.80);
if (NaMargem && DistanciaAtual <= limiteDistancia)
if (NaMargem) // && DistanciaAtual <= limiteDistancia
{
Visitado = true;
}

View File

@ -324,7 +324,7 @@ namespace AgroBase.Services.Operadores
{
percent_vel_max = pControle.MovVelocidadeSErvasPercent,
percent_vel_min = pControle.MovVelocidadeCErvasPercent,
percent_vel_curva = 20.0,
percent_vel_curva = 15.0,
rampa_partida_pct = VariaveisEquipamento.PercentualVelMin,
rampa_aceleracao_pct_s = 12.0,
rampa_desaceleracao_pct_s = 18.0,
@ -548,7 +548,7 @@ namespace AgroBase.Services.Operadores
$"[MAPSYNC] Tentativa={tentativa} | " +
$"cmd={commandId} | " +
$"rev={mapRevision} | " +
$"pontos={op.Trajetoria._TrajetoriaFixa.Count}"
$"pontos={op.Trajetoria._TrajetoriaFixa?.Count}"
);
bool atualizado =

View File

@ -419,6 +419,7 @@ class CameraOak:
t0 = time.time()
frame = pkt.getCvFrame()
dur = time.time() - t0
frame_ts_host = time.time()
resultado = {
"erro": None,
@ -450,7 +451,7 @@ class CameraOak:
self.perf.tick(
"camera_rgb",
latencia_ms=dur * 1000.0,
frame_ts_host=self._rgb_cache_ts,
frame_ts_host=frame_ts_host,
frame_ts_device=pkt_ts_device,
seq=pkt_seq,
seq_delta=seq_delta,
@ -461,7 +462,7 @@ class CameraOak:
with self._cache_lock:
self._rgb_cache = frame
self._rgb_cache_ts = time.time()
self._rgb_cache_ts = frame_ts_host
self._rgb_cache_resultado = resultado
self.timestamp_ultimo_frame_rgb = self._rgb_cache_ts
self._rgb_seq = pkt_seq
@ -478,6 +479,7 @@ class CameraOak:
t0 = time.time()
frame = pkt.getFrame()
dur = time.time() - t0
frame_ts_host = time.time()
resultado = {
"erro": None,
@ -509,7 +511,7 @@ class CameraOak:
self.perf.tick(
"camera_depth",
latencia_ms=dur * 1000.0,
frame_ts_host=self._depth_cache_ts,
frame_ts_host=frame_ts_host,
frame_ts_device=pkt_ts_device,
seq=pkt_seq,
seq_delta=seq_delta,
@ -520,7 +522,7 @@ class CameraOak:
with self._cache_lock:
self._depth_cache = frame
self._depth_cache_ts = time.time()
self._depth_cache_ts = frame_ts_host
self._depth_cache_resultado = resultado
self.timestamp_ultimo_frame_depth = self._depth_cache_ts
self.ultimo_resultado_depth = resultado
@ -1062,6 +1064,50 @@ class CameraOak:
self._definir_heartbeat()
return frame, resultado
def requisitar_frame_rgb_ref(self):
"""
Retorna referência READ-ONLY ao latest frame RGB cacheado.
Diferente de requisitar_frame_rgb(), não copia ~6 MB por frame 1080p.
O cache da câmera apenas substitui a referência por um ndarray novo; não
modifica o ndarray anterior. Portanto o consumidor pode manter a referência
durante resize/inferência sem segurar o lock.
REGRA: o consumidor não deve escrever no ndarray retornado.
"""
with self._cache_lock:
resultado = dict(self._rgb_cache_resultado)
erro = resultado.get("erro") if self._is_erro_fatal_depthai(resultado.get("erro")) else None
frame = self._rgb_cache
if erro:
self._falha_fatal_depthai(erro)
return None, resultado
self._definir_heartbeat()
return frame, resultado
def requisitar_frame_depth_ref(self):
"""Mesmo contrato read-only do RGB, aplicado ao depth latest-frame."""
if not self.tem_depth:
return None, {
"erro": "Camera nao possui sensor de profundidade",
"duracao": 0,
"frame_valido": False,
}
with self._cache_lock:
resultado = dict(self._depth_cache_resultado)
erro = resultado.get("erro") if self._is_erro_fatal_depthai(resultado.get("erro")) else None
frame = self._depth_cache
if erro:
self._falha_fatal_depthai(erro)
return None, resultado
self._definir_heartbeat()
return frame, resultado
def requisitar_frame_depth(self):
if not self.tem_depth:
return None, {

View File

@ -1,6 +1,9 @@
import time
from shared.enums import ModoOperacao, StatusModulo, StatusCarroMapa, T_Code
from shared.enums import (
ModoOperacao, StatusModulo, StatusCarroMapa, T_Code,
TipoMovimentoDirecional,
)
from shared.contexto_global_redis import ContextoGlobalRedis, CtxKey
from manager_worker.config import mostrar_log
from manager_worker.filtros import FiltroVelocidade
@ -67,6 +70,44 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
abs(_float(dir_cfg.get("angulo_max", 30.0), 30.0))
)
# V5 - velocidade coerente com a política geométrica do MPC.
# Até ~5° o carro pode manter velocidade de cruzeiro. Entre 5° e 15°
# reduz progressivamente; a partir de 15° (faixa de MovimentoArco) usa
# velocidade de curva. Os parâmetros podem ser publicados em Mov sem
# exigir mudança imediata do contrato C#.
erro_heading_curva_forte_graus = _clamp(
_float(
mov_cfg.get("erro_heading_curva_forte_graus", 15.0),
15.0,
),
3.0,
ang_max,
)
erro_heading_curva_deadband_graus = _clamp(
_float(
mov_cfg.get("erro_heading_curva_deadband_graus", 5.0),
5.0,
),
0.0,
max(0.0, erro_heading_curva_forte_graus - 0.5),
)
# Movimento/ângulo atualmente aplicados. Como o Processador calcula MOV
# antes de DIR, isto funciona como memória segura do ciclo anterior. O
# erro geométrico abaixo continua sendo o gatilho primário do ciclo atual.
tipo_direcional_atual = _enum_or(
TipoMovimentoDirecional,
controle.get(
"tipo_movimento_direcional",
TipoMovimentoDirecional.RodasDianteiras.value,
),
TipoMovimentoDirecional.RodasDianteiras,
)
angulo_direcional_atual = abs(_float(
controle.get("angulo_sp", 0.0),
0.0,
))
pontos_min_fim_corredor = max(
0,
_int(mov_cfg.get("pontos_fim_corredor_reduzir", 3), 3)
@ -105,12 +146,43 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
status_carro = estado["status_carro"]
erro_angular = estado["erro_orientacao"]
erro_angular_caminho = float(
estado.get("erro_angular_caminho", erro_angular)
)
erro_angular_combinado = float(
estado.get("erro_angular_combinado", erro_angular)
)
pontos_fim_corredor = estado["pontos_fim_corredor"]
estado_valido = estado["valido"]
debug["status_carro"] = status_carro.name
debug["erro_orientacao"] = erro_angular
debug["erro_orientacao_fonte"] = estado.get(
"erro_orientacao_fonte",
"nao_informada"
)
debug["erros_orientacao"] = {
"selecionado": round(float(erro_angular), 4),
"combinado": round(
float(estado.get("erro_angular_combinado", erro_angular)),
4
),
"caminho": round(
float(estado.get("erro_angular_caminho", erro_angular)),
4
),
"proximo_ponto": round(
float(estado.get("erro_angular_proximo_ponto", erro_angular)),
4
),
}
debug["pontos_fim_corredor"] = pontos_fim_corredor
debug["geometria_curva"] = {
"erro_heading_curva_forte_graus": round(erro_heading_curva_forte_graus, 3),
"erro_heading_curva_deadband_graus": round(erro_heading_curva_deadband_graus, 3),
"tipo_direcional_atual": tipo_direcional_atual.name,
"angulo_direcional_atual": round(angulo_direcional_atual, 3),
}
if not estado_valido:
motivo = estado.get("motivo", "Estado operacional inválido para movimento")
@ -133,10 +205,45 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
resetar_filtro = False
reduzir_para_pulverizar = False
reduzir_por_weed_indisponivel = False
limitar_velocidade_por_weed = False
reduzir_para_fim_corredor = False
reduzir_por_curva = False
teto_rigido_curva = False
teto_curva_pct = None
reduzir_por_ipb = False
# Erro geométrico que conversa com o seletor do MPC. Em tracking de
# caminho e no retorno usamos o heading da centerline, evitando que um
# simples offset lateral pareça uma curva. Em Direcionando preservamos
# o erro combinado porque ali a aquisição do alvo ainda é relevante.
if status_carro in [
StatusCarroMapa.CaminhandoRua,
StatusCarroMapa.Direcionando,
StatusCarroMapa.RetornandoBase,
]:
# Mesma referência geométrica do MPC V5: heading da centerline.
# Assim, rover paralelo porém deslocado não é confundido com curva.
erro_curva_graus = abs(erro_angular_caminho)
fonte_curva = "erro_angular_caminho"
else:
erro_curva_graus = abs(erro_angular_combinado)
fonte_curva = "erro_angular_combinado"
arco_ja_aplicado = (
tipo_direcional_atual == TipoMovimentoDirecional.MovimentoArco
)
curva_forte_geometrica = (
erro_curva_graus >= erro_heading_curva_forte_graus
)
debug["geometria_curva"].update({
"erro_usado_graus": round(float(erro_curva_graus), 3),
"fonte_erro": fonte_curva,
"arco_ja_aplicado": bool(arco_ja_aplicado),
"curva_forte_geometrica": bool(curva_forte_geometrica),
})
if status_carro == StatusCarroMapa.Parado:
velocidade_sp = 0.0
hard_stop = True
@ -151,6 +258,8 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
]:
velocidade_sp = vel_curva
reduzir_por_curva = True
teto_rigido_curva = True
teto_curva_pct = vel_curva
debug["motivos"].append("movimento em curva/manobra")
elif status_carro in [
@ -158,26 +267,64 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
StatusCarroMapa.RetornandoBase
]:
velocidade_sp = calcular_velocidade_relativa(
vel_min=vel_com_ervas,
vel_min=vel_curva,
vel_max=vel_sem_ervas,
ang_max=ang_max,
erro_orientacao=erro_angular,
k=0.85,
ang_max=erro_heading_curva_forte_graus,
erro_orientacao=erro_curva_graus,
k=1.0,
curva=0.80,
dead=10.0
dead=erro_heading_curva_deadband_graus
)
reduzir_por_curva = bool(
erro_curva_graus > erro_heading_curva_deadband_graus
or arco_ja_aplicado
)
teto_rigido_curva = bool(reduzir_por_curva)
if teto_rigido_curva:
teto_curva_pct = (
vel_curva
if (curva_forte_geometrica or arco_ja_aplicado)
else velocidade_sp
)
debug["motivos"].append(
f"{status_carro.name}: velocidade pela geometria do caminho/alvo"
)
debug["motivos"].append("direcionando/retornando com redução por erro angular")
elif status_carro == StatusCarroMapa.CaminhandoRua:
info_ervas = _ler_weed_worker()
ervas_no_radar = (
pulverizador_automatico and
info_ervas["atualizado"] and
info_ervas["ervas_no_radar"]
# O WeedWorker é a autoridade para dizer se há ervas no radar.
#
# Contrato atual:
# DadosWeedWorker.analise = envelope
# envelope.analise = dados_visuais reais
#
# _ler_weed_worker() normaliza esse contrato e também aceita o
# formato legado (campos diretamente em DadosWeedWorker.analise).
#
# Se o campo booleano não existir em uma versão legada, usamos
# ema_global apenas como fallback.
if info_ervas["campo_ervas_no_radar_presente"]:
ervas_detectadas = bool(info_ervas["ervas_no_radar"])
else:
ervas_detectadas = (
info_ervas["percentual_ervas"] >= ervas_min_percent
)
reduzir_para_pulverizar = bool(ervas_no_radar)
if pulverizador_automatico:
if info_ervas["atualizado"]:
reduzir_para_pulverizar = bool(ervas_detectadas)
else:
# Falha conservadora de qualidade de aplicação:
# percepção desconhecida NÃO significa "sem ervas".
# Mantém a velocidade de pulverização até a análise voltar,
# sem parar a operação por si só.
reduzir_por_weed_indisponivel = True
limitar_velocidade_por_weed = bool(
reduzir_para_pulverizar
or reduzir_por_weed_indisponivel
)
reduzir_para_fim_corredor = (
pontos_fim_corredor >= 0 and
@ -185,20 +332,57 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
)
debug["weed"] = info_ervas
debug["weed"]["pulverizador_automatico"] = bool(
pulverizador_automatico
)
debug["weed"]["ervas_detectadas_efetivo"] = bool(
ervas_detectadas
)
debug["weed"]["limitar_velocidade"] = bool(
limitar_velocidade_por_weed
)
velocidade_livre = calcular_velocidade_relativa(
vel_min=vel_com_ervas,
vel_min=vel_curva,
vel_max=vel_sem_ervas,
ang_max=ang_max,
erro_orientacao=erro_angular,
ang_max=erro_heading_curva_forte_graus,
erro_orientacao=erro_curva_graus,
k=1.0,
curva=0.70,
dead=5.0
curva=0.80,
dead=erro_heading_curva_deadband_graus
)
# Mesmo que o C# publique CaminhandoRua durante RetornoBase, uma
# curva forte da centerline não pode ser tratada como reta rápida.
# O heading do caminho é a mesma referência usada pelo MPC V5.
reduzir_por_curva = bool(
erro_curva_graus > erro_heading_curva_deadband_graus
or arco_ja_aplicado
)
teto_rigido_curva = bool(reduzir_por_curva)
if teto_rigido_curva:
teto_curva_pct = (
vel_curva
if (curva_forte_geometrica or arco_ja_aplicado)
else velocidade_livre
)
debug["motivos"].append(
"caminhando rua: velocidade por erro angular do caminho"
)
if reduzir_para_pulverizar:
velocidade_sp = min(velocidade_livre, vel_com_ervas)
debug["motivos"].append("ervas no radar: reduzindo para pulverizar")
debug["motivos"].append(
"ervas no radar: reduzindo para pulverizar"
)
elif reduzir_por_weed_indisponivel:
velocidade_sp = min(velocidade_livre, vel_com_ervas)
debug["motivos"].append(
"WeedWorker ausente/desatualizado: "
"mantendo velocidade conservadora de pulverização"
)
elif reduzir_para_fim_corredor:
velocidade_sp = min(velocidade_livre, vel_com_ervas)
@ -217,6 +401,16 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
f"status_carro não tratado com segurança: {status_carro.name}"
)
debug["geometria_curva"].update({
"reduzir_por_curva": bool(reduzir_por_curva),
"teto_rigido_curva": bool(teto_rigido_curva),
"teto_curva_pct": (
None if teto_curva_pct is None
else round(float(teto_curva_pct), 3)
),
"vel_curva_pct": round(float(vel_curva), 3),
})
# Se já decidiu parar, não faz limitador tentar reviver velocidade.
if hard_stop:
_resetar_rampa_velocidade(filtro_vel)
@ -284,7 +478,8 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
if usar_imu_movimento:
vel_imu, imu_resetar, imu_stop, imu_debug = _aplicar_limitador_imu(
velocidade_atual=velocidade_sp
velocidade_atual=velocidade_sp,
vel_min_equip=vel_min_equip
)
debug["imu"] = imu_debug
@ -329,7 +524,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
# Aceleração normal é filtrada para não dar tranco.
reducao_seguranca = bool(
reduzir_para_pulverizar
limitar_velocidade_por_weed
or reduzir_para_fim_corredor
or reduzir_por_curva
or reduzir_por_ipb
@ -351,23 +546,59 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None)
debug=debug,
)
# Correção crítica:
# havendo erva no radar, a saída nunca pode superar a mínima definida.
if reduzir_para_pulverizar:
velocidade_filtrada = min(
velocidade_filtrada,
vel_com_ervas
)
# Teto rígido de curva/Arco. A rampa de segurança continua suavizando
# reduções moderadas, mas uma geometria já classificada como curva forte
# não pode passar um ciclo em velocidade de cruzeiro enquanto o MPC
# coloca o rover em MovimentoArco.
if teto_rigido_curva and teto_curva_pct is not None:
teto_curva_pct = _clamp(teto_curva_pct, vel_min_equip, vel_sem_ervas)
velocidade_filtrada = min(velocidade_filtrada, teto_curva_pct)
# Sincroniza o estado da rampa com a saída realmente aplicada.
try:
filtro_vel._ultimo_sp_campo = velocidade_filtrada
filtro_vel._ultimo_ts_campo = time.monotonic()
except Exception:
pass
motivo_curva = (
"curva forte/Arco"
if (curva_forte_geometrica or arco_ja_aplicado)
else "curva moderada"
)
debug["limitadores"].append(
f"ervas no radar: teto rígido em {vel_com_ervas:.1f}%"
f"{motivo_curva}: teto geométrico em {teto_curva_pct:.1f}%"
)
# Teto rígido do WeedWorker:
#
# - ervas detectadas: nunca ultrapassa vel_com_ervas;
# - WeedWorker desatualizado com pulverizador automático ligado:
# também não assume que a rua está livre.
#
# Esse clamp acontece DEPOIS da rampa para não existir overshoot
# transitório acima da velocidade de aplicação.
if limitar_velocidade_por_weed:
velocidade_filtrada = min(
velocidade_filtrada,
vel_com_ervas
)
# Sincroniza o estado da rampa com a saída realmente aplicada.
# Assim, quando a condição desaparecer, a aceleração volta a
# acontecer pela rampa normal em vez de saltar para o SP antigo.
try:
filtro_vel._ultimo_sp_campo = velocidade_filtrada
filtro_vel._ultimo_ts_campo = time.monotonic()
except Exception:
pass
if reduzir_para_pulverizar:
motivo_teto = "ervas no radar"
else:
motivo_teto = "WeedWorker indisponível/desatualizado"
debug["limitadores"].append(
f"{motivo_teto}: teto rígido em {vel_com_ervas:.1f}%"
)
velocidade_filtrada = round(
@ -415,6 +646,33 @@ def _resolver_estado_operacional(
contexto,
erro_orientacao
):
"""
Resolve o estado operacional e escolhe a referência angular usada
EXCLUSIVAMENTE para a política de velocidade.
Política alinhada ao MPC Path Tracking V2:
- CaminhandoRua:
usa erro_angular_caminho.
O que importa para liberar velocidade é o corpo do rover estar
alinhado com a orientação local da passada. Deslocamento lateral
pode ser corrigido pelo MovimentoDiagonal sem penalizar a velocidade
como se o rover estivesse "torto".
- Direcionando / RetornandoBase:
usa erro_angular combinado.
Fora da passada, a aquisição geométrica do alvo continua relevante.
- EntrandoRua / SaindoRua / Manobrando:
a velocidade base é vel_curva, portanto o erro selecionado não
altera a velocidade nesses estados. Mantemos o combinado para
diagnóstico/coerência.
- erro_orientacao explícito:
continua tendo prioridade para preservar compatibilidade com
chamadas externas/legadas.
"""
if op_modo in [ModoOperacao.MapaGPS, ModoOperacao.RetornoBase]:
trajetoria = _dict(contexto.get("Trajetoria", {}))
@ -424,6 +682,10 @@ def _resolver_estado_operacional(
"motivo": "Trajetoria ausente no contexto",
"status_carro": StatusCarroMapa.Parado,
"erro_orientacao": 0.0,
"erro_orientacao_fonte": "trajetoria_ausente",
"erro_angular_combinado": 0.0,
"erro_angular_caminho": 0.0,
"erro_angular_proximo_ponto": 0.0,
"pontos_fim_corredor": -1,
}
@ -433,33 +695,86 @@ def _resolver_estado_operacional(
StatusCarroMapa.Parado
)
if erro_orientacao is None:
erro_orientacao = _float(trajetoria.get("erro_angular", 0.0), 0.0)
erro_combinado = _float(
trajetoria.get("erro_angular", 0.0),
0.0
)
erro_caminho = _float(
trajetoria.get(
"erro_angular_caminho",
erro_combinado
),
erro_combinado
)
erro_proximo_ponto = _float(
trajetoria.get(
"erro_angular_proximo_ponto",
erro_combinado
),
erro_combinado
)
if erro_orientacao is not None:
erro_selecionado = _float(
erro_orientacao,
erro_combinado
)
fonte_erro = "override_explicito"
elif status_carro == StatusCarroMapa.CaminhandoRua:
# Na passada, o heading local da rua é a referência correta.
# O bearing para waypoint não deve reduzir velocidade só porque
# o MPC está corrigindo offset lateral em MovimentoDiagonal.
erro_selecionado = erro_caminho
fonte_erro = "erro_angular_caminho"
else:
# Aquisição, retorno e demais estados mantêm a referência
# combinada que incorpora a geometria do alvo.
erro_selecionado = erro_combinado
fonte_erro = "erro_angular_combinado"
corredor = _dict(trajetoria.get("CorredorAtual", {}))
pontos_fim_corredor = _int(corredor.get("pontos_restantes", -1), -1)
pontos_fim_corredor = _int(
corredor.get("pontos_restantes", -1),
-1
)
return {
"valido": True,
"motivo": "",
"status_carro": status_carro,
"erro_orientacao": _float(erro_orientacao, 0.0),
"erro_orientacao": float(erro_selecionado),
"erro_orientacao_fonte": fonte_erro,
"erro_angular_combinado": float(erro_combinado),
"erro_angular_caminho": float(erro_caminho),
"erro_angular_proximo_ponto": float(erro_proximo_ponto),
"pontos_fim_corredor": pontos_fim_corredor,
}
if op_modo == ModoOperacao.MapeamentoVisual:
dados_vw = _dict(ContextoGlobalRedis.get(CtxKey.DadosVisualWorker, {}))
segmentacao = _dict(dados_vw.get("segmentacao", {}))
dados_vw = _dict(
ContextoGlobalRedis.get(CtxKey.DadosVisualWorker, {})
)
segmentacao = _dict(
dados_vw.get("segmentacao", {})
)
seg_ts = _float(
segmentacao.get(
"ts",
dados_vw.get("ts_segmentacao", dados_vw.get("momento", 0.0))
dados_vw.get(
"ts_segmentacao",
dados_vw.get("momento", 0.0)
)
),
0.0
)
seg_atualizada, idade_ms = _timestamp_atualizado(seg_ts, max_idade_s=1.0)
seg_atualizada, idade_ms = _timestamp_atualizado(
seg_ts,
max_idade_s=1.0
)
if not segmentacao or not seg_atualizada:
motivo = "segmentacao visual ausente/desatualizada"
@ -472,31 +787,61 @@ def _resolver_estado_operacional(
"motivo": motivo,
"status_carro": StatusCarroMapa.Parado,
"erro_orientacao": 0.0,
"erro_orientacao_fonte": "segmentacao_invalida",
"erro_angular_combinado": 0.0,
"erro_angular_caminho": 0.0,
"erro_angular_proximo_ponto": 0.0,
"pontos_fim_corredor": -1,
}
status_carro = _enum_or(
StatusCarroMapa,
segmentacao.get("status_corredor", StatusCarroMapa.Parado.value),
segmentacao.get(
"status_corredor",
StatusCarroMapa.Parado.value
),
StatusCarroMapa.Parado
)
if erro_orientacao is None:
erro_orientacao = _float(segmentacao.get("erro_angular", 0.0), 0.0)
erro_visual = _float(
segmentacao.get("erro_angular", 0.0),
0.0
)
if erro_orientacao is not None:
erro_selecionado = _float(
erro_orientacao,
erro_visual
)
fonte_erro = "override_explicito"
else:
erro_selecionado = erro_visual
fonte_erro = "segmentacao_visual"
return {
"valido": True,
"motivo": "",
"status_carro": status_carro,
"erro_orientacao": _float(erro_orientacao, 0.0),
"erro_orientacao": float(erro_selecionado),
"erro_orientacao_fonte": fonte_erro,
"erro_angular_combinado": float(erro_visual),
"erro_angular_caminho": float(erro_visual),
"erro_angular_proximo_ponto": float(erro_visual),
"pontos_fim_corredor": -1,
}
return {
"valido": False,
"motivo": f"Modo de operação sem estratégia de movimento: {op_modo.name}",
"motivo": (
"Modo de operação sem estratégia de movimento: "
f"{op_modo.name}"
),
"status_carro": StatusCarroMapa.Parado,
"erro_orientacao": 0.0,
"erro_orientacao_fonte": "modo_sem_estrategia",
"erro_angular_combinado": 0.0,
"erro_angular_caminho": 0.0,
"erro_angular_proximo_ponto": 0.0,
"pontos_fim_corredor": -1,
}
@ -506,34 +851,119 @@ def _resolver_estado_operacional(
# ============================================================
def _ler_weed_worker():
try:
dados = _dict(ContextoGlobalRedis.get(CtxKey.DadosWeedWorker, {}))
analise = _dict(dados.get("analise", {}))
"""
Lê e normaliza o contrato do WeedWorker.
Contrato atual publicado pelo CameraManager:
DadosWeedWorker = {
"ts_publicacao": ...,
"ts_analise": ...,
"analise": {
"ts_analise": ...,
"fps_model": ...,
"fps_inferencia": ...,
"infer_ms": ...,
"infer_gpu_ms": ...,
"analise": {
"ervas_no_radar": ...,
"estatisticas": {
"erva": {
"ema_global": ...
}
},
...
}
}
}
Também aceita o formato legado, no qual ervas_no_radar e estatisticas
ficam diretamente em DadosWeedWorker["analise"].
"""
try:
dados = _dict(
ContextoGlobalRedis.get(CtxKey.DadosWeedWorker, {})
)
envelope = _dict(dados.get("analise", {}))
# Contrato atual possui envelope["analise"].
# No contrato legado, o próprio envelope já é a análise.
analise_interna = _dict(envelope.get("analise", {}))
if analise_interna:
analise = analise_interna
esquema = "nested_v2"
else:
analise = envelope
esquema = "flat_legacy"
# Preferência de timestamp:
# 1) timestamp do envelope da análise atual;
# 2) timestamp dentro da análise legada;
# 3) timestamp raiz do DadosWeedWorker.
ts = _float(
envelope.get(
"ts_analise",
envelope.get(
"ts",
envelope.get(
"timestamp",
analise.get(
"ts",
analise.get(
"timestamp",
dados.get("ts_analise", dados.get("momento", 0.0))
dados.get(
"ts_analise",
dados.get(
"ts_publicacao",
dados.get("momento", 0.0)
)
)
)
)
)
)
),
0.0
)
atualizado, idade_ms = _timestamp_atualizado(ts, max_idade_s=1.5)
atualizado, idade_ms = _timestamp_atualizado(
ts,
max_idade_s=1.5
)
estatisticas = _dict(analise.get("estatisticas", {}))
erva = _dict(estatisticas.get("erva", {}))
estatisticas = _dict(
analise.get("estatisticas", {})
)
erva = _dict(
estatisticas.get("erva", {})
)
ervas_no_radar = _bool(analise.get("ervas_no_radar", False))
percentual_ervas = _float(erva.get("ema_global", 0.0), 0.0)
campo_ervas_presente = "ervas_no_radar" in analise
ervas_no_radar = _bool(
analise.get("ervas_no_radar", False)
)
percentual_ervas = _float(
erva.get(
"ema_global",
analise.get("percentual_ervas_no_radar", 0.0)
),
0.0
)
return {
"atualizado": atualizado,
"atualizado": bool(atualizado),
"idade_ms": idade_ms,
"ervas_no_radar": ervas_no_radar,
"ervas_no_radar": bool(ervas_no_radar),
"campo_ervas_no_radar_presente": bool(
campo_ervas_presente
),
"percentual_ervas": percentual_ervas,
"timestamp_analise": ts,
"esquema": esquema,
}
except Exception as e:
@ -541,7 +971,10 @@ def _ler_weed_worker():
"atualizado": False,
"idade_ms": None,
"ervas_no_radar": False,
"campo_ervas_no_radar_presente": False,
"percentual_ervas": 0.0,
"timestamp_analise": 0.0,
"esquema": "erro",
"erro": str(e),
}
@ -643,7 +1076,7 @@ def _aplicar_limitador_visual(
# Limitador IMU
# ============================================================
def _aplicar_limitador_imu(*, velocidade_atual):
def _aplicar_limitador_imu(*, velocidade_atual, vel_min_equip):
debug = {
"habilitado": True,
"aplicado": False,
@ -693,7 +1126,41 @@ def _aplicar_limitador_imu(*, velocidade_atual):
if risk_level_num <= 0:
return velocidade_atual, False, False, debug
vel_nova = float(velocidade_atual) * velocidade_factor
velocidade_atual = max(0.0, float(velocidade_atual))
vel_min_equip = max(0.0, float(vel_min_equip))
# Attention/risk são REDUÇÕES, não ordens de parada.
#
# Se a IMU, sozinha, levar o SP abaixo da velocidade mínima útil do
# equipamento, limita no piso operacional em vez de transformar um
# nível attention/risk em parada.
#
# O min(..., velocidade_atual) é proposital: a IMU nunca pode elevar
# uma velocidade que já tenha sido reduzida por outro limitador
# (Visual Worker, IPB, fim de corredor, pulverização etc.).
vel_calculada = velocidade_atual * velocidade_factor
if velocidade_atual >= vel_min_equip and vel_calculada > 0.0:
vel_nova = min(
velocidade_atual,
max(vel_min_equip, vel_calculada)
)
else:
# Se outro limitador já entregou algo abaixo do piso, a IMU não
# "ressuscita" a velocidade. A regra geral posterior decide se
# esse valor deve virar parada.
vel_nova = min(velocidade_atual, vel_calculada)
debug["velocidade_entrada"] = round(velocidade_atual, 3)
debug["velocidade_calculada"] = round(vel_calculada, 3)
debug["velocidade_saida"] = round(vel_nova, 3)
debug["vel_min_equip"] = round(vel_min_equip, 3)
debug["piso_operacional_aplicado"] = bool(
velocidade_atual >= vel_min_equip
and 0.0 < vel_calculada < vel_min_equip
and abs(vel_nova - vel_min_equip) < 1e-9
)
resetar_filtro = False
return vel_nova, resetar_filtro, False, debug

View File

@ -50,7 +50,7 @@ _CONFIG_LOCK = threading.Lock()
# ============================================================
# CONFIG V1 - VISUAL WORKER
# CONFIG V2 - VISUAL WORKER / ONNX FIELD CONTRACT
# ============================================================
@ -117,12 +117,39 @@ VISUAL_DEFAULT_CONFIG = {
"debug_visual": False,
"posproc_intervalo_min_s": 5.0,
# Publica logs de performance no console.
"debug_perf": False,
# Tamanho dos frames de preview/debug enviados ao C#.
"preview_size": (1280, 720),
# Preview producer lateral: só trabalha quando há consumidor.
"preview_fps": 2.0,
"preview_request_timeout_s": 0.35,
"preview_cache_fresh_s": 0.75,
# Replay/debug usa JPEG leve. PosProcessamento continua em PNG/RGB original.
"replay_jpeg_quality": 70,
# Telemetria técnica. Tudo que custa payload/log extra fica atrás deste bloco.
"telemetry": {
# Publica performance_visual no Redis.
"enabled": True,
# Imprime resumo periódico no console.
"console_performance": False,
# Abre o timing da inferência em crop/resize/cor/session/decode.
"runner_detailed_timing": True,
# Inclui o vetor completo de 8 probabilidades do status no debug.
# Desligado por padrão porque top1/top2/margin/entropy já bastam no campo.
"include_status_probs": False,
# Anexa contrato/runtime do ONNX em performance_visual.
"publish_runtime_info": True,
# Loga contrato/provider/cache na inicialização.
"log_runtime_startup": True,
},
# ============================================================
# 2) Frequências dos loops
@ -144,100 +171,81 @@ VISUAL_DEFAULT_CONFIG = {
# ============================================================
# 3) Modelo ONNX/TensorRT - segmentação de ruas/corredor
# 3) Runtime oficial do modelo de corredor - ONNX FIELD V2
# ============================================================
# Runtime oficial da v1.
"runtime_backend": "onnx",
"onnx_provider": "tensorrt",
# O caminho do ONNX vem do Redis/equipamento: path_ia_model_ruas_seg.
# Resolução, ROI, mean/std, layout, nomes de I/O e classes NÃO ficam aqui.
# Tudo isso é lido da metadata embutida no próprio ONNX.
"model_runtime": {
"provider": "tensorrt",
# Resolução esperada pelo ONNX: [W, H].
"ia_resolution": [1024, 576],
# Se True: TensorRT -> CUDA -> CPU, sempre expondo degradação na telemetria.
# Se False: falha a inicialização quando o provider solicitado não estiver disponível.
"allow_provider_fallback": True,
# ROI vertical da segmentação.
# 0.0 + 1.0 = frame inteiro.
"ia_roi_begin": 0.0,
"ia_roi_size": 1.0,
# CameraOak configura ColorCamera.ColorOrder.RGB. O ONNX também exige RGB,
# então o hot path evita conversão de cor. Se a fonte mudar no futuro,
# BGR continua suportado explicitamente pelo runner.
"camera_frame_color": "RGB",
# 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
# ============================================================
# TensorRT. O cache real vira <root>/<sha-do-onnx>/ automaticamente.
"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_engine_cache_root": "./trt_cache_visual_worker",
"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,
# ONNX Runtime.
"ort_intra_op_num_threads": 1,
"ort_inter_op_num_threads": 1,
# Se existir .contract.json, valida o SHA256 do ONNX no startup.
"verify_sidecar_sha256": True,
# Checagem de IDs únicos a cada inferência. Útil em laboratório, desnecessária no campo.
"validate_outputs_each_inference": False,
},
# ============================================================
# 5) SegmentacaoManager v1
# 4) Análise geométrica + resolução temporal do status
# ============================================================
# Este bloco controla apenas a análise da máscara:
# pred_ids + label_probs -> dados_visuais.
# IDs semânticos e nomes de status vêm do contrato ONNX.
"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.
# Frações verticais para centro/largura/ângulo. 0=topo, 1=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.
# Suavização geométrica.
"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 v2: voto temporal ponderado + troca rápida quando a cabeça está muito segura.
"status_window_s": 0.90,
"status_decay_tau_s": 0.35,
"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,
# Modelo manda quando confiança E margem são boas.
"model_conf_accept": 0.72,
"model_margin_accept": 0.15,
# Zona intermediária: só ajuda quando concorda com heurística ou ela está indefinida.
"model_conf_soft": 0.55,
"model_margin_soft": 0.06,
# Transições muito fortes podem furar a inércia após N frames consecutivos.
"model_conf_fast": 0.90,
"model_margin_fast": 0.30,
"fast_switch_min_frames": 2,
# Pesos base do voto temporal.
"status_weight_model": 1.35,
"status_weight_model_soft": 1.00,
"status_weight_heuristic": 0.75,
"status_weight_agreement_bonus": 0.40,
# Grid leve interna para centro/fallback do corredor.
"corridor_grid_rows": 6,
@ -250,7 +258,7 @@ VISUAL_DEFAULT_CONFIG = {
"prefer_prev_weight": 0.65,
"prefer_width_weight": 1.00,
# Heurística de status quando o modelo auxiliar não está confiante.
# Heurística fica como fallback/segundo sensor até termos evidência de campo para removê-la.
"thr_parado_global": 0.30,
"thr_direcionando_global": 0.82,
"thr_caminhando_score": 0.48,
@ -260,7 +268,7 @@ VISUAL_DEFAULT_CONFIG = {
# ============================================================
# 6) Grid de confiança/custo
# 5) Grid de confiança/custo
# ============================================================
"grid": {
# Formato global da grid: (cols, rows).
@ -408,7 +416,7 @@ VISUAL_DEFAULT_CONFIG = {
# ============================================================
# 7) GPU Priority Controller
# 6) GPU Priority Controller
# ============================================================
# Controla FPS do Visual Worker conforme saúde do Weed Worker.
"gpu_priority": {
@ -510,24 +518,16 @@ DET_DEFAULT_CONFIG = {
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")
# Único artefato de IA exigido pelo Visual Worker v2.
# Classes, normalização, ROI e I/O vivem dentro do contrato do ONNX.
cfg["model_path"] = equipamento.get("path_ia_model_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.")
if not cfg.get("model_path"):
mostrar_log("[WARN] path do ONNX de corredor não definido no Redis/equipamento.")
# Garante tuplas onde as dataclasses esperam tuplas.
cfg["preview_size"] = tuple(cfg.get("preview_size", (1280, 720)))
@ -556,10 +556,6 @@ def normalizar_config_runtime_seg(cfg: dict) -> dict:
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 {}
@ -615,6 +611,8 @@ def load_seg_config():
# Cópia profunda simples dos blocos aninhados.
# Evita compartilhar dict interno entre chamadas.
cfg["telemetry"] = dict(VISUAL_DEFAULT_CONFIG["telemetry"])
cfg["model_runtime"] = dict(VISUAL_DEFAULT_CONFIG["model_runtime"])
cfg["segmentacao"] = dict(VISUAL_DEFAULT_CONFIG["segmentacao"])
cfg["grid"] = {
"grid_shape": VISUAL_DEFAULT_CONFIG["grid"]["grid_shape"],

View File

@ -20,8 +20,13 @@ def main():
# Importar e configurar OpenCV antes dos módulos do Visual Worker,
# pois eles podem carregar OpenCV internamente.
import cv2
cv2.setNumThreads(2)
opencv_threads = max(1, int(os.environ.get("VISUAL_OPENCV_THREADS", "4")))
cv2.setNumThreads(opencv_threads)
cv2.ocl.setUseOpenCL(False)
print(
f"[visual][PERF] OpenCV threads requested={opencv_threads} "
f"effective={cv2.getNumThreads()} preview_cache=True"
)
from shared.enums import VisualWorkerCommandType, TipoFrameCamera
from shared.utils import encode_image_base64
@ -53,6 +58,7 @@ def main():
time.sleep(0.1)
def redis_callback(dados):
acao = None
try:
acao = VisualWorkerCommandType(dados.get("cmd", 0))
@ -60,14 +66,14 @@ def main():
mx_id = dados.get("params")
if mx_id is not None:
iniciar_camera_manager(mx_id)
elif acao == VisualWorkerCommandType.CalibrarProfundidade:
n_frames = dados.get("params", 50)
get_camera_manager().gerar_grid_ref(n_frames)
elif acao == VisualWorkerCommandType.AtualizarSaudeCamera:
get_camera_manager().atualizar_saude_camera()
elif acao == VisualWorkerCommandType.GetCameraFrame:
tipo = TipoFrameCamera(dados.get("params", TipoFrameCamera.Rgb.value))
frame = get_camera_manager().get_selected_frame(tipo)
frame = get_camera_manager().get_selected_frame(
tipo,
aguardar_atualizacao=True,
)
if frame is not None:
base64_img = encode_image_base64(frame)
if base64_img is not None:
@ -82,14 +88,11 @@ def main():
pasta = dados.get("params", {}).get("caminho", "frames_salvos")
tipos = dados.get("params", {}).get("tipos", [])
get_camera_manager().salvar_frames(tipos, nome, pasta)
elif acao == VisualWorkerCommandType.EnviarImagemMock:
caminho = dados.get("params", "")
get_camera_manager().segmentacao_manager.use_mock = caminho != ""
get_camera_manager().segmentacao_manager.img_mock = caminho
else:
mostrar_log(f"⚠️ Comando desconhecido: {acao.name}")
except Exception as e:
mostrar_log(f"Erro ao processar comando {acao.name}: {e}")
acao_nome = getattr(acao, "name", str(dados.get("cmd", "desconhecido")))
mostrar_log(f"Erro ao processar comando {acao_nome}: {e}")
def inicializar():
mostrar_log("🚀 Iniciando Visual Worker...")

View File

@ -2,7 +2,6 @@ from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import IntEnum
from collections import deque
from typing import Any, Dict, List, Optional, Sequence, Tuple
@ -12,23 +11,19 @@ import numpy as np
from shared.enums import StatusCarroMapa
class ClassesSegmentacao(IntEnum):
NAONAVEGAVEL = 0
NAVEGAVEL = 1
@dataclass
class SegmentacaoConfig:
"""
Configuração v1 do analisador semântico do Visual Worker.
Configuração v2 do analisador semântico do Visual Worker.
Esta classe controla apenas análise de máscara.
Não controla ONNX, câmera, render, preview, stream nem Redis.
"""
# IDs esperados na máscara de segmentação.
id_nao_navegavel: int = int(ClassesSegmentacao.NAONAVEGAVEL)
id_navegavel: int = int(ClassesSegmentacao.NAVEGAVEL)
# IDs são OBRIGATORIAMENTE injetados do contrato ONNX v2.
# -1 faz o componente falhar fechado se alguém tentar inicializá-lo isolado.
id_nao_navegavel: int = -1
id_navegavel: int = -1
# Scanlines usadas para estimar centro, largura e ângulo do corredor.
# Frações verticais do frame: 0.0 = topo, 1.0 = base.
@ -42,13 +37,27 @@ class SegmentacaoConfig:
ema_alpha_lat: float = 0.25
ema_alpha_conf: float = 0.20
# Histórico para estabilizar status do corredor.
status_window_s: float = 1.5
# Histórico ponderado para estabilizar status do corredor.
status_window_s: float = 0.90
status_decay_tau_s: float = 0.35
status_expected_fps: float = 10.0
# Prioridade do status vindo da cabeça auxiliar ONNX.
model_conf_accept: float = 0.70
model_conf_soft: float = 0.45
# Cabeça de status ONNX v2: confiança + margem top1-top2.
model_conf_accept: float = 0.72
model_margin_accept: float = 0.15
model_conf_soft: float = 0.55
model_margin_soft: float = 0.06
# Troca rápida para transições muito seguras.
model_conf_fast: float = 0.90
model_margin_fast: float = 0.30
fast_switch_min_frames: int = 2
# Pesos do voto temporal.
status_weight_model: float = 1.35
status_weight_model_soft: float = 1.00
status_weight_heuristic: float = 0.75
status_weight_agreement_bonus: float = 0.40
# Grid leve usada para pontuar corredor e fallback de centro.
corridor_grid_rows: int = 6
@ -70,15 +79,16 @@ class SegmentacaoConfig:
# Debug leve: inclui métricas extras no retorno, sem criar imagens.
include_debug: bool = True
include_model_probs: bool = False
debug_timing: bool = False
class SegmentacaoManager:
"""
Analisador de segmentação semântica v1.
Analisador de segmentação semântica v2.
Responsabilidade única:
pred_ids + aux_result -> dados_visuais
pred_ids + status_result -> dados_visuais
Não renderiza.
Não colore máscara.
@ -94,6 +104,14 @@ class SegmentacaoManager:
):
self.config = self._normalizar_config(config)
if int(self.config.id_navegavel) < 0 or int(self.config.id_nao_navegavel) < 0:
raise RuntimeError(
"IDs semânticos não foram injetados pelo contrato ONNX v2: "
f"nav={self.config.id_navegavel} non_nav={self.config.id_nao_navegavel}"
)
if int(self.config.id_navegavel) == int(self.config.id_nao_navegavel):
raise RuntimeError("IDs navegável/não-navegável são iguais no contrato.")
# Mantidos como metadados úteis, mas não usados no caminho quente.
self.color_map = color_map
self.classes = classes
@ -105,6 +123,8 @@ class SegmentacaoManager:
self._status_hist = deque(maxlen=max_len)
self._status_final_hist = deque(maxlen=2)
self._strong_candidate: Optional[StatusCarroMapa] = None
self._strong_candidate_count: int = 0
self._ema_ang: Optional[float] = None
self._ema_lat: Optional[float] = None
@ -118,22 +138,22 @@ class SegmentacaoManager:
# API principal
# ---------------------------------------------------------------------
def segmentar(self, predictions: np.ndarray, aux_result: Optional[Dict[str, Any]] = None):
def segmentar(self, predictions: np.ndarray, status_result: Optional[Dict[str, Any]] = None):
"""
Compatibilidade nominal para o CameraManager atual.
Para a v1, o método preferido é analisar().
No contrato v2, o método preferido é analisar().
Retorna:
resultado, erro
"""
try:
resultado = self.analisar(predictions, aux_result=aux_result)
resultado = self.analisar(predictions, status_result=status_result)
return resultado, None
except Exception as e:
self._ultimo_erro = f"Erro na análise de segmentação: {e}"
return None, self._ultimo_erro
def analisar(self, predictions: np.ndarray, aux_result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def analisar(self, predictions: np.ndarray, status_result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
t0 = time.perf_counter()
pred = self._validar_predictions(predictions)
@ -161,7 +181,7 @@ class SegmentacaoManager:
pred=pred,
mask_nav=mask_nav,
corridor_score=corridor_score,
aux_result=aux_result,
status_result=status_result,
)
t_status = time.perf_counter()
@ -182,10 +202,26 @@ class SegmentacaoManager:
"status_corredor_anterior": int(status_before.value),
"status_corredor_anterior_nome": str(status_before.name),
# Telemetria compacta da segunda cabeça. Não depende do debug pesado.
"status_fonte": str(status_debug.get("origem_status", "desconhecida")),
"status_modelo_id": (status_debug.get("modelo") or {}).get("label_id"),
"status_modelo_nome": (status_debug.get("modelo") or {}).get("status_nome"),
"status_modelo_conf": (status_debug.get("modelo") or {}).get("conf"),
"status_modelo_margin": (status_debug.get("modelo") or {}).get("margin"),
"status_modelo_entropy": (status_debug.get("modelo") or {}).get("entropy"),
"status_modelo_second_nome": (status_debug.get("modelo") or {}).get("second_name"),
"status_modelo_second_conf": (status_debug.get("modelo") or {}).get("second_conf"),
"status_fast_switch": bool(status_debug.get("fast_switch", False)),
"centros_corredor": centros,
"larguras_px": larguras_px,
}
if self.config.include_model_probs:
probs = (status_debug.get("modelo") or {}).get("probs")
if probs is not None:
dados_visuais["status_modelo_probs"] = probs
if self.config.include_debug:
dados_visuais["status_corredor_debug"] = status_debug
dados_visuais["debug_corredor"] = {
@ -670,29 +706,100 @@ class SegmentacaoManager:
pred: np.ndarray,
mask_nav: np.ndarray,
corridor_score: float,
aux_result: Optional[Dict[str, Any]],
status_result: Optional[Dict[str, Any]],
):
model_status, model_conf, model_debug = self._status_from_aux(aux_result)
model_status, model_conf, model_margin, model_debug = self._status_from_model(
status_result
)
heur_status, heur_debug = self._status_heuristico(pred, mask_nav, corridor_score)
heur_status, heur_debug = self._status_heuristico(
pred, mask_nav, corridor_score
)
origem = "heuristica"
status_now = heur_status
weight = float(self.config.status_weight_heuristic)
if model_status is not None and model_conf >= self.config.model_conf_accept:
model_fast = (
model_status is not None
and model_conf >= self.config.model_conf_fast
and model_margin >= self.config.model_margin_fast
)
model_accept = (
model_status is not None
and model_conf >= self.config.model_conf_accept
and model_margin >= self.config.model_margin_accept
)
model_soft = (
model_status is not None
and model_conf >= self.config.model_conf_soft
and model_margin >= self.config.model_margin_soft
)
if model_accept:
status_now = model_status
origem = "modelo"
elif model_status is not None and model_conf >= self.config.model_conf_soft:
# Modelo com média confiança só vence quando a heurística está indefinida.
origem = "modelo_fast" if model_fast else "modelo"
weight = float(self.config.status_weight_model)
# Confiança e margem entram suavemente no peso, sem explodir a janela.
weight *= 0.75 + 0.25 * float(np.clip(model_conf, 0.0, 1.0))
weight *= 0.85 + 0.15 * float(np.clip(model_margin, 0.0, 1.0))
if heur_status == model_status:
weight += float(self.config.status_weight_agreement_bonus)
origem += "_agree"
elif model_soft:
if heur_status == StatusCarroMapa.Indefinido:
status_now = model_status
origem = "modelo_soft"
weight = float(self.config.status_weight_model_soft)
elif heur_status == model_status:
status_now = model_status
origem = "modelo_soft_agree"
weight = (
float(self.config.status_weight_model_soft)
+ float(self.config.status_weight_agreement_bonus)
)
else:
status_now = heur_status
origem = "heuristica_com_modelo_soft"
origem = "heuristica_modelo_soft_discorda"
now = self._now()
self._status_hist.append((status_now, now, float(weight), origem))
# Transições realmente fortes não precisam aguardar toda a janela temporal.
fast_switch = False
if model_fast and model_status is not None:
if self._strong_candidate == model_status:
self._strong_candidate_count += 1
else:
self._strong_candidate = model_status
self._strong_candidate_count = 1
if self._strong_candidate_count >= max(1, int(self.config.fast_switch_min_frames)):
status_final = model_status
fast_switch = True
# Re-semeia a janela com o estado forte para não voltar no frame seguinte.
self._status_hist.clear()
self._status_hist.append((
status_final,
now,
float(self.config.status_weight_model)
+ float(self.config.status_weight_agreement_bonus),
"fast_switch_seed",
))
else:
status_final, weighted_scores = self._weighted_status()
else:
self._strong_candidate = None
self._strong_candidate_count = 0
status_final, weighted_scores = self._weighted_status()
if fast_switch:
weighted_scores = {status_final.name: 1.0}
self._status_hist.append((status_now, self._now()))
status_final = self._majority_status()
self._status_final_hist.append(status_final)
status_before = (
@ -705,34 +812,64 @@ class SegmentacaoManager:
"origem_status": origem,
"modelo": model_debug,
"heuristica": heur_debug,
"status_now": status_now.name,
"status_final": status_final.name,
"candidate_weight": round(float(weight), 4),
"weighted_scores": weighted_scores,
"fast_switch": bool(fast_switch),
"fast_candidate": (
self._strong_candidate.name
if self._strong_candidate is not None
else None
),
"fast_candidate_count": int(self._strong_candidate_count),
}
return status_now, status_final, status_before, debug
def _status_from_aux(self, aux_result: Optional[Dict[str, Any]]):
if not aux_result or aux_result.get("type") != "label":
return None, 0.0, {
def _status_from_model(self, status_result: Optional[Dict[str, Any]]):
if not status_result:
return None, 0.0, 0.0, {
"disponivel": False,
"motivo": "sem_aux_result",
"motivo": "sem_status_result",
}
try:
label_id = int(aux_result.get("label_id", StatusCarroMapa.Indefinido.value))
conf = float(aux_result.get("label_conf", 0.0))
status = StatusCarroMapa(label_id)
label_name = str(status_result.get("label_name", "")).strip()
conf = float(status_result.get("confidence", 0.0))
margin = float(status_result.get("margin", 0.0))
entropy = float(status_result.get("entropy", 1.0))
return status, conf, {
if not label_name:
raise ValueError("label_name vazio")
# Mapeamento oficial é por NOME. A ordem dos IDs do treino pode mudar
# sem embaralhar a enum do controle.
status = StatusCarroMapa[label_name]
debug = {
"disponivel": True,
"status": int(status.value),
"status_nome": str(status.name),
"label_name": aux_result.get("label_name"),
"label_id": int(status_result.get("label_id", -1)),
"label_name": label_name,
"conf": round(conf, 4),
"margin": round(margin, 4),
"entropy": round(entropy, 4),
"second_id": int(status_result.get("second_id", -1)),
"second_name": status_result.get("second_name"),
"second_conf": round(float(status_result.get("second_confidence", 0.0)), 4),
}
except Exception as e:
return None, 0.0, {
if "probs" in status_result:
debug["probs"] = list(status_result["probs"])
return status, conf, margin, debug
except Exception as exc:
return None, 0.0, 0.0, {
"disponivel": False,
"motivo": f"aux_invalido: {e}",
"motivo": f"status_modelo_invalido: {exc}",
}
def _status_heuristico(
@ -827,33 +964,39 @@ class SegmentacaoManager:
def _now() -> float:
return time.monotonic()
def _majority_status(self, janela_s: Optional[float] = None) -> StatusCarroMapa:
J = self.config.status_window_s if janela_s is None else float(janela_s)
def _weighted_status(self) -> Tuple[StatusCarroMapa, Dict[str, float]]:
window_s = max(0.05, float(self.config.status_window_s))
tau_s = max(0.05, float(self.config.status_decay_tau_s))
now = self._now()
while self._status_hist and (now - self._status_hist[0][1] > J):
while self._status_hist and (now - self._status_hist[0][1] > window_s):
self._status_hist.popleft()
if not self._status_hist:
return StatusCarroMapa.Direcionando
return StatusCarroMapa.Direcionando, {}
counts: Dict[StatusCarroMapa, int] = {}
scores: Dict[StatusCarroMapa, float] = {}
latest_ts: Dict[StatusCarroMapa, float] = {}
for status, _t in self._status_hist:
counts[status] = counts.get(status, 0) + 1
top = max(counts.values())
tied = [status for status, count in counts.items() if count == top]
for status, ts, base_weight, _source in self._status_hist:
age = max(0.0, now - float(ts))
decay = float(np.exp(-age / tau_s))
score = max(0.01, float(base_weight)) * decay
scores[status] = scores.get(status, 0.0) + score
latest_ts[status] = max(latest_ts.get(status, 0.0), float(ts))
top_score = max(scores.values())
tied = [s for s, score in scores.items() if abs(score - top_score) <= 1e-9]
if len(tied) == 1:
return tied[0]
winner = tied[0]
else:
winner = max(tied, key=lambda st: latest_ts.get(st, 0.0))
# Desempate: status mais recente.
for status, _t in reversed(self._status_hist):
if status in tied:
return status
return StatusCarroMapa.Direcionando
debug_scores = {
status.name: round(float(score), 5)
for status, score in sorted(scores.items(), key=lambda item: item[1], reverse=True)
}
return winner, debug_scores
# ---------------------------------------------------------------------
# Suavização
@ -879,6 +1022,8 @@ class SegmentacaoManager:
def reset_estado_temporal(self):
self._status_hist.clear()
self._status_final_hist.clear()
self._strong_candidate = None
self._strong_candidate_count = 0
self._ema_ang = None
self._ema_lat = None

View File

@ -57,9 +57,12 @@ class VisualDebugRenderer:
return self.build_detection_overlay(rgb_frame, detections)
if frame_type == TipoFrameCamera.Debug:
overlay = self.build_overlay(rgb_frame, pred_ids, alpha=alpha)
# Debug prefere costmap. Só monta overlay como fallback, evitando
# construir duas imagens completas para descartar uma delas.
grid = self.build_costmap_debug(rgb_frame, snapshot)
return grid if grid is not None else overlay
if grid is not None:
return grid
return self.build_overlay(rgb_frame, pred_ids, alpha=alpha)
return None
@ -67,12 +70,12 @@ class VisualDebugRenderer:
if rgb_frame is None:
return None
img = rgb_frame.copy()
if rgb_frame.shape[1::-1] != self.preview_size:
# cv2.resize já cria um novo buffer; copiar antes seria tráfego de
# memória inútil no caminho de preview.
return cv2.resize(rgb_frame, self.preview_size, interpolation=cv2.INTER_AREA)
if img.shape[1::-1] != self.preview_size:
img = cv2.resize(img, self.preview_size, interpolation=cv2.INTER_AREA)
return img
return rgb_frame.copy()
def build_segmentation_preview(self, pred_ids):
if pred_ids is None or self.color_map is None:

View File

@ -6,8 +6,6 @@ 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:
@ -41,6 +39,10 @@ class DetectionGridConfig:
@dataclass
class GridConfidenceConfig:
# IDs vêm OBRIGATORIAMENTE do contrato ONNX e são injetados pelo CameraManager.
id_nao_navegavel: int = -1
id_navegavel: int = -1
valid_mm: Tuple[int, int] = (300, 10000)
min_valid_frac: float = 0.30
conf_params: Tuple[float, float] = (0.30, 0.80)
@ -144,6 +146,14 @@ class VisualGridBuilder:
def __init__(self, config: Optional[GridConfidenceConfig | Dict[str, Any]] = None):
self.config = self._normalizar_config(config)
if int(self.config.id_navegavel) < 0 or int(self.config.id_nao_navegavel) < 0:
raise RuntimeError(
"VisualGridBuilder exige IDs do contrato ONNX v2: "
f"nav={self.config.id_navegavel} non_nav={self.config.id_nao_navegavel}"
)
if int(self.config.id_navegavel) == int(self.config.id_nao_navegavel):
raise RuntimeError("Grid recebeu IDs navegável/não-navegável iguais.")
@staticmethod
def _normalizar_config(config):
if config is None:
@ -236,8 +246,8 @@ class VisualGridBuilder:
if n == 0:
continue
n_nav = np.count_nonzero(seg_block == ClassesSegmentacao.NAVEGAVEL.value)
n_naonav = np.count_nonzero(seg_block == ClassesSegmentacao.NAONAVEGAVEL.value)
n_nav = np.count_nonzero(seg_block == cfg.id_navegavel)
n_naonav = np.count_nonzero(seg_block == cfg.id_nao_navegavel)
pct_navegavel[j, i] = n_nav / n
pct_nao_navegavel[j, i] = n_naonav / n

View File

@ -1,144 +1,31 @@
import cv2
import numpy as np
import cupy as cp
import math
from scipy import stats
from shared.enums import T_Code
def gerar_heatmap(depth_frame, dist_max_mm=3000.0):
mask_valid = depth_frame <= dist_max_mm
depth_normalized = np.zeros(depth_frame.shape, dtype=np.uint8)
depth_normalized[mask_valid] = (255 - ((depth_frame[mask_valid] / dist_max_mm) * 255)).astype(np.uint8)
depth_normalized[mask_valid] = (
255 - ((depth_frame[mask_valid] / dist_max_mm) * 255)
).astype(np.uint8)
heatmap = cv2.applyColorMap(depth_normalized, cv2.COLORMAP_JET)
heatmap[~mask_valid] = [0, 0, 0]
return heatmap
def calcular_threshold_anomalias(velocidade_ms=None, threshold_base=0.15, fator_sensibilidade=100):
"""
Calcula o threshold dinamicamente baseado na velocidade do robô.
- velocidade_ms: Velocidade em metros por segundo (m/s).
"""
try:
if velocidade_ms is None:
from shared.contexto_global_redis import ContextoGlobalRedis
velocidade_ms = ContextoGlobalRedis.get_contexto().get("Gerais", {}).get("velocidade_ms", 0.0)
threshold = threshold_base + (velocidade_ms * fator_sensibilidade)
return round(threshold, 3)
except Exception as e:
print(f"❌ Erro ao calcular thresold de anomalias: {e}")
return threshold_base
def obter_regiao_solo(depth_frame, percentual_altura):
depth_frame = cp.asarray(depth_frame) # 🔥 Garante que está na GPU
altura, largura = depth_frame.shape
linhas_solo = int((percentual_altura / 100) * altura)
y_inicio = altura - linhas_solo
depth_solo = depth_frame[y_inicio:altura, :] # 🔥 Slice direto na GPU
return depth_solo, y_inicio, linhas_solo, altura, largura
def calcular_referencia(frames, metodo="moda"):
stack = np.stack(frames)
if metodo == "moda":
ref = stats.mode(stack, axis=0, keepdims=True)[0][0]
elif metodo == "media":
ref = np.mean(stack, axis=0)
elif metodo == "mediana":
ref = np.median(stack, axis=0)
else:
raise ValueError("Método inválido. Use 'moda', 'media' ou 'mediana'.")
return ref
def calcular_inclinacao_solo(perfil, fov_h):
"""
Calcula o ângulo de inclinação do solo em graus.
:param perfil: Lista de profundidades dos setores [z1, z2, ..., zn]
:param largura_total_metros: Largura total em metros do campo de visão
:return: Ângulo de inclinação em graus
"""
if len(perfil) < 2:
return 0 # Não dá pra calcular
distancia_media = np.mean(perfil)
largura = 2 * math.tan(fov_h / 2) * distancia_media
delta_z = perfil[-1] - perfil[0]
delta_x = largura
angulo_rad = math.atan2(delta_z, delta_x)
angulo_graus = math.degrees(angulo_rad)
return round(angulo_graus, 3)
def calcular_profundidades_setores(depth_solo, n_setores, largura, distancia_max_mm):
depth_solo = cp.asarray(depth_solo) # 🔥 Garante que está na GPU
largura_setor = largura // n_setores
profundidades = []
for i in range(n_setores):
x_inicio = i * largura_setor
x_fim = (i + 1) * largura_setor if (i < n_setores - 1) else largura
setor = depth_solo[:, x_inicio:x_fim]
# 🔥 Filtra profundidade válida
setor_valido = setor[(setor > 0) & (setor < distancia_max_mm)]
z = cp.median(setor_valido).item() if setor_valido.size > 0 else 0 # 🔥 .item() p/ float
profundidades.append(z)
return cp.asnumpy(cp.array(profundidades)), largura_setor
def histogram2d_gpu(x, y, bins, range):
try:
if x.size == 0 or y.size == 0:
return cp.zeros(bins, dtype=cp.int32)
x_bins, y_bins = bins
(x_min, x_max), (y_min, y_max) = range
x_idx = cp.floor((x - x_min) / (x_max - x_min) * x_bins).astype(cp.int32)
y_idx = cp.floor((y - y_min) / (y_max - y_min) * y_bins).astype(cp.int32)
mask = (x_idx >= 0) & (x_idx < x_bins) & (y_idx >= 0) & (y_idx < y_bins)
x_idx = x_idx[mask]
y_idx = y_idx[mask]
if x_idx.size == 0:
return cp.zeros((x_bins, y_bins), dtype=cp.int32)
linear_idx = x_idx * y_bins + y_idx
if linear_idx.size == 0:
return cp.zeros((x_bins, y_bins), dtype=cp.int32)
hist_flat = cp.bincount(linear_idx, minlength=x_bins * y_bins)
hist = hist_flat.reshape((x_bins, y_bins))
return hist
except Exception as e:
print(f"Erro ao gerar histograma 2d: {e}")
return cp.zeros((x_bins, y_bins), dtype=cp.int32)
def converter_valores_numpy(obj):
if isinstance(obj, dict):
return {k: converter_valores_numpy(v) for k, v in obj.items()}
elif isinstance(obj, list):
if isinstance(obj, list):
return [converter_valores_numpy(v) for v in obj]
elif isinstance(obj, tuple):
if isinstance(obj, tuple):
return tuple(converter_valores_numpy(v) for v in obj)
elif isinstance(obj, (np.integer, np.int32, np.int64)):
if isinstance(obj, (np.integer, np.int32, np.int64)):
return int(obj)
elif isinstance(obj, (np.floating, np.float32, np.float64)):
if isinstance(obj, (np.floating, np.float32, np.float64)):
return float(obj)
elif isinstance(obj, (np.bool_)):
if isinstance(obj, np.bool_):
return bool(obj)
elif isinstance(obj, np.ndarray):
if isinstance(obj, np.ndarray):
return obj.tolist()
else:
return obj

View File

@ -28,7 +28,7 @@ from visual_worker.utils import converter_valores_numpy
from shared.perf_monitor import VisualPerfMonitor
CAMERA_MANAGER_VERSION = "production_v1_2026_09_12_runtime_priority_v3"
CAMERA_MANAGER_VERSION = "production_v1_2026_09_15_debug_geometry_sync_v4"
PRODUCT_ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
PRODUCT_MODULE_SCHEMA = "multispec_module_params_v3"
@ -2044,6 +2044,135 @@ class CameraManager:
return True
@staticmethod
def _clamp01_debug(value, default=0.0):
try:
return max(0.0, min(1.0, float(value)))
except Exception:
return max(0.0, min(1.0, float(default)))
def _resolver_geometria_debug_bicos(self, config, analise_debug=None):
"""
Resolve a geometria que o Debug deve desenhar.
Autoridade, nesta ordem:
1) estatísticas produzidas pelo WeedDetector na última decisão real;
2) o próprio helper do WeedDetector, quando disponível;
3) fallback local com o MESMO contrato: 0=topo, 1=pé.
Assim o preview não mantém uma segunda implementação da lógica de atuação.
"""
cfg = config or {}
analise_debug = analise_debug or {}
dados_visuais = analise_debug.get("dados_visuais", {}) or {}
estatisticas = dados_visuais.get("estatisticas", {}) or {}
estat_bicos = estatisticas.get("bicos", {}) or {}
estat_atuacao = estat_bicos.get("atuacao", {}) or {}
ordem_invertida = bool(
estat_bicos.get(
"ordem_bicos_invertida",
cfg.get("inverter_ordem_bicos", False),
)
)
sentido_vertical_invertido = bool(
estat_bicos.get(
"sentido_vertical_invertido",
estat_atuacao.get(
"sentido_vertical_invertido",
cfg.get("inverter_sentido_vertical_bicos", False),
),
)
)
zona_ini = estat_atuacao.get("zona_atuacao_inicio_frac")
zona_fim = estat_atuacao.get("zona_atuacao_fim_frac")
eval_ini = estat_atuacao.get("zona_eval_inicio_frac")
eval_fim = estat_atuacao.get("zona_eval_fim_frac")
# Se ainda não existe uma decisão real (startup, reset etc.), pede ao
# detector a mesma função usada pelo controle. Evita duplicar semântica.
if zona_ini is None or zona_fim is None:
detector = self.weed_detector
helper = getattr(detector, "_calcular_zona_atuacao_frac_from_config", None)
if callable(helper):
try:
zona_ini, zona_fim = helper(cfg)
except Exception:
zona_ini = None
zona_fim = None
# Último fallback, mantido fail-soft para o Debug. Esta é a convenção
# oficial atual do WeedDetector: faixa é medida de cima para baixo.
if zona_ini is None or zona_fim is None:
faixa = self._clamp01_debug(cfg.get("faixa_atuacao_bicos", 0.70), 0.70)
area = self._clamp01_debug(cfg.get("area_atuacao_bicos", 0.10), 0.10)
area = max(0.001, area)
zona_ini = faixa
zona_fim = min(1.0, faixa + area)
if sentido_vertical_invertido:
zona_ini, zona_fim = 1.0 - zona_fim, 1.0 - zona_ini
zona_ini = self._clamp01_debug(zona_ini, 0.0)
zona_fim = self._clamp01_debug(zona_fim, zona_ini)
if zona_fim < zona_ini:
zona_ini, zona_fim = zona_fim, zona_ini
# Sem compensação de latência disponível, a zona avaliada coincide com
# a zona física. Quando o detector informar outra zona, mostramos ambas.
if eval_ini is None or eval_fim is None:
eval_ini, eval_fim = zona_ini, zona_fim
eval_ini = self._clamp01_debug(eval_ini, zona_ini)
eval_fim = self._clamp01_debug(eval_fim, zona_fim)
if eval_fim < eval_ini:
eval_ini, eval_fim = eval_fim, eval_ini
pulverizacao = dados_visuais.get("pulverizacao", {}) or {}
return {
"zona_inicio_frac": float(zona_ini),
"zona_fim_frac": float(zona_fim),
"zona_eval_inicio_frac": float(eval_ini),
"zona_eval_fim_frac": float(eval_fim),
"ordem_bicos_invertida": bool(ordem_invertida),
"sentido_vertical_invertido": bool(sentido_vertical_invertido),
"pulverizacao": pulverizacao,
"fonte_detector": bool(estat_atuacao),
}
@staticmethod
def _frac_vertical_para_pixels(inicio_frac, fim_frac, altura):
if altura <= 1:
return 0, 0
inicio = max(0.0, min(1.0, float(inicio_frac)))
fim = max(0.0, min(1.0, float(fim_frac)))
if fim < inicio:
inicio, fim = fim, inicio
y0 = int(round(inicio * (altura - 1)))
y1 = int(round(fim * (altura - 1)))
y0 = max(0, min(altura - 1, y0))
y1 = max(0, min(altura - 1, y1))
return min(y0, y1), max(y0, y1)
@staticmethod
def _intervalo_visual_bico(indice_bico, qtd_bicos, largura, ordem_invertida):
"""Mapeia índice FÍSICO do bico para a coluna correspondente da imagem."""
qtd = max(1, int(qtd_bicos))
idx = int(indice_bico)
idx_visual = (qtd - 1 - idx) if ordem_invertida else idx
x0 = int(round(idx_visual * largura / float(qtd)))
x1 = int(round((idx_visual + 1) * largura / float(qtd))) - 1
x0 = max(0, min(largura - 1, x0))
x1 = max(x0, min(largura - 1, x1))
return x0, x1
def get_debug_frame(self, mostrar=False, overlay_bgr=None):
overlay = overlay_bgr if overlay_bgr is not None else self._ultimo_preview_overlay
@ -2057,12 +2186,21 @@ class CameraManager:
"fps_loop": self._ultimo_loop_analise_fps,
}
# A detecção publica controle final + análise sob _vida_lock. Tiramos o
# snapshot sob o mesmo lock para o Debug não misturar estados de ciclos
# diferentes enquanto a thread de detecção está atualizando os caches.
with self._vida_lock:
atuacao_bicos = dict(self._ultimo_controle or {})
analise_debug = dict(self._ultima_analise or {})
config_debug = dict(self.seg_config or {})
# Não publica diretamente no cache: o preview producer faz a
# publicação atômica depois de reduzir/comprimir o bundle inteiro.
return self._montar_debug_overlay(
overlay_bgr=overlay,
atuacao_bicos=self._ultimo_controle or {},
config=self.seg_config or {},
atuacao_bicos=atuacao_bicos,
config=config_debug,
analise_debug=analise_debug,
metricas_perf=metricas_perf,
mostrar=mostrar,
)
@ -2072,6 +2210,7 @@ class CameraManager:
overlay_bgr,
atuacao_bicos,
config,
analise_debug=None,
metricas_perf=None,
mostrar=False,
):
@ -2080,6 +2219,10 @@ class CameraManager:
return None
metricas_perf = metricas_perf or {}
geometria = self._resolver_geometria_debug_bicos(
config=config,
analise_debug=analise_debug,
)
W, H = self._dbg_shape
@ -2104,25 +2247,68 @@ class CameraManager:
self._dbg_img[...] = overlay_bgr
qtd_bicos = int(config.get("qtd_bicos", self.qtd_bicos or 1) or 1)
zona_inicio = float(config.get("faixa_atuacao_bicos", 0.7))
faixa_atuacao = float(config.get("area_atuacao_bicos", 0.1))
qtd_bicos = max(1, qtd_bicos)
y_inicio = int((1.0 - zona_inicio) * H)
y_fim = int((1.0 - (zona_inicio + faixa_atuacao)) * H)
y0, y1 = min(y_inicio, y_fim), max(y_inicio, y_fim)
y0, y1 = self._frac_vertical_para_pixels(
geometria["zona_inicio_frac"],
geometria["zona_fim_frac"],
H,
)
ey0, ey1 = self._frac_vertical_para_pixels(
geometria["zona_eval_inicio_frac"],
geometria["zona_eval_fim_frac"],
H,
)
# ------------------------------------------------------------
# Zona FÍSICA dos bicos. É a faixa configurada pelo operador.
# ------------------------------------------------------------
self._dbg_layer.fill(0)
cv2.rectangle(
self._dbg_layer,
(0, y0),
(W, y1),
(W - 1, y1),
(220, 220, 100),
thickness=-1,
)
cv2.addWeighted(self._dbg_layer, 0.18, self._dbg_img, 0.82, 0, dst=self._dbg_img)
cv2.rectangle(self._dbg_img, (0, y0), (W, y1), (180, 180, 80), 2)
cv2.addWeighted(
self._dbg_layer,
0.18,
self._dbg_img,
0.82,
0,
dst=self._dbg_img,
)
cv2.rectangle(
self._dbg_img,
(0, y0),
(W - 1, y1),
(180, 180, 80),
2,
)
# Zona REALMENTE avaliada pelo detector quando há antecipação por
# latência/velocidade. Se coincidir com a zona física, não duplica.
eval_diferente = abs(ey0 - y0) > 1 or abs(ey1 - y1) > 1
if eval_diferente:
cv2.rectangle(
self._dbg_img,
(0, ey0),
(W - 1, ey1),
(255, 255, 0),
2,
)
cv2.putText(
self._dbg_img,
"EVAL",
(max(5, W - 60), max(16, min(H - 5, ey0 + 16))),
cv2.FONT_HERSHEY_SIMPLEX,
0.45,
(255, 255, 0),
1,
cv2.LINE_AA,
)
largura_bico = W / float(max(qtd_bicos, 1))
cores = [
(0, 255, 0),
(255, 0, 0),
@ -2134,33 +2320,64 @@ class CameraManager:
(0, 0, 255),
]
ordem_invertida = bool(geometria["ordem_bicos_invertida"])
# ------------------------------------------------------------
# Estado ON/OFF FINAL, já depois do gate de pulverização.
# Cada índice é o bico FÍSICO. A posição visual respeita
# inverter_ordem_bicos exatamente como o WeedDetector.
# ------------------------------------------------------------
self._dbg_layer.fill(0)
for i in range(qtd_bicos):
x0 = int(i * largura_bico)
x1 = int((i + 1) * largura_bico)
x0, x1 = self._intervalo_visual_bico(
i,
qtd_bicos,
W,
ordem_invertida,
)
cor = cores[i % len(cores)]
if atuacao_bicos.get(i, False):
cv2.rectangle(self._dbg_layer, (x0, y0), (x1, y1), cor, thickness=-1)
if bool(atuacao_bicos.get(i, False)):
cv2.rectangle(
self._dbg_layer,
(x0, y0),
(x1, y1),
cor,
thickness=-1,
)
cv2.addWeighted(self._dbg_layer, 0.15, self._dbg_img, 0.85, 0, dst=self._dbg_img)
cv2.addWeighted(
self._dbg_layer,
0.15,
self._dbg_img,
0.85,
0,
dst=self._dbg_img,
)
for i in range(qtd_bicos):
x0 = int(i * largura_bico)
x1 = int((i + 1) * largura_bico)
x0, x1 = self._intervalo_visual_bico(
i,
qtd_bicos,
W,
ordem_invertida,
)
cor = cores[i % len(cores)]
status = "ON" if atuacao_bicos.get(i, False) else "OFF"
status = "ON" if bool(atuacao_bicos.get(i, False)) else "OFF"
cv2.rectangle(self._dbg_img, (x0, y0), (x1, y1), cor, 1)
texto = f"Bico {i} {status}"
text_y = max(18, min(H - 6, y0 + 20))
cv2.putText(
self._dbg_img,
f"Bico {i} {status}",
(x0 + 5, min(H - 5, y1 + 20)),
texto,
(x0 + 4, text_y),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
0.48,
cor,
2,
1,
cv2.LINE_AA,
)
fps_infer = float(metricas_perf.get("fps_infer") or 0.0)
@ -2188,6 +2405,51 @@ class CameraManager:
2,
)
zona_txt = (
f"Zona={geometria['zona_inicio_frac'] * 100:.0f}-"
f"{geometria['zona_fim_frac'] * 100:.0f}%"
)
if eval_diferente:
zona_txt += (
f" | Eval={geometria['zona_eval_inicio_frac'] * 100:.0f}-"
f"{geometria['zona_eval_fim_frac'] * 100:.0f}%"
)
zona_txt += (
f" | V={'INV' if geometria['sentido_vertical_invertido'] else 'NORMAL'}"
f" H={'INV' if ordem_invertida else 'NORMAL'}"
)
cv2.putText(
self._dbg_img,
zona_txt,
(10, 88),
cv2.FONT_HERSHEY_SIMPLEX,
0.48,
(230, 230, 230),
1,
cv2.LINE_AA,
)
pulverizacao = geometria.get("pulverizacao", {}) or {}
if pulverizacao:
permitida = bool(pulverizacao.get("permitida", False))
motivo = str(pulverizacao.get("motivo", "") or "")
gate_txt = f"Pulv: {'ON' if permitida else 'OFF'}"
if motivo and motivo != "ok":
gate_txt += f" ({motivo[:48]})"
cv2.putText(
self._dbg_img,
gate_txt,
(10, 108),
cv2.FONT_HERSHEY_SIMPLEX,
0.45,
(200, 255, 200) if permitida else (180, 180, 255),
1,
cv2.LINE_AA,
)
if mostrar:
cv2.imshow("Debug Weed Worker", self._dbg_img)
cv2.waitKey(1)

View File

@ -256,7 +256,7 @@ WEED_DEFAULT_CONFIG = {
# True no rover atual:
# a aproximação física do alvo ocorre do pé para o topo da imagem.
"inverter_sentido_vertical_bicos": True,
"inverter_sentido_vertical_bicos": False,
# Zona física de atuação no frame.
# Ambos podem ser sobrescritos pela configuração da operação.

View File

@ -25,11 +25,14 @@ class WeedDetector:
- Desloca a memória pela distância real percorrida pelo rover.
- Atua quando a evidência chega na zona física de atuação.
Convenção espacial:
- cell index 0 = região mais próxima da linha dos bicos.
- cell index maior = região mais distante, vista antes pela câmera.
- Conforme o rover anda, a memória se desloca de índices maiores
para índices menores.
Convenção espacial oficial:
- eixo vertical sempre usa coordenada de imagem: 0.0 = topo, 1.0 = pé.
- cell index 0 = topo da imagem.
- cell index N-1 = pé da imagem.
- inverter_sentido_vertical_bicos=False (rover atual):
alvo caminha topo -> pé; faixa=0.85, area=0.15 atua em 85%..100%.
- inverter_sentido_vertical_bicos=True:
alvo caminha pé -> topo; a mesma faixa é espelhada para 0%..15%.
Este arquivo NÃO:
- gera imagem;
@ -41,6 +44,7 @@ class WeedDetector:
CONTRATO_OFICIAL = "target_binary"
TARGET_ID = 1
VERSION = "weed_detector_v2_2026_09_15_vertical_contract_fix"
def __init__(self, config: Optional[dict] = None):
# O CameraManager já mantém um snapshot de configuração. Quando ele é
@ -76,8 +80,14 @@ class WeedDetector:
self.config.get("inverter_ordem_bicos", False)
)
# Convenção oficial:
# False = fluxo normal topo -> pé (rover atual).
# True = fluxo invertido pé -> topo.
#
# IMPORTANTE: esta flag altera tanto o sentido da memória espacial
# quanto a posição vertical da zona física de atuação.
self.inverter_sentido_vertical_bicos = bool(
self.config.get("inverter_sentido_vertical_bicos", True)
self.config.get("inverter_sentido_vertical_bicos", False)
)
self.detector_debug_perf = bool(
@ -168,11 +178,14 @@ class WeedDetector:
0.0 = topo da imagem
1.0 = pé da imagem
Normal:
Normal (inverter_sentido_vertical_bicos=False):
faixa=0.90, area=0.10 -> 90% até 100%, embaixo.
Com inverter_sentido_vertical_bicos=True:
Invertido (inverter_sentido_vertical_bicos=True):
faixa=0.90, area=0.10 -> 0% até 10%, em cima.
Portanto, para o rover atual, em que o alvo se aproxima do topo para
o pé da imagem, a configuração correta é False.
"""
faixa = float(cfg.get("faixa_atuacao_bicos", 0.70) or 0.70)
@ -918,11 +931,12 @@ class WeedDetector:
k_shift = max(0.0, k_shift)
# A zona configurada representa a posição física fixa dos bicos.
# Com faixa=0,85, área=0,10 e inversão vertical, 85%..95% da imagem
# original vira 5%..15% na convenção interna. Essa zona NÃO muda de
# parado até a velocidade de referência. Somente a parcela de
# velocidade ACIMA da referência antecipa a avaliação para compensar
# o tempo de resposta do sistema.
# Exemplo no rover atual (sentido normal / False):
# faixa=0,85, área=0,15 -> 85%..100%, no pé da imagem.
# No sentido invertido / True a mesma faixa é espelhada para 0%..15%.
# Essa zona NÃO muda de parado até a velocidade de referência. Somente
# a parcela de velocidade ACIMA da referência antecipa a avaliação para
# compensar o tempo de resposta do sistema.
vel_referencia = max(
0.0,
float(cfg.get("velocidade_referencia_atuacao_mps", 0.65) or 0.65),
@ -1282,19 +1296,46 @@ class WeedDetector:
vel_norm: float,
cfg: dict,
):
# Convenção legada:
# zona_inicio e zona_altura são frações medidas a partir da parte inferior da imagem.
y_inicio = int((1.0 - zona_inicio) * h)
y_fim = int((1.0 - (zona_inicio + zona_altura)) * h)
"""
ROI do fallback legado usando a MESMA convenção do caminho oficial.
y_top = min(y_inicio, y_fim)
y_bot = max(y_inicio, y_fim)
Coordenada vertical:
0.0 = topo
1.0 = pé
False: alvo topo -> pé; 0.85 + 0.15 => faixa inferior.
True : alvo pé -> topo; a mesma faixa é espelhada para o topo.
Os argumentos zona_inicio/zona_altura são mantidos na assinatura por
compatibilidade, mas o cálculo usa os valores de cfg para garantir que
exista uma única fonte de verdade.
"""
_ = zona_inicio, zona_altura
zona_ini_frac, zona_fim_frac = self._calcular_zona_atuacao_frac_from_config(cfg)
y_top = int(np.floor(zona_ini_frac * h))
y_bot = int(np.ceil(zona_fim_frac * h))
y_top = max(0, min(h, y_top))
y_bot = max(y_top, min(h, y_bot))
# Antecipação legada em pixels. A avaliação deve se deslocar para a
# região que o alvo ocupa ANTES de chegar aos bicos:
# fluxo topo -> pé : antecipa para cima (subtrai y)
# fluxo pé -> topo : antecipa para baixo (soma y)
roi_shift_per_v = float(cfg.get("k_roi_shift_px_per_vnorm", 24.0))
shift = int(roi_shift_per_v * self._clamp01(vel_norm))
shift = int(max(0.0, roi_shift_per_v) * self._clamp01(vel_norm))
if getattr(self, "inverter_sentido_vertical_bicos", False):
y_top = min(h, y_top + shift)
y_bot = min(h, y_bot + shift)
else:
y_top = max(0, y_top - shift)
y_bot = max(y_top, min(h, y_bot - shift))
y_bot = max(0, y_bot - shift)
if y_bot < y_top:
y_top, y_bot = y_bot, y_top
return int(y_top), int(y_bot), int(shift)

View File

@ -0,0 +1,851 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Capture OAK-D Lite - Agri Corridor Dataset
==========================================
Captura RGB para o dataset da camera frontal.
Contrato desta etapa:
- roda de dentro da pasta oak-d/
- captura o mesmo dominio de video usado no runtime:
OAK-D Lite -> ColorCamera 1080p -> cam.video -> host
- salva SOMENTE a imagem RGB original em PNG, sem resize e sem normalizacao
- destino padrao:
dataset/brutas/
- resize existe apenas para PREVIEW da interface e nunca toca a imagem salva
Teclas:
SPACE / S : salvar frame atual
A : ligar/desligar auto-save
E : alternar exposicao AUTO/MANUAL
+ / = : aumentar ISO no modo manual
- : diminuir ISO no modo manual
M : aumentar exposicao no modo manual
N : diminuir exposicao no modo manual
F : alternar foco AUTO/MANUAL
] : aumentar foco manual
[ : diminuir foco manual
V : alternar fullscreen
Q / ESC : sair
Exemplo:
python _0_capture.py
Auto-save a cada 1 segundo:
python _0_capture.py --auto --interval 1.0
Outra pasta:
python _0_capture.py --out dataset/brutas
"""
from __future__ import annotations
import argparse
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple
import cv2
import depthai as dai
import numpy as np
CAPTURE_W = 1920
CAPTURE_H = 1080
CAPTURE_FPS = 30.0
DEFAULT_OUT = Path("dataset") / "brutas"
DEFAULT_EXPOSURE_US = 6000
DEFAULT_ISO = 400
DEFAULT_FOCUS = 130
EXPOSURE_MIN_US = 100
EXPOSURE_MAX_US = 30000
EXPOSURE_STEP_US = 500
ISO_MIN = 100
ISO_MAX = 1600
ISO_STEP = 50
FOCUS_MIN = 0
FOCUS_MAX = 255
FOCUS_STEP = 5
WINDOW_NAME = "OAK-D Lite | Dataset Corredor Agricola"
CAPTURE_SESSION_ID = datetime.now().strftime("%Y%m%d_%H%M%S")
def timestamp_name() -> str:
"""
Nome novo:
img_sYYYYMMDD_HHMMSS__YYYYMMDD_HHMMSS_micro.png
O primeiro timestamp identifica a SESSAO de captura (uma execução do script).
O segundo identifica o frame.
Isso permite ao split manter uma mesma sessão inteira em train OU val.
"""
frame_ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
return f"img_s{CAPTURE_SESSION_ID}__{frame_ts}"
def save_png(path: Path, frame_bgr: np.ndarray) -> Tuple[bool, str]:
path.parent.mkdir(parents=True, exist_ok=True)
ok = cv2.imwrite(
str(path),
frame_bgr,
[cv2.IMWRITE_PNG_COMPRESSION, 3],
)
return bool(ok), str(path)
def fit_inside(image: np.ndarray, max_w: int, max_h: int) -> np.ndarray:
h, w = image.shape[:2]
if w <= 0 or h <= 0:
return image
scale = min(
float(max_w) / float(w),
float(max_h) / float(h),
)
scale = max(scale, 1e-6)
new_w = max(1, int(round(w * scale)))
new_h = max(1, int(round(h * scale)))
interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR
return cv2.resize(image, (new_w, new_h), interpolation=interp)
def live_quality(frame_bgr: np.ndarray) -> dict:
"""
QA apenas visual.
Nunca rejeita nem altera a imagem salva.
"""
small = fit_inside(frame_bgr, 640, 360)
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
mean = float(gray.mean())
dark_pct = float((gray <= 5).mean() * 100.0)
bright_pct = float((gray >= 250).mean() * 100.0)
sharpness = float(cv2.Laplacian(gray, cv2.CV_64F).var())
warnings = []
if mean < 35:
warnings.append("MUITO ESCURA")
elif mean > 225:
warnings.append("MUITO CLARA")
if dark_pct > 20.0:
warnings.append("CLIP PRETO")
if bright_pct > 8.0:
warnings.append("CLIP BRANCO")
if sharpness < 35.0:
warnings.append("FOCO/BLUR?")
return {
"mean": mean,
"dark_pct": dark_pct,
"bright_pct": bright_pct,
"sharpness": sharpness,
"warnings": warnings,
}
def put_line(
canvas: np.ndarray,
text: str,
xy: Tuple[int, int],
scale: float = 0.65,
thickness: int = 1,
color=(235, 235, 235),
):
cv2.putText(
canvas,
text,
xy,
cv2.FONT_HERSHEY_SIMPLEX,
scale,
color,
thickness,
cv2.LINE_AA,
)
class CameraControls:
def __init__(
self,
queue,
exposure_us: int,
iso: int,
focus: int,
):
self.queue = queue
self.exposure_auto = True
self.focus_auto = True
self.exposure_us = int(exposure_us)
self.iso = int(iso)
self.focus = int(focus)
def send_initial_auto(self):
ctrl = dai.CameraControl()
try:
ctrl.setAutoExposureEnable()
except Exception:
pass
try:
ctrl.setAutoWhiteBalanceLock(False)
except Exception:
pass
try:
ctrl.setAutoFocusMode(
dai.CameraControl.AutoFocusMode.CONTINUOUS_VIDEO
)
ctrl.setAutoFocusTrigger()
except Exception:
pass
self.queue.send(ctrl)
def set_exposure_auto(self):
ctrl = dai.CameraControl()
try:
ctrl.setAutoExposureEnable()
except Exception as exc:
print(f"[WARN] Nao consegui ativar AE: {exc}")
return
self.queue.send(ctrl)
self.exposure_auto = True
print("[CAM] Exposicao: AUTO")
def set_exposure_manual(self):
self.exposure_us = int(
np.clip(
self.exposure_us,
EXPOSURE_MIN_US,
EXPOSURE_MAX_US,
)
)
self.iso = int(
np.clip(
self.iso,
ISO_MIN,
ISO_MAX,
)
)
ctrl = dai.CameraControl()
ctrl.setManualExposure(
self.exposure_us,
self.iso,
)
self.queue.send(ctrl)
self.exposure_auto = False
print(
f"[CAM] Exposicao: MANUAL | "
f"{self.exposure_us} us | ISO {self.iso}"
)
def toggle_exposure(self):
if self.exposure_auto:
self.set_exposure_manual()
else:
self.set_exposure_auto()
def adjust_exposure(self, delta_us: int):
if self.exposure_auto:
print("[CAM] Ajuste de exposicao ignorado: pressione E para modo MANUAL.")
return
self.exposure_us += int(delta_us)
self.set_exposure_manual()
def adjust_iso(self, delta_iso: int):
if self.exposure_auto:
print("[CAM] Ajuste de ISO ignorado: pressione E para modo MANUAL.")
return
self.iso += int(delta_iso)
self.set_exposure_manual()
def set_focus_auto(self):
ctrl = dai.CameraControl()
try:
ctrl.setAutoFocusMode(
dai.CameraControl.AutoFocusMode.CONTINUOUS_VIDEO
)
ctrl.setAutoFocusTrigger()
except Exception as exc:
print(f"[WARN] Autofocus nao disponivel: {exc}")
return
self.queue.send(ctrl)
self.focus_auto = True
print("[CAM] Foco: AUTO CONTINUOUS")
def set_focus_manual(self):
self.focus = int(
np.clip(
self.focus,
FOCUS_MIN,
FOCUS_MAX,
)
)
ctrl = dai.CameraControl()
try:
ctrl.setManualFocus(self.focus)
except Exception as exc:
print(f"[WARN] Foco manual nao disponivel: {exc}")
return
self.queue.send(ctrl)
self.focus_auto = False
print(f"[CAM] Foco: MANUAL | lens={self.focus}")
def toggle_focus(self):
if self.focus_auto:
self.set_focus_manual()
else:
self.set_focus_auto()
def adjust_focus(self, delta: int):
if self.focus_auto:
print("[CAM] Ajuste de foco ignorado: pressione F para modo MANUAL.")
return
self.focus += int(delta)
self.set_focus_manual()
def create_pipeline(fps: float) -> dai.Pipeline:
pipeline = dai.Pipeline()
cam_rgb = pipeline.createColorCamera()
cam_rgb.setBoardSocket(dai.CameraBoardSocket.CAM_A)
cam_rgb.setResolution(
dai.ColorCameraProperties.SensorResolution.THE_1080_P
)
cam_rgb.setFps(float(fps))
cam_rgb.setInterleaved(False)
# Mesmo contrato do runtime atual.
cam_rgb.setColorOrder(
dai.ColorCameraProperties.ColorOrder.RGB
)
xout = pipeline.createXLinkOut()
xout.setStreamName("rgb")
# IMPORTANTE: VIDEO, nao preview.
cam_rgb.video.link(xout.input)
control_in = pipeline.createXLinkIn()
control_in.setStreamName("control")
control_in.out.link(cam_rgb.inputControl)
return pipeline
def build_canvas(
frame_bgr: np.ndarray,
last_saved: Optional[np.ndarray],
*,
controls: CameraControls,
auto_save: bool,
interval_s: float,
saved_count: int,
out_dir: Path,
fps_view: float,
quality: dict,
) -> np.ndarray:
canvas_w = 1600
canvas_h = 900
canvas = np.zeros(
(canvas_h, canvas_w, 3),
dtype=np.uint8,
)
live = fit_inside(frame_bgr, 1150, 780)
lh, lw = live.shape[:2]
live_x = 20
live_y = 60
canvas[
live_y:live_y + lh,
live_x:live_x + lw,
] = live
panel_x = 1200
put_line(
canvas,
"OAK-D LITE / CORREDOR",
(panel_x, 60),
scale=0.78,
thickness=2,
)
put_line(
canvas,
"Fonte: VIDEO 1920x1080",
(panel_x, 100),
)
put_line(
canvas,
f"View FPS: {fps_view:.1f}",
(panel_x, 130),
)
put_line(
canvas,
f"Salvas: {saved_count}",
(panel_x, 160),
)
exp_text = (
"AUTO"
if controls.exposure_auto
else f"MANUAL {controls.exposure_us}us ISO{controls.iso}"
)
focus_text = (
"AUTO"
if controls.focus_auto
else f"MANUAL {controls.focus}"
)
put_line(
canvas,
f"Exposure: {exp_text}",
(panel_x, 205),
)
put_line(
canvas,
f"Focus: {focus_text}",
(panel_x, 235),
)
auto_text = (
f"ON ({interval_s:.2f}s)"
if auto_save
else "OFF"
)
put_line(
canvas,
f"Auto-save: {auto_text}",
(panel_x, 265),
)
put_line(
canvas,
f"Luma mean: {quality['mean']:.1f}",
(panel_x, 315),
)
put_line(
canvas,
f"Dark clip: {quality['dark_pct']:.1f}%",
(panel_x, 345),
)
put_line(
canvas,
f"White clip: {quality['bright_pct']:.1f}%",
(panel_x, 375),
)
put_line(
canvas,
f"Sharpness: {quality['sharpness']:.1f}",
(panel_x, 405),
)
if quality["warnings"]:
put_line(
canvas,
"QA: " + " | ".join(quality["warnings"]),
(panel_x, 440),
scale=0.55,
thickness=2,
color=(0, 180, 255),
)
else:
put_line(
canvas,
"QA: OK",
(panel_x, 440),
scale=0.62,
thickness=2,
color=(80, 230, 80),
)
put_line(canvas, "SPACE/S salvar", (panel_x, 510))
put_line(canvas, "A auto-save", (panel_x, 540))
put_line(canvas, "E auto/manual exp", (panel_x, 570))
put_line(canvas, "+/- ISO manual", (panel_x, 600))
put_line(canvas, "M/N exposicao manual", (panel_x, 630))
put_line(canvas, "F auto/manual foco", (panel_x, 660))
put_line(canvas, "[/] foco manual", (panel_x, 690))
put_line(canvas, "V fullscreen", (panel_x, 720))
put_line(canvas, "Q/ESC sair", (panel_x, 750))
if last_saved is not None:
thumb = fit_inside(last_saved, 360, 100)
th, tw = thumb.shape[:2]
tx = panel_x
ty = 780
if ty + th <= canvas_h and tx + tw <= canvas_w:
canvas[
ty:ty + th,
tx:tx + tw,
] = thumb
put_line(
canvas,
f"Saida: {out_dir}",
(20, 30),
scale=0.62,
color=(200, 220, 255),
)
return canvas
def main():
parser = argparse.ArgumentParser(
description=(
"Captura PNG lossless da OAK-D Lite para dataset "
"de corredor agricola."
)
)
parser.add_argument(
"--out",
default=str(DEFAULT_OUT),
help="Fila de imagens brutas (somente PNGs).",
)
parser.add_argument(
"--fps",
type=float,
default=CAPTURE_FPS,
help="FPS da camera.",
)
parser.add_argument(
"--auto",
action="store_true",
help="Inicia com auto-save ligado.",
)
parser.add_argument(
"--interval",
type=float,
default=1.0,
help="Intervalo do auto-save em segundos.",
)
parser.add_argument(
"--exposure_us",
type=int,
default=DEFAULT_EXPOSURE_US,
help="Exposicao inicial quando entrar em modo manual.",
)
parser.add_argument(
"--iso",
type=int,
default=DEFAULT_ISO,
help="ISO inicial quando entrar em modo manual.",
)
parser.add_argument(
"--focus",
type=int,
default=DEFAULT_FOCUS,
help="Lens position inicial quando entrar em foco manual.",
)
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
saved_count = len(list(out_dir.glob("*.png")))
print("=" * 72)
print("OAK-D Lite | Captura Dataset Corredor Agricola")
print("=" * 72)
print(f"Saida : {out_dir.resolve()}")
print("Formato : PNG lossless")
print(f"Resolucao : {CAPTURE_W}x{CAPTURE_H}")
print("Fonte : ColorCamera.video")
print(f"FPS camera : {args.fps}")
print(f"Ja existem : {saved_count} PNGs")
print(f"Sessao : {CAPTURE_SESSION_ID}")
print("=" * 72)
print("SPACE/S salvar | A auto-save | E exposure | F foco | Q sair")
pipeline = create_pipeline(args.fps)
saver = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="png_saver",
)
pending = []
last_saved: Optional[np.ndarray] = None
auto_save = bool(args.auto)
interval_s = max(0.10, float(args.interval))
last_auto_save_t = 0.0
fps_view = 0.0
fps_frames = 0
fps_t0 = time.perf_counter()
fullscreen = False
try:
with dai.Device(pipeline) as device:
control_queue = device.getInputQueue("control")
rgb_queue = device.getOutputQueue(
"rgb",
maxSize=2,
blocking=False,
)
controls = CameraControls(
control_queue,
exposure_us=args.exposure_us,
iso=args.iso,
focus=args.focus,
)
controls.send_initial_auto()
cv2.namedWindow(
WINDOW_NAME,
cv2.WINDOW_NORMAL,
)
cv2.resizeWindow(
WINDOW_NAME,
1600,
900,
)
while True:
packet = rgb_queue.get()
frame = packet.getCvFrame()
if (
frame is None
or frame.ndim != 3
or frame.shape[2] != 3
):
print(
f"[WARN] Frame invalido: "
f"{getattr(frame, 'shape', None)}"
)
continue
h, w = frame.shape[:2]
if (w, h) != (CAPTURE_W, CAPTURE_H):
print(
f"[WARN] Frame veio {w}x{h}, "
f"esperado {CAPTURE_W}x{CAPTURE_H}. "
"Nao sera redimensionado silenciosamente."
)
now_perf = time.perf_counter()
fps_frames += 1
dt_fps = now_perf - fps_t0
if dt_fps >= 1.0:
fps_view = fps_frames / dt_fps
fps_frames = 0
fps_t0 = now_perf
quality = live_quality(frame)
def request_save(current_frame: np.ndarray):
nonlocal saved_count, last_saved
name = timestamp_name() + ".png"
path = out_dir / name
snapshot = current_frame.copy()
future = saver.submit(
save_png,
path,
snapshot,
)
pending.append((future, path))
last_saved = snapshot
saved_count += 1
print(
f"[CAPTURE] #{saved_count} -> {path}"
)
if auto_save:
if (
last_auto_save_t <= 0.0
or now_perf - last_auto_save_t >= interval_s
):
request_save(frame)
last_auto_save_t = now_perf
still_pending = []
for future, path in pending:
if not future.done():
still_pending.append((future, path))
continue
try:
ok, saved_path = future.result()
except Exception as exc:
print(
f"[ERRO] Falha salvando {path}: {exc}"
)
continue
if not ok:
print(
f"[ERRO] cv2.imwrite retornou False: "
f"{saved_path}"
)
pending = still_pending
canvas = build_canvas(
frame,
last_saved,
controls=controls,
auto_save=auto_save,
interval_s=interval_s,
saved_count=saved_count,
out_dir=out_dir,
fps_view=fps_view,
quality=quality,
)
cv2.imshow(
WINDOW_NAME,
canvas,
)
key = cv2.waitKey(1) & 0xFF
if key in (ord("q"), 27):
print("[INFO] Encerrando captura...")
break
if key in (ord("s"), ord(" ")):
request_save(frame)
elif key == ord("a"):
auto_save = not auto_save
last_auto_save_t = 0.0
print(
f"[CAPTURE] Auto-save "
f"{'ON' if auto_save else 'OFF'} "
f"| interval={interval_s:.2f}s"
)
elif key == ord("e"):
controls.toggle_exposure()
elif key in (ord("+"), ord("=")):
controls.adjust_iso(ISO_STEP)
elif key == ord("-"):
controls.adjust_iso(-ISO_STEP)
elif key == ord("m"):
controls.adjust_exposure(EXPOSURE_STEP_US)
elif key == ord("n"):
controls.adjust_exposure(-EXPOSURE_STEP_US)
elif key == ord("f"):
controls.toggle_focus()
elif key == ord("]"):
controls.adjust_focus(FOCUS_STEP)
elif key == ord("["):
controls.adjust_focus(-FOCUS_STEP)
elif key == ord("v"):
fullscreen = not fullscreen
cv2.setWindowProperty(
WINDOW_NAME,
cv2.WND_PROP_FULLSCREEN,
(
cv2.WINDOW_FULLSCREEN
if fullscreen
else cv2.WINDOW_NORMAL
),
)
finally:
cv2.destroyAllWindows()
print(
f"[INFO] Aguardando {len(pending)} PNG(s) "
"que ja foram solicitados..."
)
saver.shutdown(wait=True)
failures = 0
for future, path in pending:
try:
ok, _ = future.result()
if not ok:
failures += 1
except Exception as exc:
failures += 1
print(f"[ERRO] {path}: {exc}")
print("=" * 72)
print("Captura encerrada.")
print(f"Total contabilizado : {saved_count}")
print(f"Falhas finais : {failures}")
print(f"Saida : {out_dir.resolve()}")
print("=" * 72)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,185 @@
{
"camera": "oak-d",
"modelo": "segformer_b0",
"model_name": "corredores_v2",
"main_class_name": "navegavel",
"model_to_use": "geral",
"ckpt_test": "best_operational",
"raw_size": [1920, 1080],
"resolucao": [1024, 576],
"roi_inicio": 0.0,
"roi_tamanho": 1.0,
"channels": 3,
"backbone": "nvidia/mit-b0",
"label_classes": [
"Parado",
"EntrandoRua",
"CaminhandoRua",
"SaindoRua",
"Manobrando",
"Direcionando",
"RetornandoBase",
"Indefinido"
],
"corridor_training": {
"data": {
"strict_labels": true,
"strict_pipeline_contract": true,
"preflight_max_samples": 0
},
"augmentation": {
"enabled": true,
"horizontal_flip_p": 0.50,
"affine_p": 0.60,
"rotate_deg": 4.0,
"scale_min": 0.92,
"scale_max": 1.08,
"translate_frac": 0.03,
"global_gain_p": 0.45,
"global_gain_min": 0.82,
"global_gain_max": 1.18,
"contrast_p": 0.35,
"contrast_min": 0.85,
"contrast_max": 1.15,
"gamma_p": 0.30,
"gamma_min": 0.82,
"gamma_max": 1.18,
"rgb_gain_p": 0.25,
"rgb_gain_min": 0.94,
"rgb_gain_max": 1.06,
"shadow_p": 0.35,
"shadow_strength_min": 0.12,
"shadow_strength_max": 0.42,
"noise_p": 0.18,
"noise_sigma_min": 0.003,
"noise_sigma_max": 0.018,
"blur_p": 0.12,
"blur_kernel": 3,
"occlusion_p": 0.00,
"occlusion_min_frac": 0.04,
"occlusion_max_frac": 0.15,
"ramp_epochs": 6
},
"sampler": {
"mode": "joint",
"joint_power": 0.30,
"status_power": 0.12,
"group_power": 0.10,
"max_weight_ratio": 5.0,
"samples_per_epoch": 0
},
"class_weighting": {
"seg_method": "log_inverse",
"seg_log_offset": 1.02,
"seg_min_weight": 0.35,
"seg_max_weight": 3.0,
"seg_max_samples": 1200,
"status_enabled": true,
"status_power": 0.30,
"status_min_weight": 0.50,
"status_max_weight": 3.0
},
"loss": {
"seg_ce_weight": 0.65,
"seg_dice_weight": 0.35,
"boundary_boost": 0.15,
"unsafe_nav_weight": 0.06,
"status_weight": 0.40,
"status_ramp_epochs": 8,
"status_label_smoothing": 0.03
},
"status_head": {
"pool_h": 3,
"pool_w": 4,
"hidden": 256,
"dropout": 0.20,
"detach_seg_summary": true,
"seg_summary": "probabilities"
},
"optimizer": {
"encoder_lr_mult": 0.50,
"decoder_lr_mult": 1.00,
"status_lr_mult": 2.00,
"freeze_encoder_epochs": 2,
"no_decay_bias": true,
"no_decay_norm": true,
"betas": [0.9, 0.999],
"eps": 1e-8
},
"scheduler": {
"mode": "poly",
"warmup_ratio": 0.05,
"warmup_start_factor": 0.10,
"poly_power": 1.0,
"min_lr_ratio": 0.02
},
"optimization": {
"grad_clip_norm": 1.0,
"matmul_precision": "high",
"cudnn_benchmark": true,
"persistent_workers": true,
"prefetch_factor": 2
},
"score": {
"nav_iou": 0.40,
"nav_f1": 0.20,
"status_macro_f1": 0.20,
"safety": 0.20
},
"operational_gate": {
"enabled": true,
"min_epoch": 5,
"min_nav_iou": 0.70,
"min_nav_f1": 0.80,
"min_non_nav_iou": 0.60,
"min_status_macro_f1": 0.60,
"max_unsafe_nav_rate": 0.03,
"group_unsafe": {
"enabled": true,
"min_non_nav_pixels": 5000,
"max_rate": 0.05,
"max_rate_by_group": {
"naonavegavel": 0.04,
"navegavel_naonavegavel": 0.05
}
},
"status_recall": {
"enabled": false,
"min_support": 5,
"minimums": {}
}
},
"checkpoint": {
"early_stop_patience": 18,
"early_stop_min_delta": 0.0003
}
}
}

View File

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
from PIL import Image
import glob
import os
import cv2
import numpy as np
import torch
from torch.utils.data import Dataset
from utils import carregar_labelmap_completo, compute_roi_indices, resize_keep_width
IMG_EXTS = (".jpg", ".jpeg", ".png")
MSK_EXTS = (".png", ".jpg", ".jpeg") # preferimos .png se existir
def _is_dir(p): return os.path.isdir(p)
def _is_file(p): return os.path.isfile(p)
def _list_groups(group_root):
if not _is_dir(group_root): return []
out = []
for g in sorted(os.listdir(group_root)):
gdir = os.path.join(group_root, g)
if not _is_dir(gdir):
continue
if _is_dir(os.path.join(gdir, "images")) and _is_dir(os.path.join(gdir, "masks")):
out.append(g)
return out
def _mask_for_base(msk_dir, base):
"""Encontra a máscara que casa com o base, priorizando .png."""
best = None
for ext in MSK_EXTS:
cand = os.path.join(msk_dir, base + ext)
if _is_file(cand):
if best is None: best = cand
# mantém .png se aparecer depois
if os.path.splitext(cand)[1].lower() == ".png":
return cand
return best
def _collect_pairs_legacy(root):
"""root/{images,masks}"""
img_dir = os.path.join(root, "images")
msk_dir = os.path.join(root, "masks")
imgs = []
msks = []
for p in sorted(glob.glob(os.path.join(img_dir, "*"))):
base, ext = os.path.splitext(os.path.basename(p))
if ext.lower() not in IMG_EXTS:
continue
m = _mask_for_base(msk_dir, base)
if m:
imgs.append(p)
msks.append(m)
return imgs, msks
def _collect_pairs_grouped(root):
"""Suporta:
- root/group/<g>/{images,masks}
- root/<g>/{images,masks} (quando root já é '.../group')
"""
# case A: root tem subpasta 'group'
group_root = os.path.join(root, "group")
if not _is_dir(group_root):
# case B: root JÁ É a pasta 'group'
group_root = root
groups = _list_groups(group_root)
imgs, msks = [], []
for g in groups:
img_dir = os.path.join(group_root, g, "images")
msk_dir = os.path.join(group_root, g, "masks")
for p in sorted(glob.glob(os.path.join(img_dir, "*"))):
base, ext = os.path.splitext(os.path.basename(p))
if ext.lower() not in IMG_EXTS:
continue
m = _mask_for_base(msk_dir, base)
if m:
imgs.append(p)
msks.append(m)
return imgs, msks
class ROISegDataset(Dataset):
"""
Compatível com o dataset original, mas agora aceita:
- root = '.../split/train' (com 'group' dentro)
- root = '.../split/train/group'
- root = '.../split/train/<grupo>' (ainda funciona via legado se tiver images/masks)
- root legado = '.../split/train' com 'images' e 'masks' diretamente
"""
def __init__(self, root, out_dir, zona_inicio, faixa_atuacao, input_w=384, min_input_h=96, labelmap_path="labelmap.txt",
mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)):
self.out_dir = out_dir
self.zona_inicio = zona_inicio
self.faixa_atuacao = faixa_atuacao
self.input_w = input_w
self.min_input_h = min_input_h
self.mean = np.array(mean, dtype=np.float32).reshape(1, 1, 3)
self.std = np.array(std, dtype=np.float32).reshape(1, 1, 3)
# tenta agrupar; se não encontrar, cai pro legado
imgs, msks = _collect_pairs_grouped(root)
if not imgs:
imgs, msks = _collect_pairs_legacy(root)
assert len(imgs) == len(msks) and len(imgs) > 0, f"Nenhuma imagem/máscara encontrada em {root}"
self.img_paths = imgs
self.msk_paths = msks
# labelmap
_, _, self.classes, self.ignore_rgb = carregar_labelmap_completo(labelmap_path)
# tipicamente ignore_rgb é (255,255,255)
self.ignore_id = int(self.ignore_rgb[0]) if isinstance(self.ignore_rgb, (list, tuple)) else int(self.ignore_rgb)
def __len__(self):
return len(self.img_paths)
def __getitem__(self, idx):
img_rgb = np.array(Image.open(self.img_paths[idx]).convert("RGB"))
# Máscara como escala de cinza (IDs já foram normalizados na etapa de normalize)
msk_grayscale = np.array(Image.open(self.msk_paths[idx]).convert("L"))
H, W = img_rgb.shape[:2]
y_fim, y_inicio = compute_roi_indices(H, self.zona_inicio, self.faixa_atuacao)
img_roi = img_rgb[y_fim:y_inicio, 0:W]
msk_roi = msk_grayscale[y_fim:y_inicio, 0:W]
img_in = resize_keep_width(img_roi, self.input_w, self.min_input_h, cv2.INTER_AREA)
msk_ids = resize_keep_width(msk_roi, self.input_w, self.min_input_h, cv2.INTER_NEAREST)
valores_validos = list(range(len(self.classes))) + [255]
msk_ids[np.isin(msk_ids, valores_validos, invert=True)] = self.ignore_id
if np.all(msk_ids == self.ignore_id):
raise ValueError(f"Máscara {self.msk_paths[idx]} está só com valor de ignore ({self.ignore_id})")
img_f = img_in.astype(np.float32) / 255.0
img_f = (img_f - self.mean) / self.std
img_chw = np.transpose(img_f, (2, 0, 1))
if idx == 0:
os.makedirs(self.out_dir, exist_ok=True)
cv2.imwrite(os.path.join(self.out_dir, "debug_roi_input.png"), cv2.cvtColor(img_roi, cv2.COLOR_RGB2BGR))
cv2.imwrite(os.path.join(self.out_dir, "debug_roi_mask.png"), msk_roi)
return torch.from_numpy(img_chw).float(), torch.from_numpy(msk_ids.astype(np.int64))

View File

@ -0,0 +1,144 @@
import cv2
import numpy as np
# ----------------------------
# Helpers LABELMAP
# ----------------------------
def carregar_labelmap_completo(caminho):
cor_para_id = {}
id_para_nome = {}
cores_rgb = []
with open(caminho, 'r') as arquivo:
idx = 0
for linha in arquivo:
if linha.startswith("#") or not linha.strip():
continue
partes = linha.strip().split(':')
if len(partes) >= 2:
nome_classe, cor_rgb_str = partes[0], partes[1]
r, g, b = map(int, cor_rgb_str.split(','))
cor_rgb = (r, g, b)
if nome_classe.lower() == "ignore":
ignore_rgb = cor_rgb
continue # NÃO adiciona ignore no LUT de classes
cor_para_id[cor_rgb] = idx
cores_rgb.append(cor_rgb)
id_para_nome[idx] = nome_classe
idx += 1
#print(f"Mapa: {cor_para_id}")
#print(f"Colormap RGB: {cores_rgb}")
#print(f"Classes: {id_para_nome}")
#print(f"Ignore RGB: {ignore_rgb}")
return cor_para_id, cores_rgb, id_para_nome, ignore_rgb
def converter_mask_rgb_para_ids(img_rgb, mapa_rgb, ignore_id):
# Cria um mapa 256^3 para IDs (usa int32 para indexar)
lut = np.full((256**3,), ignore_id, dtype=np.uint8)
for cor, classe_id in mapa_rgb.items():
r, g, b = cor
lut[(r << 16) + (g << 8) + b] = classe_id
# Converte RGB para índice único
flat_idx = (img_rgb[:,:,0].astype(np.int32) << 16) + \
(img_rgb[:,:,1].astype(np.int32) << 8) + \
img_rgb[:,:,2].astype(np.int32)
# Aplica LUT vetorizada
return lut[flat_idx]
def converter_mask_ids_para_rgb(mask_ids: np.ndarray, colormap_rgb: list, ignore_id: int = 255) -> np.ndarray:
# Criar lookup table (256 cores possíveis)
lut = np.zeros((256, 3), dtype=np.uint8)
for i, color in enumerate(colormap_rgb):
lut[i] = color
lut[ignore_id] = (255, 255, 255)
# Aplicar LUT direto (vetorizado)
return lut[mask_ids]
def converter_mask_ids_para_bgr(mask_ids: np.ndarray, colormap_rgb: list, ignore_id: int = 255) -> np.ndarray:
"""
Converte máscara de IDs para imagem BGR (uint8),
pronta para uso com OpenCV.
"""
lut = np.zeros((256, 3), dtype=np.uint8)
for i, (r, g, b) in enumerate(colormap_rgb):
lut[i] = (b, g, r) # RGB -> BGR
lut[ignore_id] = (255, 255, 255) # branco em BGR = RGB
return lut[mask_ids]
def desenhar_legenda_vertical(colormap_rgb, classes, largura=200):
"""
Retorna uma imagem com a legenda das classes (cor + nome)
"""
nomes_classes = [classes[i] for i in range(len(classes))]
altura_por_classe = 30
altura_total = altura_por_classe * len(colormap_rgb)
legenda = np.ones((altura_total, largura, 3), dtype=np.uint8) * 255
for idx, (rgb, nome) in enumerate(zip(colormap_rgb, nomes_classes)):
y = idx * altura_por_classe
color = tuple(int(c) for c in rgb)
cv2.rectangle(legenda, (10, y + 5), (30, y + 25), color, -1)
cv2.putText(legenda, nome, (40, y + 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1, cv2.LINE_AA)
return legenda
def desenhar_legenda_horizontal(colormap_rgb, classes, altura=30, largura_por_classe=120):
"""
Retorna uma imagem com a legenda das classes (cor + nome), em uma única linha horizontal
"""
nomes_classes = [classes[i] for i in range(len(classes))]
largura_total = largura_por_classe * len(colormap_rgb)
legenda = np.ones((altura, largura_total, 3), dtype=np.uint8) * 255 # faixa branca
for idx, (rgb, nome) in enumerate(zip(colormap_rgb, nomes_classes)):
x = idx * largura_por_classe
color = tuple(int(c) for c in rgb)
# Retângulo colorido
cv2.rectangle(legenda, (x + 10, 5), (x + 30, 25), color, -1)
# Texto da classe
cv2.putText(legenda, nome, (x + 35, 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1, cv2.LINE_AA)
return legenda
def _infer_ignore_id(ignore_rgb, default_id=255):
import numpy as _np
if isinstance(ignore_rgb, (list, tuple)):
if len(ignore_rgb) == 1 and isinstance(ignore_rgb[0], (int, _np.integer)):
return int(ignore_rgb[0])
if len(ignore_rgb) == 3:
return default_id
if isinstance(ignore_rgb, (int, _np.integer)):
return int(ignore_rgb)
return default_id
# ----------------------------
# Helpers ROI
# ----------------------------
def compute_roi_indices(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(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 / 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)