novo core da camera multiespectral versao produto, saude do atuador nao penaliza autonomia quando nao precisa, saude do movimentacao avalia travamento da roda, saude do sensoriamento avalia comando e leitura dos servos, reles e sinaleiros, com persistencia temporal, regras taticas permite parar o carro ao detectar travamento das rodas, travamento das rodas aciona parada de emergencia, ajustado contrato do weed worker para a versao produto, adicionado offset de referenciamento direcional, ajustado calculo de erro angular no direcional entre comando e leitura com persistencia temporal, ajustado limites dos sensores, incluido parada de emergencia mediante a liberacao humana apos travamento das rodas

This commit is contained in:
Diego Freitas 2026-08-31 09:30:08 -03:00
parent 21decdbbe9
commit c9ad8d2aff
24 changed files with 11007 additions and 1988 deletions

View File

@ -36,6 +36,7 @@ namespace AgroBase.Forms.Direcional
this.lblTipoMovimento = new System.Windows.Forms.Label();
this.btnSalvarSentido = new System.Windows.Forms.Button();
this.gpbDF = new System.Windows.Forms.GroupBox();
this.chbDFUsar = new System.Windows.Forms.CheckBox();
this.label5 = new System.Windows.Forms.Label();
this.txtDFOffsetAngulo = new System.Windows.Forms.TextBox();
this.label2 = new System.Windows.Forms.Label();
@ -47,6 +48,7 @@ namespace AgroBase.Forms.Direcional
this.lblDFEsquerda = new System.Windows.Forms.Label();
this.lblDFDireita = new System.Windows.Forms.Label();
this.gpbEF = new System.Windows.Forms.GroupBox();
this.chbEFUsar = new System.Windows.Forms.CheckBox();
this.label6 = new System.Windows.Forms.Label();
this.txtEFOffsetAngulo = new System.Windows.Forms.TextBox();
this.label4 = new System.Windows.Forms.Label();
@ -58,6 +60,7 @@ namespace AgroBase.Forms.Direcional
this.lblEFEsquerda = new System.Windows.Forms.Label();
this.lblEFDireita = new System.Windows.Forms.Label();
this.gpbDT = new System.Windows.Forms.GroupBox();
this.chbDTUsar = new System.Windows.Forms.CheckBox();
this.label8 = new System.Windows.Forms.Label();
this.txtDTOffsetAngulo = new System.Windows.Forms.TextBox();
this.label3 = new System.Windows.Forms.Label();
@ -69,6 +72,7 @@ namespace AgroBase.Forms.Direcional
this.lblDTEsquerda = new System.Windows.Forms.Label();
this.lblDTDireita = new System.Windows.Forms.Label();
this.gpbET = new System.Windows.Forms.GroupBox();
this.chbETUsar = new System.Windows.Forms.CheckBox();
this.label7 = new System.Windows.Forms.Label();
this.txtETOffsetAngulo = new System.Windows.Forms.TextBox();
this.label1 = new System.Windows.Forms.Label();
@ -79,10 +83,14 @@ namespace AgroBase.Forms.Direcional
this.cmbETEsquerda = new System.Windows.Forms.ComboBox();
this.lblETEsquerda = new System.Windows.Forms.Label();
this.lblETDireita = new System.Windows.Forms.Label();
this.chbDFUsar = new System.Windows.Forms.CheckBox();
this.chbDTUsar = new System.Windows.Forms.CheckBox();
this.chbEFUsar = new System.Windows.Forms.CheckBox();
this.chbETUsar = new System.Windows.Forms.CheckBox();
this.txtDTOffsetRef = new System.Windows.Forms.TextBox();
this.label9 = new System.Windows.Forms.Label();
this.label10 = new System.Windows.Forms.Label();
this.txtETOffsetRef = new System.Windows.Forms.TextBox();
this.label11 = new System.Windows.Forms.Label();
this.txtEFOffsetRef = new System.Windows.Forms.TextBox();
this.label12 = new System.Windows.Forms.Label();
this.txtDFOffsetRef = new System.Windows.Forms.TextBox();
this.gpbSentido.SuspendLayout();
this.gpbDF.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.nudDFOffset)).BeginInit();
@ -109,7 +117,7 @@ namespace AgroBase.Forms.Direcional
this.gpbSentido.Margin = new System.Windows.Forms.Padding(2);
this.gpbSentido.Name = "gpbSentido";
this.gpbSentido.Padding = new System.Windows.Forms.Padding(2);
this.gpbSentido.Size = new System.Drawing.Size(376, 427);
this.gpbSentido.Size = new System.Drawing.Size(376, 463);
this.gpbSentido.TabIndex = 80;
this.gpbSentido.TabStop = false;
this.gpbSentido.Text = "Sentido de giro";
@ -117,7 +125,7 @@ namespace AgroBase.Forms.Direcional
// lblIP
//
this.lblIP.AutoSize = true;
this.lblIP.Location = new System.Drawing.Point(9, 378);
this.lblIP.Location = new System.Drawing.Point(9, 422);
this.lblIP.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.lblIP.Name = "lblIP";
this.lblIP.Size = new System.Drawing.Size(17, 13);
@ -126,7 +134,7 @@ namespace AgroBase.Forms.Direcional
//
// txtIP
//
this.txtIP.Location = new System.Drawing.Point(12, 395);
this.txtIP.Location = new System.Drawing.Point(12, 439);
this.txtIP.Margin = new System.Windows.Forms.Padding(2);
this.txtIP.Name = "txtIP";
this.txtIP.Size = new System.Drawing.Size(85, 20);
@ -154,7 +162,7 @@ namespace AgroBase.Forms.Direcional
//
// btnSalvarSentido
//
this.btnSalvarSentido.Location = new System.Drawing.Point(275, 391);
this.btnSalvarSentido.Location = new System.Drawing.Point(275, 435);
this.btnSalvarSentido.Margin = new System.Windows.Forms.Padding(2);
this.btnSalvarSentido.Name = "btnSalvarSentido";
this.btnSalvarSentido.Size = new System.Drawing.Size(88, 24);
@ -165,6 +173,8 @@ namespace AgroBase.Forms.Direcional
//
// gpbDF
//
this.gpbDF.Controls.Add(this.label12);
this.gpbDF.Controls.Add(this.txtDFOffsetRef);
this.gpbDF.Controls.Add(this.chbDFUsar);
this.gpbDF.Controls.Add(this.label5);
this.gpbDF.Controls.Add(this.txtDFOffsetAngulo);
@ -180,11 +190,21 @@ namespace AgroBase.Forms.Direcional
this.gpbDF.Margin = new System.Windows.Forms.Padding(2);
this.gpbDF.Name = "gpbDF";
this.gpbDF.Padding = new System.Windows.Forms.Padding(2);
this.gpbDF.Size = new System.Drawing.Size(168, 152);
this.gpbDF.Size = new System.Drawing.Size(168, 175);
this.gpbDF.TabIndex = 9;
this.gpbDF.TabStop = false;
this.gpbDF.Text = "M4 - Direito Frente (DF)";
//
// chbDFUsar
//
this.chbDFUsar.AutoSize = true;
this.chbDFUsar.Location = new System.Drawing.Point(127, 124);
this.chbDFUsar.Name = "chbDFUsar";
this.chbDFUsar.Size = new System.Drawing.Size(32, 17);
this.chbDFUsar.TabIndex = 19;
this.chbDFUsar.Text = "L";
this.chbDFUsar.UseVisualStyleBackColor = true;
//
// label5
//
this.label5.AutoSize = true;
@ -294,6 +314,8 @@ namespace AgroBase.Forms.Direcional
//
// gpbEF
//
this.gpbEF.Controls.Add(this.label11);
this.gpbEF.Controls.Add(this.txtEFOffsetRef);
this.gpbEF.Controls.Add(this.chbEFUsar);
this.gpbEF.Controls.Add(this.label6);
this.gpbEF.Controls.Add(this.txtEFOffsetAngulo);
@ -309,11 +331,21 @@ namespace AgroBase.Forms.Direcional
this.gpbEF.Margin = new System.Windows.Forms.Padding(2);
this.gpbEF.Name = "gpbEF";
this.gpbEF.Padding = new System.Windows.Forms.Padding(2);
this.gpbEF.Size = new System.Drawing.Size(168, 152);
this.gpbEF.Size = new System.Drawing.Size(168, 175);
this.gpbEF.TabIndex = 9;
this.gpbEF.TabStop = false;
this.gpbEF.Text = "M2 - Esquerdo Frente (EF)";
//
// chbEFUsar
//
this.chbEFUsar.AutoSize = true;
this.chbEFUsar.Location = new System.Drawing.Point(127, 124);
this.chbEFUsar.Name = "chbEFUsar";
this.chbEFUsar.Size = new System.Drawing.Size(32, 17);
this.chbEFUsar.TabIndex = 21;
this.chbEFUsar.Text = "L";
this.chbEFUsar.UseVisualStyleBackColor = true;
//
// label6
//
this.label6.AutoSize = true;
@ -423,6 +455,8 @@ namespace AgroBase.Forms.Direcional
//
// gpbDT
//
this.gpbDT.Controls.Add(this.label9);
this.gpbDT.Controls.Add(this.txtDTOffsetRef);
this.gpbDT.Controls.Add(this.chbDTUsar);
this.gpbDT.Controls.Add(this.label8);
this.gpbDT.Controls.Add(this.txtDTOffsetAngulo);
@ -434,15 +468,25 @@ namespace AgroBase.Forms.Direcional
this.gpbDT.Controls.Add(this.cmbDTEsquerda);
this.gpbDT.Controls.Add(this.lblDTEsquerda);
this.gpbDT.Controls.Add(this.lblDTDireita);
this.gpbDT.Location = new System.Drawing.Point(195, 216);
this.gpbDT.Location = new System.Drawing.Point(195, 239);
this.gpbDT.Margin = new System.Windows.Forms.Padding(2);
this.gpbDT.Name = "gpbDT";
this.gpbDT.Padding = new System.Windows.Forms.Padding(2);
this.gpbDT.Size = new System.Drawing.Size(168, 152);
this.gpbDT.Size = new System.Drawing.Size(168, 175);
this.gpbDT.TabIndex = 9;
this.gpbDT.TabStop = false;
this.gpbDT.Text = "M3 - Direito Trás (DT)";
//
// chbDTUsar
//
this.chbDTUsar.AutoSize = true;
this.chbDTUsar.Location = new System.Drawing.Point(125, 124);
this.chbDTUsar.Name = "chbDTUsar";
this.chbDTUsar.Size = new System.Drawing.Size(32, 17);
this.chbDTUsar.TabIndex = 23;
this.chbDTUsar.Text = "L";
this.chbDTUsar.UseVisualStyleBackColor = true;
//
// label8
//
this.label8.AutoSize = true;
@ -552,6 +596,8 @@ namespace AgroBase.Forms.Direcional
//
// gpbET
//
this.gpbET.Controls.Add(this.label10);
this.gpbET.Controls.Add(this.txtETOffsetRef);
this.gpbET.Controls.Add(this.chbETUsar);
this.gpbET.Controls.Add(this.label7);
this.gpbET.Controls.Add(this.txtETOffsetAngulo);
@ -563,15 +609,25 @@ namespace AgroBase.Forms.Direcional
this.gpbET.Controls.Add(this.cmbETEsquerda);
this.gpbET.Controls.Add(this.lblETEsquerda);
this.gpbET.Controls.Add(this.lblETDireita);
this.gpbET.Location = new System.Drawing.Point(13, 216);
this.gpbET.Location = new System.Drawing.Point(13, 239);
this.gpbET.Margin = new System.Windows.Forms.Padding(2);
this.gpbET.Name = "gpbET";
this.gpbET.Padding = new System.Windows.Forms.Padding(2);
this.gpbET.Size = new System.Drawing.Size(168, 152);
this.gpbET.Size = new System.Drawing.Size(168, 175);
this.gpbET.TabIndex = 0;
this.gpbET.TabStop = false;
this.gpbET.Text = "M1 - Esquerdo Trás (ET)";
//
// chbETUsar
//
this.chbETUsar.AutoSize = true;
this.chbETUsar.Location = new System.Drawing.Point(127, 124);
this.chbETUsar.Name = "chbETUsar";
this.chbETUsar.Size = new System.Drawing.Size(32, 17);
this.chbETUsar.TabIndex = 23;
this.chbETUsar.Text = "L";
this.chbETUsar.UseVisualStyleBackColor = true;
//
// label7
//
this.label7.AutoSize = true;
@ -679,52 +735,88 @@ namespace AgroBase.Forms.Direcional
this.lblETDireita.TabIndex = 2;
this.lblETDireita.Text = "Direita";
//
// chbDFUsar
// txtDTOffsetRef
//
this.chbDFUsar.AutoSize = true;
this.chbDFUsar.Location = new System.Drawing.Point(127, 124);
this.chbDFUsar.Name = "chbDFUsar";
this.chbDFUsar.Size = new System.Drawing.Size(32, 17);
this.chbDFUsar.TabIndex = 19;
this.chbDFUsar.Text = "L";
this.chbDFUsar.UseVisualStyleBackColor = true;
this.txtDTOffsetRef.Location = new System.Drawing.Point(65, 146);
this.txtDTOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.txtDTOffsetRef.Name = "txtDTOffsetRef";
this.txtDTOffsetRef.Size = new System.Drawing.Size(57, 20);
this.txtDTOffsetRef.TabIndex = 24;
this.txtDTOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
//
// chbDTUsar
// label9
//
this.chbDTUsar.AutoSize = true;
this.chbDTUsar.Location = new System.Drawing.Point(125, 124);
this.chbDTUsar.Name = "chbDTUsar";
this.chbDTUsar.Size = new System.Drawing.Size(32, 17);
this.chbDTUsar.TabIndex = 23;
this.chbDTUsar.Text = "L";
this.chbDTUsar.UseVisualStyleBackColor = true;
this.label9.AutoSize = true;
this.label9.Location = new System.Drawing.Point(10, 149);
this.label9.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.label9.Name = "label9";
this.label9.Size = new System.Drawing.Size(55, 13);
this.label9.TabIndex = 25;
this.label9.Text = "Offset Ref";
//
// chbEFUsar
// label10
//
this.chbEFUsar.AutoSize = true;
this.chbEFUsar.Location = new System.Drawing.Point(127, 124);
this.chbEFUsar.Name = "chbEFUsar";
this.chbEFUsar.Size = new System.Drawing.Size(32, 17);
this.chbEFUsar.TabIndex = 21;
this.chbEFUsar.Text = "L";
this.chbEFUsar.UseVisualStyleBackColor = true;
this.label10.AutoSize = true;
this.label10.Location = new System.Drawing.Point(10, 149);
this.label10.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.label10.Name = "label10";
this.label10.Size = new System.Drawing.Size(55, 13);
this.label10.TabIndex = 27;
this.label10.Text = "Offset Ref";
//
// chbETUsar
// txtETOffsetRef
//
this.chbETUsar.AutoSize = true;
this.chbETUsar.Location = new System.Drawing.Point(127, 124);
this.chbETUsar.Name = "chbETUsar";
this.chbETUsar.Size = new System.Drawing.Size(32, 17);
this.chbETUsar.TabIndex = 23;
this.chbETUsar.Text = "L";
this.chbETUsar.UseVisualStyleBackColor = true;
this.txtETOffsetRef.Location = new System.Drawing.Point(65, 146);
this.txtETOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.txtETOffsetRef.Name = "txtETOffsetRef";
this.txtETOffsetRef.Size = new System.Drawing.Size(57, 20);
this.txtETOffsetRef.TabIndex = 26;
this.txtETOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
//
// label11
//
this.label11.AutoSize = true;
this.label11.Location = new System.Drawing.Point(11, 149);
this.label11.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.label11.Name = "label11";
this.label11.Size = new System.Drawing.Size(55, 13);
this.label11.TabIndex = 29;
this.label11.Text = "Offset Ref";
//
// txtEFOffsetRef
//
this.txtEFOffsetRef.Location = new System.Drawing.Point(66, 146);
this.txtEFOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.txtEFOffsetRef.Name = "txtEFOffsetRef";
this.txtEFOffsetRef.Size = new System.Drawing.Size(57, 20);
this.txtEFOffsetRef.TabIndex = 28;
this.txtEFOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
//
// label12
//
this.label12.AutoSize = true;
this.label12.Location = new System.Drawing.Point(10, 149);
this.label12.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.label12.Name = "label12";
this.label12.Size = new System.Drawing.Size(55, 13);
this.label12.TabIndex = 31;
this.label12.Text = "Offset Ref";
//
// txtDFOffsetRef
//
this.txtDFOffsetRef.Location = new System.Drawing.Point(65, 146);
this.txtDFOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.txtDFOffsetRef.Name = "txtDFOffsetRef";
this.txtDFOffsetRef.Size = new System.Drawing.Size(57, 20);
this.txtDFOffsetRef.TabIndex = 30;
this.txtDFOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
//
// frmDirConfig
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.BackColor = System.Drawing.Color.White;
this.ClientSize = new System.Drawing.Size(398, 447);
this.ClientSize = new System.Drawing.Size(398, 485);
this.Controls.Add(this.gpbSentido);
this.Margin = new System.Windows.Forms.Padding(2);
this.Name = "frmDirConfig";
@ -805,5 +897,13 @@ namespace AgroBase.Forms.Direcional
private System.Windows.Forms.CheckBox chbEFUsar;
private System.Windows.Forms.CheckBox chbDTUsar;
private System.Windows.Forms.CheckBox chbETUsar;
private System.Windows.Forms.Label label12;
private System.Windows.Forms.TextBox txtDFOffsetRef;
private System.Windows.Forms.Label label11;
private System.Windows.Forms.TextBox txtEFOffsetRef;
private System.Windows.Forms.Label label9;
private System.Windows.Forms.TextBox txtDTOffsetRef;
private System.Windows.Forms.Label label10;
private System.Windows.Forms.TextBox txtETOffsetRef;
}
}

View File

@ -72,6 +72,9 @@ namespace AgroBase.Forms.Direcional
TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo");
txtOffsetAngulo.Text = Modulo.DirMotor.OffsetAnguloReal.ToString();
TextBox txtOffsetRef = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetRef");
txtOffsetRef.Text = Modulo.DirMotor.OffsetReferenciamento.ToString();
CheckBox chbUsar = FuncoesGlobais.FindControlRecursive<CheckBox>(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar");
chbUsar.Checked = Modulo.DirMotor.UsarAnguloSensor;
}
@ -119,6 +122,9 @@ namespace AgroBase.Forms.Direcional
TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo");
Modulo.DirMotor.OffsetAnguloReal = Convert.ToDouble(txtOffsetAngulo.Text);
TextBox txtOffsetRef = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetRef");
Modulo.DirMotor.OffsetReferenciamento = Convert.ToDouble(txtOffsetRef.Text);
CheckBox chbUsar = FuncoesGlobais.FindControlRecursive<CheckBox>(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar");
Modulo.DirMotor.UsarAnguloSensor = chbUsar.Checked;
}

View File

@ -339,13 +339,28 @@ namespace AgroBase.Models.Modules
{
var op = Variaveis.OperacaoEmAndamento;
if (op.CalibrandoRuntime)
if (op == null)
return;
long tickSolicitado = Stopwatch.GetTimestamp();
lock (_metricasLock)
{
long agoraTicks = Stopwatch.GetTimestamp();
DateTime agora = DateTime.Now;
double esperaLockSeg = (agoraTicks - tickSolicitado) / (double)Stopwatch.Frequency;
double tempoMovimentoAtualSeg = op.Sensoriamento?.Movimentacao?.TempoMovimentoSegs ?? 0.0;
if (op.CalibrandoRuntime)
{
/*
* Durante a calibragem não integra, mas mantém
* o relógio ancorado no instante atual.
*/
ReancorarMetricas(agoraTicks, tempoMovimentoAtualSeg, encerrarTrechos: true);
return;
}
bool operacaoConcluida = op.Sensoriamento?.Operacao?.StatusOperacaoAtual == StatusOperacao.Concluido;
bool operacaoAtiva = op.Sensoriamento?.Operacao?.OperacaoIniciada == true && !operacaoConcluida;
@ -411,10 +426,11 @@ namespace AgroBase.Models.Modules
: new HashSet<int>();
double vazaoAtualMLs = operacaoAtiva ? ObterVazaoAtualConfiavelMLs(agora) : 0.0;
double tempoMovimentoAtualSeg = op.Sensoriamento?.Movimentacao?.TempoMovimentoSegs ?? 0.0;
if (_ultimoTickMetricas != 0)
{
bool haviaAtuacaoNoIntervaloAnterior = _bicosAtivosIntervaloAnterior.Count > 0 || _bombasAtivasIntervaloAnterior.Count > 0 || _vazaoIntervaloAnteriorMLs > 0.01;
double dt = (agoraTicks - _ultimoTickMetricas) / (double)Stopwatch.Frequency;
if (dt > 0 && dt <= IntervaloMaximoIntegracaoSeg)
@ -443,8 +459,26 @@ namespace AgroBase.Models.Modules
if (dt > IntervaloMaximoIntegracaoSeg)
{
Variaveis.MostrarLog("[ATU/METRICAS] Integração ignorada por dt alto: " + dt.ToString("0.000") + "s");
op.Sensoriamento.InserirLog(Dispositivo, StatusModulo.Alerta, 50, "[ATU/METRICAS] Integração ignorada por dt alto: " + dt.ToString("0.000") + "s");
string mensagem =
"[ATU/METRICAS] Integração ignorada por dt alto: " +
$"{dt:0.000}s; " +
$"opAtiva={operacaoAtiva}; " +
$"atuacaoAnterior={haviaAtuacaoNoIntervaloAnterior}; " +
$"bicosAnterior={_bicosAtivosIntervaloAnterior.Count}; " +
$"bombasAnterior={_bombasAtivasIntervaloAnterior.Count}; " +
$"vazaoAnterior={_vazaoIntervaloAnteriorMLs:0.00}mL/s; " +
$"esperaLock={esperaLockSeg * 1000.0:0.0}ms";
Variaveis.MostrarLog(mensagem);
/*
* Só vira evento operacional se efetivamente
* perdemos um intervalo de pulverização.
*/
if (operacaoAtiva && haviaAtuacaoNoIntervaloAnterior)
{
op.Sensoriamento?.InserirLog(Dispositivo, StatusModulo.Alerta, 80, mensagem);
}
}
}
}
@ -494,6 +528,24 @@ namespace AgroBase.Models.Modules
}
}
private void ReancorarMetricas(long agoraTicks, double tempoMovimentoAtualSeg, bool encerrarTrechos)
{
if (encerrarTrechos)
{
foreach (var bico in BicosPulverizadores ?? new List<AtuadorBicoModel>())
{
bico?.AtualizarEstadoTrecho(false);
}
}
_ultimoTickMetricas = agoraTicks;
_tempoMovimentoAnteriorSeg = tempoMovimentoAtualSeg;
_bicosAtivosIntervaloAnterior = new HashSet<int>();
_bombasAtivasIntervaloAnterior = new HashSet<int>();
_potenciaBombasIntervaloAnterior = new Dictionary<int, double>();
_vazaoIntervaloAnteriorMLs = 0.0;
ZerarVazoesInstantaneas();
}
private void IntegrarIntervaloAnterior(double dt, double tempoMovimentoIntervaloSeg)
{
var bicosDoIntervalo = (BicosPulverizadores ??

View File

@ -24,6 +24,7 @@ namespace AgroBase.Models.Modules
public Sentido Sentido_SP { get; set; } = Sentido.Parado;
public double Angulo_SP { get; set; } = 0;
public double FolgaMecanica { get; set; } = 0;
public double OffsetReferenciamento { get; set; } = 0;
public double Velocidade_Max { get; set; } = 600;
public int Velocidade
{
@ -149,11 +150,16 @@ namespace AgroBase.Models.Modules
return !Comandar || Sentido_SP == Sentido.Parado ? 0 : Angulo_SP;
}
}
public double ErroAnguloSP
public double ErroAngulo
{
get
{
return Math.Abs(AnguloLeitura - AnguloSPAtual);
double anguloSp =
!Comandar || Sentido_SP == Sentido.Parado
? 0
: Angulo_SP;
return anguloSp - AnguloLeitura;
}
}
public bool RetornandoAzero
@ -165,13 +171,7 @@ namespace AgroBase.Models.Modules
return foraZero && parar;
}
}
public bool EmMovimento
{
get
{
return Math.Abs(RPM) > 1;
}
}
public bool EmMovimento { get; set; }
public bool AtingiuAnguloSP
{
get
@ -207,6 +207,109 @@ namespace AgroBase.Models.Modules
}
private DateTime _erroAngularDesde = DateTime.MinValue;
private DateTime _recuperacaoAngularDesde = DateTime.MinValue;
public DateTime UltimaMudancaAnguloSP { get; private set; }
public DateTime InicioMovimentoAngular { get; private set; }
public bool ErroSeguimentoAngularPersistente { get; private set; }
public void AtualizarSaudeSeguimentoAngular(DateTime agora)
{
const double toleranciaFalhaGraus = 3.0;
const double toleranciaRecuperacaoGraus = 2.0;
TimeSpan tempoGracaSP = TimeSpan.FromMilliseconds(750);
TimeSpan persistenciaFalha = TimeSpan.FromSeconds(2);
TimeSpan persistenciaRecuperacao = TimeSpan.FromMilliseconds(500);
TimeSpan timeoutMovimento = TimeSpan.FromSeconds(15);
if (!Comandar)
{
ResetarSaudeSeguimentoAngular();
return;
}
bool emGracaSP =
UltimaMudancaAnguloSP != DateTime.MinValue &&
agora - UltimaMudancaAnguloSP <= tempoGracaSP;
bool movimentoDentroDoPrazo =
EmMovimento &&
InicioMovimentoAngular != DateTime.MinValue &&
agora - InicioMovimentoAngular <= timeoutMovimento;
bool movimentoExcedeuTimeout =
EmMovimento &&
InicioMovimentoAngular != DateTime.MinValue &&
agora - InicioMovimentoAngular > timeoutMovimento;
double ErroAnguloAbs = Math.Abs(ErroAngulo);
bool candidatoFalha =
ErroAnguloAbs > toleranciaFalhaGraus &&
!emGracaSP &&
(!EmMovimento || movimentoExcedeuTimeout);
if (candidatoFalha)
{
_recuperacaoAngularDesde = DateTime.MinValue;
if (_erroAngularDesde == DateTime.MinValue)
_erroAngularDesde = agora;
if (agora - _erroAngularDesde >= persistenciaFalha)
ErroSeguimentoAngularPersistente = true;
return;
}
_erroAngularDesde = DateTime.MinValue;
bool recuperado =
ErroAnguloAbs <= toleranciaRecuperacaoGraus ||
movimentoDentroDoPrazo ||
emGracaSP;
if (!recuperado)
{
_recuperacaoAngularDesde = DateTime.MinValue;
return;
}
if (_recuperacaoAngularDesde == DateTime.MinValue)
_recuperacaoAngularDesde = agora;
if (agora - _recuperacaoAngularDesde >= persistenciaRecuperacao)
ErroSeguimentoAngularPersistente = false;
}
private void ResetarSaudeSeguimentoAngular()
{
_erroAngularDesde = DateTime.MinValue;
_recuperacaoAngularDesde = DateTime.MinValue;
ErroSeguimentoAngularPersistente = false;
}
public void AtualizarEmMovimento(bool emMovimentoAgora)
{
DateTime agora = DateTime.Now;
if (emMovimentoAgora && !EmMovimento)
{
InicioMovimentoAngular = agora;
}
if (!emMovimentoAgora)
{
InicioMovimentoAngular = DateTime.MinValue;
}
EmMovimento = emMovimentoAgora;
}
public Dictionary<TipoMovimentoDirecional, ConfiguracaoSentidoMotor> ConfigSentidos { get; set; }
public List<FuncoesPinout> Funcoes { get; set; }
@ -313,38 +416,41 @@ namespace AgroBase.Models.Modules
var _Controle = op.Controle;
int AnguloTipoMovimento = VariaveisEquipamento.AnguloMovimento[_Controle.TipoMovimento];
Sentido novoSentido = Sentido.Parado;
double novoAngulo = 0;
if (!Comandar)
{
Sentido_SP = Sentido.Parado;
novoSentido = Sentido.Parado;
}
else if (Direcao == Direcao.Direita)
{
Sentido_SP = ConfigSentidos[_Controle.TipoMovimento].Direita;
novoSentido = ConfigSentidos[_Controle.TipoMovimento].Direita;
}
else if (Direcao == Direcao.Esquerda)
{
Sentido_SP = ConfigSentidos[_Controle.TipoMovimento].Esquerda;
novoSentido = ConfigSentidos[_Controle.TipoMovimento].Esquerda;
}
else
{
Sentido_SP = Sentido.Parado;
novoSentido = Sentido.Parado;
}
UltimaDirecao = Direcao;
int mx = Sentido_SP == Sentido.Parado ? 0 : Sentido_SP == Sentido.Antihorario ? -1 : 1;
int mx = novoSentido == Sentido.Parado ? 0 : novoSentido == Sentido.Antihorario ? -1 : 1;
if (Sentido_SP == Sentido.Parado)
if (novoSentido == Sentido.Parado)
{
Angulo_SP = 0;
novoAngulo = 0;
UltimaDirecao = Direcao.Parado;
}
else if (AnguloTipoMovimento != -1)
{
Angulo_SP = AnguloTipoMovimento;
novoAngulo = AnguloTipoMovimento;
}
else
{
Angulo_SP = _Controle.Angulo;
novoAngulo = _Controle.Angulo;
}
if (Comandar)
@ -353,8 +459,16 @@ namespace AgroBase.Models.Modules
//Console.WriteLine($"[{Mod_ID}] Angulo Atualizado para {Variaveis.OperacaoEmAndamento.SimulacaoAnguloControle}");
}
Angulo_SP = Math.Abs(Angulo_SP) * mx;
Angulo_SP += AnguloFolgaCompensar;
novoAngulo = Math.Abs(novoAngulo) * mx;
novoAngulo += AnguloFolgaCompensar;
if (Math.Abs(novoAngulo - Angulo_SP) >= 0.05)
{
UltimaMudancaAnguloSP = DateTime.Now;
}
Angulo_SP = novoAngulo;
Sentido_SP = novoSentido;
if (_Simulando)
{
@ -473,9 +587,7 @@ namespace AgroBase.Models.Modules
_sentido = _sentido == Sentido.Horario ? Sentido.Antihorario : Sentido.Horario;
Variaveis.MostrarLog($"[DirecionalModel] [ReferenciaMotorRelativo] [DIR {Mod_ID}] - Invertendo Sentido de Giro para {_sentido.ToString()}");
double offset = Mod_ID == "DT" ? -2 : 0;
_anguloSP = CalcularCentroCompensado(AnguloEntrada, AnguloSaida, _sentido, offset);
_anguloSP = CalcularCentroCompensado(AnguloEntrada, AnguloSaida, _sentido, OffsetReferenciamento);
}
verificacoesAngulo++;

View File

@ -494,6 +494,8 @@ namespace AgroBase.Models.Modules
modulo.DirMotor.EnviarComandoControle(controleDir.UltimaDirecao);
enviouControle = true;
}
modulo.DirMotor.AtualizarSaudeSeguimentoAngular(agora);
}
if (enviouControle)

View File

@ -625,9 +625,9 @@ namespace AgroBase.Models.Modules
posicao = CanMessagePosicaoDados.Dados1,
indice = 0,
analise_health = true,
maximo = 5.2,
minimo = 4.9,
nominal = 5.0,
maximo = 5.3,
minimo = 4.75,
nominal = 5.1,
unidade_medida = "V"
},
new SensorValoresLeituraModel()
@ -636,9 +636,9 @@ namespace AgroBase.Models.Modules
posicao = CanMessagePosicaoDados.Dados1,
indice = 1,
analise_health = true,
maximo = 1500,
maximo = 800,
minimo = 100,
nominal = 280,
nominal = 430,
unidade_medida = "mA"
},
new SensorValoresLeituraModel()

View File

@ -4094,6 +4094,21 @@ namespace AgroBase.Models
op.TempoIniciarOperacao = Math.Max(0, Convert.ToInt32(Math.Round(tempoAguardarInicio)));
JObject emergenciaSistema = RedisService.GetField<JObject>(CtxKey.DadosOperacao, "emergencia_sistema");
bool emergenciaSistemicaSolicitada = emergenciaSistema?["solicitada"]?.Value<bool>() ?? RedisService.GetField<bool>(CtxKey.DadosOperacao, "emergencia_sistema_solicitada", false);
bool emergenciaAnterior = Operacao.Emergencia;
bool emergencia = emergenciaAnterior || emergenciaSistemicaSolicitada;
if (emergenciaSistemicaSolicitada && !emergenciaAnterior)
{
string codigo = emergenciaSistema?["codigo"]?.Value<string>() ?? "INTERTRAVAMENTO_SISTEMA";
string motivo = emergenciaSistema?["motivo"]?.Value<string>() ?? "Intertravamento sistêmico solicitado";
int severidade = emergenciaSistema?["severidade"]?.Value<int>() ?? 90;
InserirLog(T_Code.Mov, StatusModulo.Falha, Math.Max(0, 100 - severidade), $"Emergência sistêmica acionada [{codigo}]: {motivo}");
}
var operacaoAtualizada = new OperacaoSensoriamentoLogModel()
{
Momento = agora,
@ -4102,7 +4117,7 @@ namespace AgroBase.Models
Status = op.Status,
StatusOperacaoAnterior = Operacao.StatusOperacaoAnterior,
OperacaoIniciada = Operacao.OperacaoIniciada,
Emergencia = Operacao.Emergencia,
Emergencia = emergencia,
Pausa = Operacao.Pausa,
Calibrando = Operacao.Calibrando,
Finalizando = Operacao.Finalizando,
@ -4889,7 +4904,7 @@ namespace AgroBase.Models
? (velocidade / velocidadeMax) * 100.0
: 0;
double erroAngulo = dir != null ? CalcularErroAngulo(dir) : 0;
double erroAngulo = dir?.ErroAngulo ?? 0;
return new DirecionalModuloLogModel
{
@ -4919,6 +4934,7 @@ namespace AgroBase.Models
ErroAngulo = Math.Round(erroAngulo, 2),
ErroAnguloAbs = Math.Round(Math.Abs(erroAngulo), 2),
ErroSeguimentoAngularPersistente = dir?.ErroSeguimentoAngularPersistente ?? false,
AtingiuAnguloSP = dir?.AtingiuAnguloSP ?? false,
RetornandoAzero = dir?.RetornandoAzero ?? false,
@ -4943,19 +4959,6 @@ namespace AgroBase.Models
};
}
double CalcularErroAngulo(DirecionalModel dir)
{
if (dir == null)
return 0;
double anguloSp =
!dir.Comandar || dir.Sentido_SP == Sentido.Parado
? 0
: dir.Angulo_SP;
return anguloSp - dir.AnguloLeitura;
}
DirecionalStatusLogModel CriarStatus((bool, List<string>) status)
{
return new DirecionalStatusLogModel
@ -5021,7 +5024,7 @@ namespace AgroBase.Models
model.AnguloMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.AnguloLeitura));
model.AnguloSPMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.Angulo_SP));
model.ErroAnguloMedioAbs = MediaOuZero(controlando, x => Math.Abs(CalcularErroAngulo(x.DirMotor)));
model.ErroAnguloMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.ErroAngulo));
model.Status =
dados != null && controle != null
@ -5999,11 +6002,9 @@ namespace AgroBase.Models
public DirecionalStatusLogModel Status { get; set; } = new DirecionalStatusLogModel();
public bool StatusGeralOk =>
Disponivel &&
Status.Ok &&
ModulosInicializados > 0 &&
!Modulos.Any(x => x.ErroAnguloAbs > 3.0 && x.Controlar);
public bool StatusEstruturalOk => Disponivel && Status.Ok && ModulosInicializados > 0;
public bool StatusGeralOk => StatusEstruturalOk && !Modulos.Any(x => x.Controlar && x.ErroSeguimentoAngularPersistente);
public List<DirecionalModuloLogModel> Modulos { get; set; } = new List<DirecionalModuloLogModel>();
@ -6078,6 +6079,7 @@ namespace AgroBase.Models
// Diagnóstico geométrico
public double ErroAngulo { get; set; }
public double ErroAnguloAbs { get; set; }
public bool ErroSeguimentoAngularPersistente { get; set; }
public bool AtingiuAnguloSP { get; set; }
public bool RetornandoAzero { get; set; }

View File

@ -1228,6 +1228,8 @@ namespace AgroBase.Services
short rpm = DecodeInt16BE(data, 1);
item.RPM.valor = rpm;
Variaveis.OperacaoEmAndamento?.DispMvd?.Dados?.Modulos?.FirstOrDefault(x => x.DirMotor._EnderecoCAN_Tx == item.EnderecoTx)?.DirMotor?.AtualizarEmMovimento(Math.Abs(rpm) > 1);
}
private void ProcessarPulsosRecebidos(MKS057DModel item, byte[] data)

View File

@ -1,4 +1,5 @@
using AgroBase.Models;
using AgroBase.Models.Modules;
using AgroBase.Models.Operadores;
using Newtonsoft.Json;
using System;
@ -788,15 +789,30 @@ namespace AgroBase.Services.Operadores
})
.ToList();
bool possuiComandoServo = servo.UltimoComandoEnviado != DateTime.MinValue;
DateTime ultimaRespostaAnguloServo =
servo.ValoresLeituras?
.Where(x =>x.funcao == FuncoesPinout.ServoAnguloLeitura)
.OrderByDescending(x =>x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
bool possuiRespostaServo = ultimaRespostaAnguloServo != DateTime.MinValue;
double idadeUltimoComandoServoMs =
servo.UltimoComandoEnviado == DateTime.MinValue
!possuiComandoServo
? 999999
: (agora_dt - servo.UltimoComandoEnviado).TotalMilliseconds;
: Math.Max(0, (agora_dt - servo.UltimoComandoEnviado).TotalMilliseconds);
double idadeUltimaRespostaServoMs =
servo.UltimaLeitura == DateTime.MinValue
!possuiRespostaServo
? 999999
: (agora_dt - servo.UltimaLeitura).TotalMilliseconds;
: Math.Max(0, (agora_dt - ultimaRespostaAnguloServo).TotalMilliseconds);
bool telemetriaPosComandoServo =
possuiComandoServo &&
possuiRespostaServo &&
ultimaRespostaAnguloServo >= servo.UltimoComandoEnviado;
var servoObj = new
{
@ -806,15 +822,21 @@ namespace AgroBase.Services.Operadores
tipo,
controlar = servo.Controlar,
mandatorio = servo.Mandatorio,
angulo_sp = servo._AnguloControle,
angulo_desejado = servo.UltimoAnguloDesejado,
angulo_leitura = servo._AnguloLeiutra,
possui_comando = possuiComandoServo,
telemetria_pos_comando = telemetriaPosComandoServo,
ultimo_comando_ms = idadeUltimoComandoServoMs,
ultima_resposta_ms = idadeUltimaRespostaServoMs,
dados,
timestamp = agora,
saude = saude ?? new { },
angulo_desejado = servo.UltimoAnguloDesejado,
angulo_leitura = servo._AnguloLeiutra,
ultimo_comando_ms = idadeUltimoComandoServoMs,
ultima_resposta_ms = idadeUltimaRespostaServoMs,
};
senServosAtivos.Add($"{tipo}.{id}");
@ -843,15 +865,30 @@ namespace AgroBase.Services.Operadores
})
.ToList();
bool possuiComandoRele = rele.UltimoComandoEnviado != DateTime.MinValue;
bool possuiRespostaRele = rele.UltimaLeitura != DateTime.MinValue;
double idadeUltimoComandoReleMs = !possuiComandoRele ? 999999 : Math.Max(0, (agora_dt - rele.UltimoComandoEnviado).TotalMilliseconds);
double idadeUltimaRespostaReleMs = !possuiRespostaRele ? 999999 : Math.Max(0, (agora_dt - rele.UltimaLeitura).TotalMilliseconds);
DateTime ultimaRespostaEstadoRele =
rele.ValoresLeituras?
.Where(x =>x.funcao == FuncoesPinout.EstadoLeitura)
.OrderByDescending(x =>x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
bool telemetriaPosComando =
bool possuiComandoRele = rele.UltimoComandoEnviado != DateTime.MinValue;
bool possuiRespostaRele = ultimaRespostaEstadoRele != DateTime.MinValue;
double idadeUltimoComandoReleMs =
!possuiComandoRele
? 999999
: Math.Max(0, (agora_dt - rele.UltimoComandoEnviado).TotalMilliseconds);
double idadeUltimaRespostaReleMs =
!possuiRespostaRele
? 999999
: Math.Max(0, (agora_dt - ultimaRespostaEstadoRele).TotalMilliseconds);
bool telemetriaPosComandoRele =
possuiComandoRele &&
possuiRespostaRele &&
rele.UltimaLeitura >= rele.UltimoComandoEnviado;
ultimaRespostaEstadoRele >= rele.UltimoComandoEnviado;
var releObj = new
{
@ -870,7 +907,7 @@ namespace AgroBase.Services.Operadores
estado_leitura = rele._EstadoLeitura,
possui_comando = possuiComandoRele,
telemetria_pos_comando = telemetriaPosComando,
telemetria_pos_comando = telemetriaPosComandoRele,
ultimo_comando_ms = idadeUltimoComandoReleMs,
ultima_resposta_ms = idadeUltimaRespostaReleMs,
@ -902,15 +939,50 @@ namespace AgroBase.Services.Operadores
})
.ToList();
DateTime ultimaRespostaLedId =
led.ValoresLeituras?
.Where(x =>x.funcao == FuncoesPinout.StatusLedID)
.OrderByDescending(x => x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
DateTime ultimaRespostaLedStatus =
led.ValoresLeituras?
.Where(x =>x.funcao == FuncoesPinout.StatusLedLeitura)
.OrderByDescending(x =>x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
bool possuiComandoLed = led.UltimoComandoEnviado != DateTime.MinValue;
bool possuiRespostaLed =
ultimaRespostaLedId != DateTime.MinValue &&
ultimaRespostaLedStatus != DateTime.MinValue;
/*
* Usa a mais antiga das duas respostas.
* Assim ultima_resposta_ms representa a idade do conjunto completo.
*/
DateTime ultimaRespostaLed =
possuiRespostaLed
? (ultimaRespostaLedId <= ultimaRespostaLedStatus ? ultimaRespostaLedId : ultimaRespostaLedStatus)
: DateTime.MinValue;
double idadeUltimoComandoLedMs =
led.UltimoComandoEnviado == DateTime.MinValue
!possuiComandoLed
? 999999
: (agora_dt - led.UltimoComandoEnviado).TotalMilliseconds;
: Math.Max(0, (agora_dt - led.UltimoComandoEnviado).TotalMilliseconds);
double idadeUltimaRespostaLedMs =
led.UltimaLeitura == DateTime.MinValue
!possuiRespostaLed
? 999999
: (agora_dt - led.UltimaLeitura).TotalMilliseconds;
: Math.Max(0, (agora_dt - ultimaRespostaLed).TotalMilliseconds);
bool telemetriaPosComandoLed =
possuiComandoLed &&
possuiRespostaLed &&
ultimaRespostaLedId >= led.UltimoComandoEnviado &&
ultimaRespostaLedStatus >= led.UltimoComandoEnviado;
int statusLedSp = led.UltimoComportamentoDesejado != null ? (int)led.UltimoComportamentoDesejado.statusLED : -1;
var ledObj = new
{
@ -920,16 +992,24 @@ namespace AgroBase.Services.Operadores
tipo,
controlar = led.Controlar,
mandatorio = led.Mandatorio,
status_led_id_sp = led.UltimoComportamentoDesejado?.id ?? -1,
status_led_sp = statusLedSp,
status_led_id = (int)led._StatusLedId,
status_led_leitura = (int)led._StatusLed,
possui_comando = possuiComandoLed,
telemetria_pos_comando = telemetriaPosComandoLed,
ultimo_comando_ms = idadeUltimoComandoLedMs,
ultima_resposta_ms = idadeUltimaRespostaLedMs,
dados,
timestamp = agora,
saude = saude ?? new { },
status_led_id_sp = led.UltimoComportamentoDesejado?.id ?? -1,
status_led_sp = led.UltimoComportamentoDesejado?.statusLED,
status_led_id = led._StatusLedId,
status_led_leitura = led._StatusLed,
ultimo_comando_ms = idadeUltimoComandoLedMs,
ultima_resposta_ms = idadeUltimaRespostaLedMs,
};
senLedsAtivos.Add($"{tipo}.{id}");

View File

@ -2,6 +2,7 @@ import json
import os
import time
import threading
import hashlib
from pathlib import Path
from datetime import datetime
@ -15,6 +16,9 @@ from camera_worker.oak_fcc3_core.oak_fcc3_client import OakFcc3Client
from camera_worker.camera_imu import IMUCamera
CAMERA_MULTISPECTRAL_VERSION = "production_v1_2026_08_24"
class CameraMultispectral:
"""
Adaptador do módulo OAK-FFC-3 multiespectral para o ecossistema do robô.
@ -28,6 +32,9 @@ class CameraMultispectral:
A classe NÃO expõe lógica radiométrica/fusão/flatfield para o worker.
Isso fica dentro do core OAK-FCC-3.
Em modo produto, hardware/resoluções/Bayer/target_size vêm exclusivamente
do module_params homologado e do hardware validado pelo Manager/Client.
"""
def __init__(
@ -41,6 +48,7 @@ class CameraMultispectral:
target_size=None,
cache_max_age_s=0.15,
timeout_s=1.0,
require_product_contract=False,
):
self.mostrar_log = mostrar_log
self.mx_id = str(mx_id) if mx_id else None
@ -61,11 +69,42 @@ class CameraMultispectral:
self.rodando = False
self.ultima_saude = {}
self.width = int(width)
self.height = int(height)
# width/height recebidos do caller são somente compatibilidade legado.
# Em produto, resolução nativa vem do module_params/hardware real.
self.legacy_width = int(width)
self.legacy_height = int(height)
self.requested_target_size = (
None
if target_size is None
else [int(target_size[0]), int(target_size[1])]
)
# Aliases históricos. São sobrescritos pelo contrato do Client e passam
# a significar exclusivamente a resolução RGB nativa de referência.
self.width = self.legacy_width
self.height = self.legacy_height
self.fps = int(fps)
self.target_size = target_size
self.target_size = self.requested_target_size
self.tensor_size = self.requested_target_size
self.timeout_s = float(timeout_s)
self.require_product_contract = bool(require_product_contract)
self.product_contract = False
self.client_contract = {}
self.sensor_size_by_role = {
"rgb": [self.legacy_width, self.legacy_height],
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
self.camera_hardware = {}
self.rgb_native_size = [self.legacy_width, self.legacy_height]
self.spectral_native_size = {
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
self.bayer_pattern = None
self.module_params_sha256 = None
self._cache_max_age_s = float(cache_max_age_s)
self._lock = threading.Lock()
@ -115,6 +154,9 @@ class CameraMultispectral:
module_calibration_json = self._resolver_module_params_default()
self.module_calibration_json = module_calibration_json
self.module_params_sha256 = self._sha256_file_safe(
self.module_calibration_json
)
ContextoGlobalRedis.atualizar_ctx_dict(
ContextoGlobalRedis.CamKey(self.mx_id),
@ -137,8 +179,10 @@ class CameraMultispectral:
try:
self.client = OakFcc3Client(
mx_id=self.mx_id,
width=self.width,
height=self.height,
# Fallbacks legado. O Client produto ignora estes valores como
# autoridade física e usa o module_params homologado.
width=self.legacy_width,
height=self.legacy_height,
bayer="BGGR",
fps=self.fps,
frame_type="RAW_BRUTO",
@ -146,15 +190,31 @@ class CameraMultispectral:
capture_mode="TRIPLE",
raw_policy="require_triple",
module_calibration_json=self.module_calibration_json,
sync_mode="best",
sync_tolerance_ms=25.0,
sync_mode="strict",
hardware_sync_enabled=False,
frame_sync_master="CAM_A",
sync_tolerance_ms=15.0,
imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz,
# Runtime de campo: não executa a auditoria estatística
# evaluate_frame_quality() em todo frame.
# O tensor continua passando por todo o processamento científico
# necessário (decode, radiometria, flat-field, fusão e resize).
evaluate_quality=False,
require_product_contract=self.require_product_contract,
)
# O contrato já está disponível antes do start porque o Client
# carrega/valida module_params no construtor.
self._adotar_contrato_client()
resp = self.client.start(print_debug=False)
# Reconfirma depois do Manager abrir e validar o hardware físico.
self._adotar_contrato_client()
# Se mx_id veio None, pega o ID real aberto pelo core antes de criar IMU.
try:
self.mx_id = str(getattr(self.client, "mx_id", None) or self.mx_id)
@ -235,6 +295,11 @@ class CameraMultispectral:
self.mostrar_log(f"[CameraMultispectral] Erro ao iniciar módulo: {e}")
# Produto é fail-closed: o CameraManager precisa receber o motivo
# real da falha, e não um objeto parcialmente inicializado.
if self.require_product_contract:
raise
ContextoGlobalRedis.atualizar_ctx_dict(
ContextoGlobalRedis.CamKey(self.mx_id),
mx_id=self.mx_id,
@ -267,40 +332,204 @@ class CameraMultispectral:
# Devolve o default mesmo se não existir, para o erro ficar claro no core.
return "calibration/module_params.json"
def _sha256_file_safe(self, path):
try:
if not path or not os.path.isfile(path):
return None
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
except Exception:
return None
@staticmethod
def _normalize_size(value, field_name):
if not isinstance(value, (list, tuple)) or len(value) != 2:
raise RuntimeError(f"{field_name} inválido: {value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{field_name} inválido: {value!r}")
return [w, h]
def _adotar_contrato_client(self):
if self.client is None:
raise RuntimeError("OakFcc3Client não inicializado")
getter = getattr(self.client, "get_contract", None)
if not callable(getter):
if self.require_product_contract:
raise RuntimeError(
"OakFcc3Client sem get_contract(); versão produto obrigatória."
)
return
contract = getter() or {}
if not isinstance(contract, dict):
raise RuntimeError(
f"Contrato do OakFcc3Client inválido: {type(contract)}"
)
product = bool(contract.get("product_contract", False))
if self.require_product_contract and not product:
raise RuntimeError(
"CameraMultispectral exige module_params de produção homologado."
)
sizes_raw = contract.get("sensor_size_by_role") or {}
sizes = {}
for role in ("rgb", "re", "nir"):
if role not in sizes_raw:
if self.require_product_contract:
raise RuntimeError(
f"Contrato do Client sem sensor_size_by_role.{role}"
)
continue
sizes[role] = self._normalize_size(
sizes_raw[role],
f"sensor_size_by_role.{role}",
)
if "rgb" in sizes:
self.rgb_native_size = list(sizes["rgb"])
self.width = int(self.rgb_native_size[0])
self.height = int(self.rgb_native_size[1])
if "re" in sizes:
self.spectral_native_size["re"] = list(sizes["re"])
if "nir" in sizes:
self.spectral_native_size["nir"] = list(sizes["nir"])
if sizes:
self.sensor_size_by_role = {
role: list(size)
for role, size in sizes.items()
}
camera_hardware = contract.get("camera_hardware") or {}
if isinstance(camera_hardware, dict):
self.camera_hardware = json.loads(
json.dumps(camera_hardware, default=str)
)
bayer = contract.get("bayer_pattern")
if bayer:
bayer = str(bayer).upper()
if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(f"Bayer inválido no contrato: {bayer!r}")
self.bayer_pattern = bayer
default_target = contract.get("default_target_size")
if default_target is not None:
default_target = self._normalize_size(
default_target,
"client.default_target_size",
)
if (
self.requested_target_size is not None
and default_target is not None
and list(self.requested_target_size) != list(default_target)
):
raise RuntimeError(
"Resolução IA divergente do module_params: "
f"caller={self.requested_target_size}, "
f"module_params={default_target}"
)
self.target_size = (
list(default_target)
if default_target is not None
else (
list(self.requested_target_size)
if self.requested_target_size is not None
else list(self.rgb_native_size)
)
)
self.tensor_size = list(self.target_size)
self.product_contract = product
self.client_contract = json.loads(
json.dumps(contract, default=str)
)
def _montar_parametros(self, status=None, start_resp=None):
status = status or {}
altura_camera = 111.0
# Valores medidos por você anteriormente:
# FOV prático: 96 x 157 cm a 111 cm de altura.
# FOV prático histórico medido no conjunto mecânico.
# É mantido como referência operacional, mas não como calibração óptica.
largura_real = 157.0
altura_real = 96.0
cm_por_px_x = largura_real / float(self.width)
cm_por_px_y = altura_real / float(self.height)
tensor_w = int(self.tensor_size[0])
tensor_h = int(self.tensor_size[1])
# Para o worker, pixel operacional significa pixel do tensor entregue à IA,
# não pixel do sensor bruto. A homografia/crop pode reduzir discretamente
# a FOV real, então estes valores continuam sendo NOMINAIS.
cm_por_px_x = largura_real / float(tensor_w)
cm_por_px_y = altura_real / float(tensor_h)
parametros = {
"tipo": "multispectral",
"adapter_version": CAMERA_MULTISPECTRAL_VERSION,
"product_contract": bool(self.product_contract),
"module_calibration_json": self.module_calibration_json,
"module_params_sha256": self.module_params_sha256,
"frame_type": "RAW_BRUTO",
"channels": ["R", "G", "B", "RE", "NIR"],
"output_layout": "CHW",
"dtype": "float32",
"tensor_min": 0.0,
"tensor_max": 1.0,
"rgb_width": self.width,
"rgb_height": self.height,
"tensor_width": self.width if self.target_size is None else int(self.target_size[0]),
"tensor_height": self.height if self.target_size is None else int(self.target_size[1]),
"reference_camera": "rgb",
"rgb_native_size": list(self.rgb_native_size),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(
json.dumps(self.camera_hardware, default=str)
),
"bayer_pattern": self.bayer_pattern,
# Aliases compatíveis, agora explicitamente RGB nativo.
"rgb_width": int(self.rgb_native_size[0]),
"rgb_height": int(self.rgb_native_size[1]),
"tensor_size": list(self.tensor_size),
"tensor_width": tensor_w,
"tensor_height": tensor_h,
"fps": self.fps,
"altura_camera": altura_camera,
"largura_real": largura_real,
"altura_real": altura_real,
"cm_por_px_x": cm_por_px_x,
"cm_por_px_y": cm_por_px_y,
"cm_por_px_nominal": True,
"status": status,
"start_resp": start_resp,
"client_contract": json.loads(
json.dumps(self.client_contract, default=str)
),
}
return parametros
@ -557,6 +786,15 @@ class CameraMultispectral:
f"Tensor multiespectral deveria ter 5 canais, veio shape={tensor.shape}"
)
if self.target_size is not None:
expected_w = int(self.target_size[0])
expected_h = int(self.target_size[1])
if tuple(tensor.shape[1:]) != (expected_h, expected_w):
raise RuntimeError(
"Tensor final diverge do contrato de resolução: "
f"shape={tensor.shape}, esperado=(5,{expected_h},{expected_w})"
)
t_validate_ms = (time.perf_counter() - t0) * 1000.0
# ============================================================
@ -595,6 +833,13 @@ class CameraMultispectral:
"shape": list(tensor.shape),
"dtype": str(tensor.dtype),
"channels": ["R", "G", "B", "RE", "NIR"],
"product_contract": bool(self.product_contract),
"tensor_size": list(self.tensor_size),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"bayer_pattern": self.bayer_pattern,
"frame_id": int(meta.get("frame_id", 0) or 0),
"async_packet_seq": int(
@ -615,6 +860,22 @@ class CameraMultispectral:
},
}
#capture_perf = dict(meta.get("capture_perf", {}) or {})
#agora_log = time.monotonic()
#if not hasattr(self, "_ultimo_log_sync"):
# self._ultimo_log_sync = 0.0
#if agora_log - self._ultimo_log_sync >= 1.0:
# print(
# "[OAK_SYNC] "
# f"frame={meta.get('frame_id')} | "
# f"dt={float(meta.get('sync_dt_ms', 0.0) or 0.0):.3f}ms | "
# f"ok={bool(meta.get('sync_ok', False))} | "
# f"tol={float(meta.get('sync_tolerance_ms', 0.0) or 0.0):.3f}ms | "
# f"seq={capture_perf.get('selected_seq_by_cam')} | "
# f"ts={capture_perf.get('selected_ts_by_cam')}"
# )
# self._ultimo_log_sync = agora_log
with self._lock:
self.ultimo_tensor_multispec = tensor
self.ultimo_meta = meta
@ -674,7 +935,10 @@ class CameraMultispectral:
def requisitar_frame_rgb(self, force: bool = False, max_age_s: float = None):
"""
Retorna preview BGR uint8 do tensor multiespectral.
Retorna preview BGR uint8 gerado sob demanda a partir do Raw5 cacheado.
O hot path de inferência não paga custo de preview. O preview só é
construído quando stream/debug realmente pede um frame RGB.
"""
try:
agora = time.time()
@ -682,27 +946,58 @@ class CameraMultispectral:
max_age_s = self._cache_max_age_s
with self._lock:
cache_ok = (
preview_ok = (
self.ultimo_frame_rgb is not None
and self.timestamp_ultimo_frame_rgb is not None
and (agora - self.timestamp_ultimo_frame_rgb) < max_age_s
)
if cache_ok and not force:
if preview_ok and not force:
return self.ultimo_frame_rgb, dict(self._ultimo_resultado_rgb)
# Atualiza tensor, que por consequência atualiza preview RGB.
tensor, res = self.requisitar_tensor_multispec(force=force, max_age_s=max_age_s)
tensor_cache = self.ultimo_tensor_multispec
ts_tensor = self.timestamp_ultimo_tensor
tensor_fresco = (
tensor_cache is not None
and ts_tensor is not None
and (agora - ts_tensor) < max_age_s
)
if not tensor_fresco or force:
tensor_cache, res = self.requisitar_tensor_multispec(
force=force,
max_age_s=max_age_s,
)
if tensor_cache is None:
return None, {
"erro": res.get("erro") or "tensor indisponível para preview",
"duracao": res.get("duracao", 0.0),
"frame_valido": False,
}
t0 = time.perf_counter()
preview = self._tensor_to_preview_bgr(tensor_cache)
dur = time.perf_counter() - t0
if preview is None or preview.size <= 0:
raise RuntimeError("Falha ao gerar preview RGB a partir do Raw5.")
resultado = {
"erro": None,
"duracao": dur,
"frame_valido": True,
"origem": "raw5_cache_on_demand",
"shape": list(preview.shape),
}
with self._lock:
if self.ultimo_frame_rgb is not None:
return self.ultimo_frame_rgb, dict(self._ultimo_resultado_rgb)
self.ultimo_frame_rgb = preview
self.timestamp_ultimo_frame_rgb = time.time()
self._ultimo_resultado_rgb = resultado
return None, {
"erro": res.get("erro") or "preview RGB indisponível",
"duracao": res.get("duracao", 0.0),
"frame_valido": False,
}
return preview, resultado
except Exception as e:
self.mostrar_log(f"[CameraMultispectral] Erro ao requisitar RGB: {e}")
@ -1341,8 +1636,12 @@ class CameraMultispectral:
core = getattr(self.client, "core", None)
if core is not None and hasattr(core, "fuse_multispec_cameras"):
t0 = time.perf_counter()
out = core.fuse_multispec_cameras(decoded_fixed, meta, 5)
out = core.resize_tensor_chw(out, target_size=self.target_size)
out = core.fuse_multispec_cameras(
decoded_fixed,
meta,
5,
target_size=self.target_size,
)
#t_core_build_ms = (time.perf_counter() - t0) * 1000.0
#t_total_ms = (time.perf_counter() - t_total0) * 1000.0
@ -1407,6 +1706,16 @@ class CameraMultispectral:
meta_item.setdefault("role", role)
meta_item.setdefault("socket", info.get("socket", cam_id))
meta_item.setdefault("sensor", info.get("sensor"))
meta_item.setdefault("width", info.get("width"))
meta_item.setdefault("height", info.get("height"))
meta_item.setdefault("native_size", info.get("native_size"))
meta_item.setdefault("bit_depth", info.get("bit_depth"))
meta_item.setdefault("raw_format", info.get("raw_format"))
if role == "rgb":
meta_item.setdefault(
"bayer_pattern",
info.get("bayer_pattern") or self.bayer_pattern,
)
fixed["meta"] = meta_item
out[cam_id] = fixed
@ -1423,6 +1732,16 @@ class CameraMultispectral:
"role": role,
"socket": info.get("socket", cam_id),
"sensor": info.get("sensor"),
"width": info.get("width"),
"height": info.get("height"),
"native_size": info.get("native_size"),
"bit_depth": info.get("bit_depth"),
"raw_format": info.get("raw_format"),
"bayer_pattern": (
info.get("bayer_pattern") or self.bayer_pattern
if role == "rgb"
else None
),
"timestamp": (meta.get("timestamps") or {}).get(cam_id) if isinstance(meta, dict) else None,
"shape": list(item.shape),
"dtype": str(item.dtype),
@ -1441,6 +1760,28 @@ class CameraMultispectral:
# Salvamento científico / pós-processamento
# ============================================================
def _processing_diagnostics_json_safe(self):
core = getattr(self.client, "core", None) if self.client is not None else None
if core is None:
return None
payload = {
"fusion_result": getattr(core, "last_fusion_result", None),
"radiometric_normalization_result": getattr(
core,
"last_radiometric_normalization_result",
None,
),
"flatfield_result": getattr(core, "last_flatfield_result", None),
"frame_quality": getattr(core, "last_frame_quality_result", None),
}
try:
return json.loads(json.dumps(payload, default=str))
except Exception:
return None
def _ts_name(self) -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
@ -1466,6 +1807,20 @@ class CameraMultispectral:
with self._lock:
raw_meta = dict(self.ultimo_meta or {})
# Reforça o contrato científico no próprio stream_meta salvo.
raw_meta.setdefault("frame_type", "RAW_BRUTO")
raw_meta.setdefault("reference_camera", "rgb")
raw_meta.setdefault("bayer_pattern", self.bayer_pattern)
raw_meta.setdefault(
"sensor_size_by_role",
{role: list(size) for role, size in self.sensor_size_by_role.items()},
)
raw_meta.setdefault(
"camera_hardware",
json.loads(json.dumps(self.camera_hardware, default=str)),
)
raw_meta.setdefault("product_contract", bool(self.product_contract))
required = {"CAM_A", "CAM_B", "CAM_C"}
presentes = set(raw_frame.keys())
faltando = sorted(required - presentes)
@ -1526,9 +1881,9 @@ class CameraMultispectral:
preview = self.client.build_save_preview_from_cam_a(
packed_raw_by_camera=raw_frame,
meta_stream=raw_meta,
sensor_width=self.width,
sensor_height=self.height,
bayer_pattern="BGGR",
sensor_width=int(self.rgb_native_size[0]),
sensor_height=int(self.rgb_native_size[1]),
bayer_pattern=self.bayer_pattern,
)
if preview is not None:
@ -1641,9 +1996,22 @@ class CameraMultispectral:
"mx_id": self.mx_id,
"module_calibration_json": self.module_calibration_json,
"sensor_width": self.width,
"sensor_height": self.height,
"bayer_pattern": "BGGR",
# Compatibilidade root: sensor_width/height representam CAM_A/RGB.
"reference_camera": "rgb",
"sensor_width": int(self.rgb_native_size[0]),
"sensor_height": int(self.rgb_native_size[1]),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(
json.dumps(self.camera_hardware, default=str)
),
"bayer_pattern": self.bayer_pattern,
"tensor_size": list(self.tensor_size),
"product_contract": bool(self.product_contract),
"camera_adapter_version": CAMERA_MULTISPECTRAL_VERSION,
"module_params_sha256": self.module_params_sha256,
"fps_target": self.fps,
"frame_type": "RAW_BRUTO",
@ -1662,6 +2030,10 @@ class CameraMultispectral:
"stream_meta": raw_meta,
"actual_camera_controls": self.client.get_current_camera_controls() if self.client else None,
"radiometric_last_result": self.client.get_radiometric_last_result() if self.client else None,
"client_contract": json.loads(
json.dumps(self.client_contract, default=str)
),
"processing_diagnostics": self._processing_diagnostics_json_safe(),
"resultado": resultado,
}

View File

@ -11,6 +11,9 @@ from .radiometric_controller import RadiometricController
PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR")
PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES)
OAK_FCC3_CLIENT_VERSION = "production_v1_2026_08_24"
PRODUCT_SCHEMA = "multispec_module_params_v3"
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
class OakFcc3Client:
@ -46,58 +49,105 @@ class OakFcc3Client:
mx_id=None,
imu_modo="rotation_vector",
imu_freq_hz=200,
evaluate_quality=True,
require_product_contract=False,
**kwargs,
):
self.width = width
self.height = height
self.bayer = bayer
self.fps = fps
self.frame_type = frame_type
self.output_dtype = output_dtype
self.capture_mode = capture_mode
self.raw_policy = raw_policy
# width/height/bayer recebidos do caller ficam apenas como fallback legado.
# Em module_params de produção, hardware real + MP são a autoridade.
self.legacy_width = int(width)
self.legacy_height = int(height)
self.legacy_bayer = str(bayer or "BGGR").upper()
self.fps = float(fps)
self.frame_type = str(frame_type).upper()
self.output_dtype = str(output_dtype).lower()
self.capture_mode = str(capture_mode).upper()
self.raw_policy = str(raw_policy).lower()
self.module_calibration_json = module_calibration_json
self.module_params = self._load_module_params(module_calibration_json)
self.require_product_contract = bool(require_product_contract)
self.module_params = self._load_module_params(
module_calibration_json,
required=self.require_product_contract,
)
self.product_contract = self._is_product_module_params(self.module_params)
if self.require_product_contract and not self.product_contract:
raise RuntimeError(
"Contrato de produção obrigatório, mas module_params não foi "
"gerado pelo assembler oficial."
)
self.fusion_config = self.module_params.get("fusion_config", {}) or {}
self.camera_hardware = self._resolve_camera_hardware()
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
rgb_size = self.sensor_size_by_role.get(
"rgb",
[self.legacy_width, self.legacy_height],
)
self.rgb_native_width = int(rgb_size[0])
self.rgb_native_height = int(rgb_size[1])
# Mantidos por compatibilidade, mas agora significam RGB de referência.
self.width = self.rgb_native_width
self.height = self.rgb_native_height
self.bayer = self._resolve_bayer_pattern()
self.default_target_size = self._resolve_default_target_size()
self.imu_modo = str(imu_modo).strip().lower()
self.imu_freq_hz = int(imu_freq_hz)
self.evaluate_quality = bool(evaluate_quality)
self.mx_id = str(mx_id) if mx_id else None
# Validação antecipada do modo produto. O Manager também repete estes
# checks antes de abrir o hardware, de propósito.
self._validate_static_product_contract()
self.svc = OakFcc3Service(
timeout=10,
fps=fps,
width=width,
height=height,
frame_type=frame_type,
output_dtype=output_dtype,
capture_mode=capture_mode,
raw_policy=raw_policy,
fps=self.fps,
width=self.width,
height=self.height,
frame_type=self.frame_type,
output_dtype=self.output_dtype,
capture_mode=self.capture_mode,
raw_policy=self.raw_policy,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
mx_id=self.mx_id,
module_calibration_json=module_calibration_json,
module_params=self.module_params,
require_product_contract=self.require_product_contract,
imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz,
**kwargs,
)
self.applied_camera_controls = {}
self.radiometric_controller = None
self.radiometric_controller_enabled = False
self.core = RawProcessorCore(
sensor_width=width,
sensor_height=height,
bayer_pattern=bayer,
sensor_width=self.rgb_native_width,
sensor_height=self.rgb_native_height,
bayer_pattern=self.bayer,
calibration_json_path=module_calibration_json,
)
# Preview é apenas visual, porém também precisa usar o Bayer/raster
# reais do RGB para não mentir sobre a AR0234.
self.preview = RawProcessorPreview(
sensor_width=width,
sensor_height=height,
bayer_pattern=bayer,
sensor_width=self.rgb_native_width,
sensor_height=self.rgb_native_height,
bayer_pattern=self.bayer,
)
self._preview_cache = {
(self.rgb_native_width, self.rgb_native_height, self.bayer): self.preview,
}
def __enter__(self):
self.start()
@ -106,19 +156,247 @@ class OakFcc3Client:
def __exit__(self, exc_type, exc, tb):
self.stop()
def _load_module_params(self, path):
if not path or not os.path.isfile(path):
def _load_module_params(self, path, required=False):
if not path:
if required:
raise FileNotFoundError(
"module_params obrigatório no contrato de produção."
)
return {}
if not os.path.isfile(path):
if required:
raise FileNotFoundError(
f"module_params não encontrado: {path}"
)
return {}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"module_params root deve ser dict/object: {path}"
)
return data
@staticmethod
def _is_product_module_params(module_params):
mp = module_params or {}
assembly = mp.get("assembly_metadata", {}) or {}
return bool(
mp.get("schema") == PRODUCT_SCHEMA
and assembly.get("schema") == ASSEMBLY_SCHEMA
and isinstance(mp.get("camera_hardware"), dict)
and isinstance(mp.get("sensor_size_by_role"), dict)
and isinstance(mp.get("calibration_provenance"), dict)
)
@staticmethod
def _normalize_size(value, label):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{label} inválido: {value!r}")
return [w, h]
def _resolve_camera_hardware(self):
raw = self.module_params.get("camera_hardware", {}) or {}
if not self.product_contract:
return {}
out = {}
expected = {
"rgb": ("CAM_A", ("OV9782", "AR0234")),
"re": ("CAM_B", ("OV9282",)),
"nir": ("CAM_C", ("OV9282",)),
}
for role, (expected_socket, allowed_sensors) in expected.items():
item = raw.get(role)
if not isinstance(item, dict):
raise RuntimeError(f"camera_hardware sem role={role}")
socket = str(item.get("socket") or item.get("socket_name") or "").upper()
sensor = str(item.get("sensor") or item.get("sensor_name") or "").upper()
size = self._normalize_size(item.get("size"), f"camera_hardware.{role}.size")
if socket != expected_socket:
raise RuntimeError(
f"camera_hardware.{role}.socket={socket!r}, esperado={expected_socket!r}"
)
if sensor not in allowed_sensors:
raise RuntimeError(
f"camera_hardware.{role}.sensor={sensor!r}, permitidos={allowed_sensors}"
)
out[role] = {
"role": role,
"socket": socket,
"sensor": sensor,
"size": size,
}
return out
def _resolve_sensor_size_by_role(self):
if not self.product_contract:
return {
"rgb": [self.legacy_width, self.legacy_height],
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
raw = self.module_params.get("sensor_size_by_role", {}) or {}
out = {}
for role in ("rgb", "re", "nir"):
size = self._normalize_size(
raw.get(role),
f"sensor_size_by_role.{role}",
)
expected = self.camera_hardware[role]["size"]
if size != expected:
raise RuntimeError(
f"sensor_size_by_role.{role}={size} != camera_hardware={expected}"
)
out[role] = size
return out
def _resolve_bayer_pattern(self):
value = self.module_params.get("bayer_pattern") if self.product_contract else None
bayer = str(value or self.legacy_bayer).upper()
if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(f"bayer_pattern inválido: {bayer!r}")
return bayer
def _resolve_default_target_size(self):
value = (self.fusion_config or {}).get("target_size")
if value is None:
return None
return self._normalize_size(value, "fusion_config.target_size")
def _resolve_target_size(self, target_size):
value = target_size if target_size is not None else self.default_target_size
if value is None:
return None
size = self._normalize_size(value, "target_size")
if self.product_contract and self.default_target_size is not None:
if size != self.default_target_size:
raise RuntimeError(
"target_size solicitado diverge do module_params homologado: "
f"requested={size}, calibrated_runtime={self.default_target_size}"
)
return size
def _validate_static_product_contract(self):
if not self.product_contract:
return
if self.frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client de produção aceita somente frame_type='RAW_BRUTO'."
)
if self.raw_policy != "require_triple":
raise RuntimeError(
"OakFcc3Client de produção exige raw_policy='require_triple'."
)
if self.capture_mode not in ("TRIPLE", "AUTO"):
raise RuntimeError(
"OakFcc3Client de produção exige capture_mode TRIPLE/AUTO."
)
expected_mx = (
(self.module_params.get("calibration_provenance", {}) or {})
.get("device_mx_id")
)
if expected_mx and self.mx_id and str(expected_mx) != self.mx_id:
raise RuntimeError(
"MX ID solicitado diverge da calibração homologada: "
f"requested={self.mx_id}, calibrated={expected_mx}"
)
def _sync_contract_from_manager_status(self, status):
if not isinstance(status, dict):
return
if self.product_contract and not bool(status.get("product_contract", False)):
raise RuntimeError(
"Client carregou module_params produto, mas Manager não reconheceu o contrato."
)
sizes = status.get("sensor_size_by_role")
if isinstance(sizes, dict):
normalized = {
role: self._normalize_size(sizes.get(role), f"manager.sensor_size_by_role.{role}")
for role in ("rgb", "re", "nir")
}
if self.product_contract and normalized != self.sensor_size_by_role:
raise RuntimeError(
"Manager e Client discordam sobre sensor_size_by_role: "
f"manager={normalized}, client={self.sensor_size_by_role}"
)
self.sensor_size_by_role = normalized
bayer = status.get("bayer_pattern")
if bayer:
bayer = str(bayer).upper()
if self.product_contract and bayer != self.bayer:
raise RuntimeError(
f"Manager Bayer={bayer} != Client/module_params={self.bayer}"
)
def get_contract(self):
return {
"client_version": OAK_FCC3_CLIENT_VERSION,
"product_contract": bool(self.product_contract),
"require_product_contract": bool(self.require_product_contract),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(json.dumps(self.camera_hardware)),
"bayer_pattern": self.bayer,
"default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
}
def apply_module_camera_settings(self):
camera_settings = self.module_params.get("camera_settings", {}) or {}
applied = {}
if self.product_contract:
missing = [
role
for role in ("rgb", "re", "nir")
if not isinstance(camera_settings.get(role), dict)
]
if missing:
raise RuntimeError(
f"module_params produto sem camera_settings para: {missing}"
)
for role, settings in camera_settings.items():
applied = {}
roles = ("rgb", "re", "nir") if self.product_contract else tuple(camera_settings.keys())
for role in roles:
settings = camera_settings.get(role)
if not isinstance(settings, dict):
continue
@ -137,17 +415,43 @@ class OakFcc3Client:
}
self.applied_camera_controls = applied
if self.product_contract:
failed = {
role: value
for role, value in applied.items()
if not bool((value or {}).get("ok", False))
}
if failed:
raise RuntimeError(
f"Falha reaplicando camera_settings homologado: {failed}"
)
return applied
def enable_radiometric_controller(self):
cfg = self.module_params.get("radiometric_config", {}) or {}
enabled = bool(cfg.get("enabled", False))
# No produto atual este controller de exposição em campo é OFF.
# Não instanciamos um segundo piloto para ficar parado dentro do loop.
if not enabled:
self.radiometric_controller = None
self.radiometric_controller_enabled = False
return None
self.radiometric_controller = RadiometricController(
client=self,
config_json_path=self.module_calibration_json,
)
self.radiometric_controller.sync_from_camera_controls(self.applied_camera_controls)
self.radiometric_controller.sync_from_camera_controls(
self.applied_camera_controls
)
self.radiometric_controller.sync_from_actual_camera_controls()
self.radiometric_controller_enabled = bool(
getattr(self.radiometric_controller, "enabled", True)
)
return self.radiometric_controller
def update_radiometry(self, decoded, meta=None):
@ -187,17 +491,45 @@ class OakFcc3Client:
)
try:
self.mx_id = self.svc.manager.mx_id
status = self.svc.get_status()
self.mx_id = str(status.get("mx_id") or self.mx_id or "") or None
self._sync_contract_from_manager_status(status)
except Exception:
pass
# Se a validação de contrato falhar, fecha hardware antes de propagar.
try:
self.svc.disconnect()
except Exception:
pass
raise
# O Manager de produção já aplicou estes controles via initialControl.
# Reaplicamos após start como confirmação operacional e para manter
# compatibilidade com Managers legados durante a migração.
applied = self.apply_module_camera_settings()
self.enable_radiometric_controller()
if print_debug:
print("[OAK CLIENT] START:", resp)
print("[OAK CLIENT] CONTRACT:", self.get_contract())
print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied)
self.enable_radiometric_controller()
if isinstance(resp, dict):
resp = dict(resp)
resp["client_version"] = OAK_FCC3_CLIENT_VERSION
resp["product_contract"] = bool(self.product_contract)
resp["sensor_size_by_role"] = {
role: list(size)
for role, size in self.sensor_size_by_role.items()
}
resp["bayer_pattern"] = self.bayer
resp["default_target_size"] = (
None if self.default_target_size is None
else list(self.default_target_size)
)
resp["radiometric_controller_enabled"] = bool(
self.radiometric_controller_enabled
)
return resp
@ -209,7 +541,29 @@ class OakFcc3Client:
return self.svc.get_device_metrics()
def get_status(self):
return self.svc.get_status()
status = self.svc.get_status()
if not isinstance(status, dict):
status = {}
else:
status = dict(status)
status.update({
"client_version": OAK_FCC3_CLIENT_VERSION,
"client_product_contract": bool(self.product_contract),
"client_require_product_contract": bool(self.require_product_contract),
"client_sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"client_bayer_pattern": self.bayer,
"client_default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
})
return status
def get_next_raw_frame(self, timeout=1.0):
return self.svc.capture_frame(timeout=timeout)
@ -224,6 +578,12 @@ class OakFcc3Client:
frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper()
meta = dict(raw_meta)
if self.product_contract and frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client produto recebeu frame_type não canônico: "
f"{frame_type!r}. Esperado='RAW_BRUTO'."
)
if frame_type == "RAW_BRUTO":
decoded = self.decode_stream_cameras(raw_frame, raw_meta)
@ -320,6 +680,25 @@ class OakFcc3Client:
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
channels_expected = self._validate_physical_channel_count(channels_expected)
target_size = self._resolve_target_size(target_size)
frame_type = str(
(meta or {}).get("frame_type", self.frame_type)
if isinstance(meta, dict)
else self.frame_type
).upper()
# No runtime quente evitamos o quality audit pesado. Decodificamos e
# usamos exatamente o mesmo caminho do CameraMultispectral.
if not self.evaluate_quality and frame_type == "RAW_BRUTO":
decoded = self.core.decode_stream_cameras(frame, meta)
return self.build_infer_tensor_from_decoded(
decoded=decoded,
meta=meta,
channels_expected=channels_expected,
target_size=target_size,
)
return self.core.build_infer_tensor_from_stream(
frame,
meta,
@ -327,19 +706,57 @@ class OakFcc3Client:
target_size=target_size,
)
def build_infer_tensor_from_decoded(self, decoded, meta, channels_expected, target_size=None):
def build_infer_tensor_from_decoded(
self,
decoded,
meta,
channels_expected,
target_size=None,
evaluate_quality=None,
):
channels_expected = self._validate_physical_channel_count(channels_expected)
tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.core.resize_tensor_chw(tensor, target_size=target_size)
target_size = self._resolve_target_size(target_size)
if evaluate_quality is None:
evaluate_quality = self.evaluate_quality
evaluate_quality = bool(evaluate_quality)
# Core novo faz a geometria source->target em uma única etapa.
# NÃO redimensionar novamente depois da fusão.
tensor = self.core.fuse_multispec_cameras(
decoded,
meta,
channels_expected,
target_size=target_size,
)
patch_cfg = getattr(
self.core,
"patch_normalization_config",
{},
) or {}
# Mantém paridade com build_infer_tensor_from_stream: se a calibração
# habilitar patch normalization, ela também vale no caminho decoded.
patch_cfg = getattr(self.core, "patch_normalization_config", {}) or {}
if bool(patch_cfg.get("enabled", False)):
if self.product_contract:
raise RuntimeError(
"patch_normalization não é permitido no contrato produto."
)
tensor = self.core.apply_patch_normalization_to_tensor(tensor)
self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
return tensor
if evaluate_quality:
self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
else:
# Evita deixar resultado antigo no objeto e evita percentis no hot path.
self.core.last_frame_quality_result = {
"status": "skipped",
"usable_for_training": None,
"reason": "disabled_by_oak_fcc3_client",
"client_version": OAK_FCC3_CLIENT_VERSION,
}
return np.ascontiguousarray(
tensor.astype(np.float32, copy=False)
)
def decode_stream_cameras(self, frame, meta):
if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW":
@ -382,12 +799,19 @@ class OakFcc3Client:
tensor = np.transpose(rgb01.astype(np.float32), (2, 0, 1))
return np.ascontiguousarray(tensor.astype(np.float32, copy=False))
def build_multispec_tensor(self, decoded, meta=None, target_size=None):
def build_multispec_tensor(
self,
decoded,
meta=None,
target_size=None,
evaluate_quality=None,
):
tensor = self.build_infer_tensor_from_decoded(
decoded=decoded,
meta=meta,
channels_expected=5,
target_size=target_size,
evaluate_quality=evaluate_quality,
)
return np.ascontiguousarray(tensor.astype(np.float32, copy=False))
@ -448,10 +872,14 @@ class OakFcc3Client:
arr = arr[:, :, 0]
if bit_depth == 10 and arr.ndim == 2:
role_size = self.sensor_size_by_role.get(
str(role).lower(),
[self.rgb_native_width, self.rgb_native_height],
)
raw16 = self.core.unpack_raw10_packed(
arr,
sensor_width=int(info.get("width", self.width)),
sensor_height=int(info.get("height", self.height)),
sensor_width=int(info.get("width", role_size[0])),
sensor_height=int(info.get("height", role_size[1])),
)
if role == "rgb":
@ -513,7 +941,7 @@ class OakFcc3Client:
or cam_meta.get("bayer")
or stream_meta.get("bayer_pattern")
or bayer_pattern
or "RGGB"
or self.bayer
)
bayer = str(bayer).upper()
@ -545,19 +973,17 @@ class OakFcc3Client:
real_w = int(cam_meta.get("width", sensor_width))
real_h = int(cam_meta.get("height", sensor_height))
core = RawProcessorCore(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
cache_key = (real_w, real_h, bayer)
preview = self._preview_cache.get(cache_key)
if preview is None:
preview = RawProcessorPreview(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
self._preview_cache[cache_key] = preview
preview = RawProcessorPreview(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
raw16 = core.unpack_raw10_packed(
raw16 = self.core.unpack_raw10_packed(
packed,
sensor_width=real_w,
sensor_height=real_h,
@ -624,6 +1050,11 @@ class OakFcc3Client:
def decode_oak_aligned_multispec(self, frame, meta):
if self.product_contract:
raise RuntimeError(
"MULTISPEC alinhado pela OAK é legado e não faz parte do contrato produto."
)
"""
Decodifica frames já alinhados pela OAK.
@ -672,6 +1103,11 @@ class OakFcc3Client:
return decoded
def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None):
if self.product_contract:
raise RuntimeError(
"Tensor MULTISPEC pré-alinhado pela OAK é legado no contrato produto."
)
"""
Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia.
"""

View File

@ -1,95 +1,634 @@
# camera_worker/oak_fcc3_core/raw_processor_preview.py
# -*- coding: utf-8 -*-
"""
RawProcessorPreview - Production
================================
Conversor EXCLUSIVAMENTE visual para RAW Bayer da câmera RGB.
Este módulo:
- NÃO participa do tensor científico;
- NÃO altera RAW salvo;
- NÃO aplica Flat-Field;
- NÃO aplica Radiometric Normalization;
- NÃO aplica Homography;
- NÃO deve ser usado para treino ou inferência.
Contrato oficial:
CAM_A = RGB
OV9782 -> 1280x800
AR0234 -> 1920x1200
A resolução e o Bayer devem vir do module_params/Client já validados.
Pipeline de preview:
RAW Bayer uint16
-> robust display levels
-> uint8
-> demosaic BGR
-> gray-world opcional
-> contraste opcional
-> JPEG/preview
Importante:
O resultado desta classe é BGR, compatível com OpenCV.
"""
from __future__ import annotations
from typing import Optional
import cv2
import numpy as np
from typing import Optional
RAW_PROCESSOR_PREVIEW_VERSION = "production_v1_2026_08_24"
SUPPORTED_BAYER_PATTERNS = (
"RGGB",
"BGGR",
"GRBG",
"GBRG",
)
SUPPORTED_BIT_DEPTHS = (
8,
10,
12,
16,
)
class RawProcessorPreview:
def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"):
self.sensor_width = sensor_width
self.sensor_height = sensor_height
self.bayer_pattern = bayer_pattern.upper()
"""
Preview RAW Bayer para debug/stream/salvamento visual.
def raw16_to_vis8(
self, raw16: np.ndarray,
black_level: Optional[int] = None,
white_level: Optional[int] = None,
gamma: float = 2.2,
bit_depth: int = 10
) -> np.ndarray:
"""
Conversão para visualização:
- auto-level
- gamma
"""
max_val = float((1 << bit_depth) - 1)
Parâmetros
----------
sensor_width:
Largura NATIVA do RGB de referência.
raw = raw16.astype(np.float32)
sensor_height:
Altura NATIVA do RGB de referência.
if black_level is None:
black_level = float(raw.min())
if white_level is None:
white_level = float(raw.max())
bayer_pattern:
Bayer real da CAM_A, vindo do contrato homologado.
if white_level <= black_level:
norm = raw / max_val
else:
norm = (raw - black_level) / (white_level - black_level)
sensor_name:
Opcional, somente rastreabilidade.
norm = np.clip(norm, 0.0, 1.0)
strict_shape:
Se True, RAW recebido deve possuir exatamente HxW nativo.
Recomendado e default no produto.
"""
if gamma is not None and gamma > 0:
norm = np.power(norm, 1.0 / gamma)
def __init__(
self,
sensor_width: int,
sensor_height: int,
bayer_pattern: str = "GBRG",
sensor_name: Optional[str] = None,
strict_shape: bool = True,
):
self.sensor_width = int(sensor_width)
self.sensor_height = int(sensor_height)
return (norm * 255.0).clip(0, 255).astype(np.uint8)
if self.sensor_width <= 0 or self.sensor_height <= 0:
raise ValueError(
"Resolução inválida para preview: "
f"{self.sensor_width}x{self.sensor_height}"
)
def _debayer_code(self):
mapping = {
"RGGB": cv2.COLOR_BayerRG2RGB_EA,
"BGGR": cv2.COLOR_BayerBG2RGB_EA,
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
self.bayer_pattern = str(
bayer_pattern
).strip().upper()
if self.bayer_pattern not in SUPPORTED_BAYER_PATTERNS:
raise ValueError(
f"Padrão Bayer não suportado: {self.bayer_pattern}. "
f"Suportados={SUPPORTED_BAYER_PATTERNS}"
)
self.sensor_name = (
str(sensor_name).strip().upper()
if sensor_name
else None
)
self.strict_shape = bool(
strict_shape
)
# ============================================================
# Contrato
# ============================================================
def get_contract(self) -> dict:
return {
"preview_version": RAW_PROCESSOR_PREVIEW_VERSION,
"sensor_name": self.sensor_name,
"sensor_width": int(
self.sensor_width
),
"sensor_height": int(
self.sensor_height
),
"sensor_size": [
int(
self.sensor_width
),
int(
self.sensor_height
),
],
"bayer_pattern": (
self.bayer_pattern
),
"strict_shape": bool(
self.strict_shape
),
"output_color_order": "BGR",
"scientific_effect": "none",
}
if self.bayer_pattern not in mapping:
raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}")
# ============================================================
# Validação
# ============================================================
return mapping[self.bayer_pattern]
def _validate_raw(
self,
raw16: np.ndarray,
bit_depth: int,
) -> np.ndarray:
if raw16 is None:
raise ValueError(
"RAW de preview é None."
)
def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray:
raw = np.asarray(
raw16
)
if raw.ndim != 2:
raise ValueError(
"RAW Bayer deve ser 2D HxW; "
f"shape={raw.shape}"
)
if self.strict_shape:
expected = (
self.sensor_height,
self.sensor_width,
)
if raw.shape != expected:
raise ValueError(
"RAW Bayer com shape incompatível: "
f"recebido={raw.shape}, esperado={expected}"
)
bit_depth = int(
bit_depth
)
if bit_depth not in SUPPORTED_BIT_DEPTHS:
raise ValueError(
f"bit_depth não suportado: {bit_depth}. "
f"Suportados={SUPPORTED_BIT_DEPTHS}"
)
if not np.issubdtype(
raw.dtype,
np.integer,
):
raise ValueError(
"RAW Bayer para preview deve ser inteiro; "
f"dtype={raw.dtype}"
)
return raw
@staticmethod
def _validate_levels(
black_level,
white_level,
max_val,
):
black = (
None
if black_level is None
else float(
black_level
)
)
white = (
None
if white_level is None
else float(
white_level
)
)
if (
black is not None
and not np.isfinite(
black
)
):
raise ValueError(
f"black_level inválido: {black_level}"
)
if (
white is not None
and not np.isfinite(
white
)
):
raise ValueError(
f"white_level inválido: {white_level}"
)
if black is not None:
black = float(
np.clip(
black,
0.0,
max_val,
)
)
if white is not None:
white = float(
np.clip(
white,
0.0,
max_val,
)
)
return black, white
# ============================================================
# RAW -> display uint8
# ============================================================
def raw16_to_vis8(
self,
raw16: np.ndarray,
black_level: Optional[float] = None,
white_level: Optional[float] = None,
gamma: float = 2.2,
bit_depth: int = 10,
auto_low_percentile: float = 0.5,
auto_high_percentile: float = 99.5,
) -> np.ndarray:
"""
Gray-world simples para deixar o preview mais agradável.
Não usar no raw de treino.
Converte RAW Bayer para uint8 de DISPLAY.
Quando níveis não são informados, usa percentis robustos em vez de
min/max para evitar que hot pixels ou pequenos pontos saturados lavem
todo o preview.
Isso é somente estética visual.
"""
img = bgr.astype(np.float32)
raw = self._validate_raw(
raw16,
bit_depth,
)
mean_b = float(img[:, :, 0].mean())
mean_g = float(img[:, :, 1].mean())
mean_r = float(img[:, :, 2].mean())
max_val = float(
(1 << int(bit_depth))
- 1
)
mean_gray = (mean_b + mean_g + mean_r) / 3.0
black, white = (
self._validate_levels(
black_level,
white_level,
max_val,
)
)
low_p = float(
auto_low_percentile
)
high_p = float(
auto_high_percentile
)
if not (
0.0 <= low_p
< high_p
<= 100.0
):
raise ValueError(
"Percentis automáticos inválidos: "
f"{low_p}, {high_p}"
)
raw_f = raw.astype(
np.float32,
copy=False,
)
# Amostragem determinística para previews grandes.
# Evita percentil full-frame de 2.3M pixels em todo snapshot.
total = raw_f.size
max_samples = 250_000
if total > max_samples:
step = max(
1,
total // max_samples,
)
sample = raw_f.reshape(
-1
)[::step]
else:
sample = raw_f.reshape(
-1
)
if black is None:
black = float(
np.percentile(
sample,
low_p,
)
)
if white is None:
white = float(
np.percentile(
sample,
high_p,
)
)
if white <= black:
# Caso frame praticamente constante.
if max_val <= 0.0:
norm = np.zeros_like(
raw_f,
dtype=np.float32,
)
else:
norm = raw_f / max_val
else:
norm = (
raw_f - black
) / (
white - black
)
norm = np.clip(
norm,
0.0,
1.0,
)
if gamma is not None:
gamma = float(
gamma
)
if not np.isfinite(
gamma
) or gamma <= 0.0:
raise ValueError(
f"gamma inválido: {gamma}"
)
norm = np.power(
norm,
1.0 / gamma,
)
return np.clip(
norm * 255.0,
0.0,
255.0,
).astype(
np.uint8
)
# ============================================================
# Bayer
# ============================================================
def _debayer_code(self):
"""
Retorna código OpenCV que produz BGR.
A versão antiga usava COLOR_Bayer*2RGB_EA e em seguida tratava o
resultado como BGR, podendo trocar vermelho/azul no preview.
"""
# IMPORTANTE:
# Os aliases Bayer do OpenCV são contraintuitivos em relação ao
# nome físico 2x2 que usamos no produto. O mapeamento abaixo foi
# validado com mosaicos sintéticos de cor conhecida e produz BGR:
#
# físico RGGB -> OpenCV BayerBG2BGR
# físico BGGR -> OpenCV BayerRG2BGR
# físico GRBG -> OpenCV BayerGB2BGR
# físico GBRG -> OpenCV BayerGR2BGR
#
# Não "simplifique" este mapa pela semelhança dos nomes.
mapping = {
"RGGB": (
cv2.COLOR_BayerBG2BGR_EA
),
"BGGR": (
cv2.COLOR_BayerRG2BGR_EA
),
"GRBG": (
cv2.COLOR_BayerGB2BGR_EA
),
"GBRG": (
cv2.COLOR_BayerGR2BGR_EA
),
}
return mapping[
self.bayer_pattern
]
# ============================================================
# Ajustes VISUAIS
# ============================================================
@staticmethod
def apply_preview_white_balance(
bgr: np.ndarray,
strength: float = 1.0,
) -> np.ndarray:
"""
Gray-world simples somente para deixar o preview legível.
Não usar no RAW científico.
"""
img = np.asarray(
bgr
)
if (
img.ndim != 3
or img.shape[2] != 3
):
raise ValueError(
"Preview WB exige BGR HxWx3; "
f"shape={img.shape}"
)
strength = float(
strength
)
if not np.isfinite(
strength
):
raise ValueError(
f"strength inválido: {strength}"
)
strength = float(
np.clip(
strength,
0.0,
1.0,
)
)
work = img.astype(
np.float32,
copy=True,
)
# Amostragem reduz custo em 1920x1200.
h, w = work.shape[:2]
sample_step = max(
1,
int(
np.sqrt(
(h * w)
/ 200_000.0
)
),
)
sample = work[
::sample_step,
::sample_step,
]
mean_b = float(
sample[:, :, 0].mean()
)
mean_g = float(
sample[:, :, 1].mean()
)
mean_r = float(
sample[:, :, 2].mean()
)
mean_gray = (
mean_b
+ mean_g
+ mean_r
) / 3.0
eps = 1e-6
gain_b = mean_gray / max(mean_b, eps)
gain_g = mean_gray / max(mean_g, eps)
gain_r = mean_gray / max(mean_r, eps)
# strength=1 aplica total, strength=0 não aplica
gain_b = 1.0 + (gain_b - 1.0) * strength
gain_g = 1.0 + (gain_g - 1.0) * strength
gain_r = 1.0 + (gain_r - 1.0) * strength
gain_b = mean_gray / max(
mean_b,
eps,
)
img[:, :, 0] *= gain_b
img[:, :, 1] *= gain_g
img[:, :, 2] *= gain_r
gain_g = mean_gray / max(
mean_g,
eps,
)
return np.clip(img, 0, 255).astype(np.uint8)
gain_r = mean_gray / max(
mean_r,
eps,
)
def apply_preview_contrast(self, bgr: np.ndarray, alpha: float = 1.08, beta: float = 0.0) -> np.ndarray:
gain_b = 1.0 + (
gain_b - 1.0
) * strength
gain_g = 1.0 + (
gain_g - 1.0
) * strength
gain_r = 1.0 + (
gain_r - 1.0
) * strength
work[:, :, 0] *= gain_b
work[:, :, 1] *= gain_g
work[:, :, 2] *= gain_r
return np.clip(
work,
0.0,
255.0,
).astype(
np.uint8
)
@staticmethod
def apply_preview_contrast(
bgr: np.ndarray,
alpha: float = 1.08,
beta: float = 0.0,
) -> np.ndarray:
"""
Ajuste leve de contraste/brilho para preview.
Contraste/brilho de DISPLAY.
"""
out = cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta)
return out
alpha = float(
alpha
)
beta = float(
beta
)
if (
not np.isfinite(
alpha
)
or alpha <= 0.0
):
raise ValueError(
f"alpha inválido: {alpha}"
)
if not np.isfinite(
beta
):
raise ValueError(
f"beta inválido: {beta}"
)
return cv2.convertScaleAbs(
bgr,
alpha=alpha,
beta=beta,
)
# ============================================================
# Preview completo
# ============================================================
def raw16_to_preview_bgr(
self,
@ -99,28 +638,89 @@ class RawProcessorPreview:
apply_wb: bool = True,
apply_contrast: bool = True,
bit_depth: int = 10,
black_level: Optional[float] = None,
white_level: Optional[float] = None,
) -> np.ndarray:
"""
Pipeline de preview bonito:
1. auto-level + gamma no mosaico
2. demosaic
3. white balance simples
4. leve contraste final
Pipeline visual:
1. robust levels + gamma no mosaico
2. demosaic BGR
3. gray-world opcional
4. contraste opcional
"""
vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth)
bgr = cv2.cvtColor(vis8, self._debayer_code())
vis8 = self.raw16_to_vis8(
raw16,
black_level=black_level,
white_level=white_level,
gamma=gamma,
bit_depth=bit_depth,
)
bgr = cv2.cvtColor(
vis8,
self._debayer_code(),
)
if apply_wb:
bgr = self.apply_preview_white_balance(bgr, strength=wb_strength)
bgr = (
self.apply_preview_white_balance(
bgr,
strength=wb_strength,
)
)
if apply_contrast:
bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0)
bgr = (
self.apply_preview_contrast(
bgr,
alpha=1.08,
beta=0.0,
)
)
return bgr
return np.ascontiguousarray(
bgr,
dtype=np.uint8,
)
def raw16_to_preview_jpg_bytes(
self,
raw16: np.ndarray,
jpeg_quality: int = 95,
**preview_kwargs,
) -> bytes:
quality = int(
jpeg_quality
)
if not (
1 <= quality <= 100
):
raise ValueError(
f"jpeg_quality inválido: {quality}"
)
bgr = (
self.raw16_to_preview_bgr(
raw16,
**preview_kwargs,
)
)
ok, enc = cv2.imencode(
".jpg",
bgr,
[
int(
cv2.IMWRITE_JPEG_QUALITY
),
quality,
],
)
def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes:
bgr = self.raw16_to_preview_bgr(raw16)
ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
if not ok:
raise RuntimeError("Falha ao codificar preview JPG")
raise RuntimeError(
"Falha ao codificar preview JPG."
)
return enc.tobytes()

View File

@ -149,7 +149,7 @@ class SegformerNavRunner:
"trt_fp16_enable": bool(self.config.get("trt_fp16_enable", True)),
"trt_engine_cache_enable": bool(self.config.get("trt_engine_cache_enable", True)),
"trt_engine_cache_path": str(
self.config.get("trt_engine_cache_path", "./trt_cache_visual_worker")
self.config.get("trt_engine_cache_path", "./trt_cache_weed_worker")
),
}

View File

@ -1402,13 +1402,17 @@ class ModuloAtuador(ModuloDiagnosticoBase):
calibrando = ctx["calibrando"]
bomba_principal = ctx["bomba_principal"]
autonomia_corredor_aplicavel = bool(
auto
and ctx["operacao_iniciada"]
and not calibrando
and not ctx["finalizando"]
)
herbicida_pode_bloquear = bool(
auto and
ctx["operacao_iniciada"] and
not calibrando and
not ctx["finalizando"] and
not ctx["pausa"] and
not ctx["emergencia"]
autonomia_corredor_aplicavel
and not ctx["pausa"]
and not ctx["emergencia"]
)
em_transicao = self._pulverizacao_em_transicao(ctx)
@ -1491,7 +1495,7 @@ class ModuloAtuador(ModuloDiagnosticoBase):
})
score = min(score, 90)
if not ctx["herbicida_ok_corredor"]:
if (autonomia_corredor_aplicavel and not ctx["herbicida_ok_corredor"]):
motivo = self._texto_ou_padrao(
ctx.get("autonomia_motivo"),
ctx.get("motivo_herbicida_corredor"),

View File

@ -172,6 +172,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
motivos_gerais = []
condicoes_gerais = []
drivers_com_travamento = []
saude_total_ativos = 0.0
total_drivers_em_uso = 0
algum_conectado = False
@ -209,6 +210,17 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
conectado = self._bool(saude_mod.get("conectado_efetivo", False))
em_uso = self._bool(saude_mod.get("em_uso", True))
detalhes_valores = self._as_dict(saude_mod.get("detalhes_valores", {}))
if self._bool(detalhes_valores.get("travamento_confirmado", False)):
drivers_com_travamento.append({
"id": endereco_str,
"label": saude_mod.get("label"),
"rpm": detalhes_valores.get("rpm", 0),
"rpm_sp": detalhes_valores.get("rpm_sp", 0),
"corrente_motor": detalhes_valores.get("corrente_motor", 0),
"nivel": detalhes_valores.get("travamento_nivel"),
})
if conectado:
algum_conectado = True
@ -253,6 +265,9 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
"drivers_ativos": ativos,
"drivers_em_uso": total_drivers_em_uso,
"modelo": "movimentacao_inteligente_por_resposta_comando_valores_e_telemetria",
"travamento_confirmado": len(drivers_com_travamento) > 0,
"drivers_com_travamento": drivers_com_travamento
},
)
@ -924,6 +939,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
"temperatura_motor": temperatura_motor,
"temperatura_driver": temperatura_driver,
"score_valores": int(max(0, min(100, round(score)))),
# Novo contrato
"travamento_detectado": possivel_travamento,
"travamento_confirmado": estado_travamento["nivel"] == "confirmado",
"travamento_nivel": estado_travamento["nivel"],
"travamento_duracao_s": estado_travamento.get("duracao_s", 0.0),
}
return {

View File

@ -25,6 +25,7 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
COMANDO_GRACE_MS = 2000.0
COMANDO_ALERTA_MS = 3000.0
COMANDO_FALHA_MS = 7000.0
COMANDO_RECUPERACAO_MS = 1500.0
COMPONENTE_RECUPERACAO_GRACE_MS = 3000.0
MODULO_RECUPERACAO_GRACE_MS = 10000.0
@ -82,7 +83,10 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
updates[f"sensores.{item['key']}.saude"] = s
for item in servos:
s = self._avaliar_servo(item)
s = self._avaliar_servo(
item,
modulo_disponivel=sen_disponivel,
)
saude_individual.append(s)
updates[f"servos.{item['key']}.saude"] = s
@ -92,7 +96,10 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
updates[f"reles.{item['key']}.saude"] = s
for item in sinaleiros:
s = self._avaliar_sinaleiro(item)
s = self._avaliar_sinaleiro(
item,
modulo_disponivel=sen_disponivel,
)
saude_individual.append(s)
updates[f"sinaleiros.{item['key']}.saude"] = s
@ -239,12 +246,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
corrente_36v = self._valor_sensor(sensores, "AB36V", "Corrente")
temp_bateria = self._valor_sensor(sensores, "TBAT", "Temperatura")
temps_motor = []
for s in sensores:
label = str(s.get("dados", {}).get("label", ""))
if label.startswith("TMV"):
temps_motor.append(self._float(self._campo_atual(s["dados"], "Temperatura", 0), 0))
return {
"bms_ligado": bms_ligado,
"percent_bateria": percent_bateria,
@ -257,7 +258,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
"tensao_36v": tensao_36v,
"corrente_36v": corrente_36v,
"temp_bateria": temp_bateria,
"temperaturas_motor": temps_motor,
"debug": {
"bms_ligado": bms_ligado,
"percent_bateria": percent_bateria,
@ -267,7 +267,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
"sensor_ab36v_em_uso": ab36v_em_uso,
"tensao_36v": tensao_36v,
"corrente_36v": corrente_36v,
"temp_motor_max": max(temps_motor) if temps_motor else 0,
"temp_bateria": temp_bateria,
},
}
@ -281,7 +280,8 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
conectado = self._bool(dados.get("conectado", False))
aferir = self._bool(dados.get("aferir", False))
mandatorio = self._bool(dados.get("mandatorio", False))
em_uso = aferir
delegado_ao_mov = str(dados.get("label", "")).upper().startswith("TMV")
em_uso = aferir and not delegado_ao_mov
motivos = []
condicoes = []
@ -298,7 +298,12 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
saude = int(max(0, min(100, round(saude))))
status = self._resolver_status_item(conectado, saude, condicoes)
debug = {"resposta": resposta_score, "valores": valor_debug, "telemetria": telemetria["parametros"]}
debug = {
"resposta": resposta_score,
"valores": valor_debug,
"telemetria": telemetria["parametros"],
"avaliacao_delegada_ao_mov": delegado_ao_mov,
}
return self._item(item, status, saude, motivos, condicoes, em_uso, debug, "sensor")
def _avaliar_valores_sensor(self, item, ctx, motivos, condicoes):
@ -403,8 +408,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
label = str(dados.get("label", ""))
if label == "AB36V" and ctx["sensor_ab36v_em_uso"]:
return ["Tensao", "Corrente", "Temperatura"]
if label.startswith("TMV"):
return ["Temperatura"]
if label == "TBAT" and not ctx["bms_ligado"]:
return ["Temperatura"]
if mandatorio:
@ -415,73 +418,254 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
# Atuadores auxiliares
# ============================================================
def _avaliar_servo(self, item):
def _avaliar_servo(self, item, modulo_disponivel=True):
dados = item["dados"]
conectado = self._bool(dados.get("conectado", False))
controlar = self._bool(dados.get("controlar", False))
if not conectado:
return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], controlar, {}, "servo")
runtime, suspenso = self._preparar_atuador_auxiliar(
item=item,
grupo="servo",
nome="Servo SEN",
modulo_disponivel=modulo_disponivel,
)
if suspenso is not None:
return suspenso
motivos, condicoes = [], []
resposta = self._score_resposta(dados, motivos, condicoes, "Servo SEN")
comando, dbg_cmd = self._avaliar_comando_servo(item, motivos, condicoes)
telem = self._avaliar_telemetria(dados, motivos, ["ServoAnguloLeitura"] if controlar else [])
comando, dbg_cmd = self._avaliar_comando_servo(
item,
motivos,
condicoes,
runtime,
)
telem = self._avaliar_telemetria(
dados,
motivos,
["ServoAnguloLeitura"] if controlar else [],
)
saude = resposta * 0.25 + comando * 0.60 + telem["score"] * 0.15
if any(self._is_falha_hardware(c) for c in condicoes):
saude = min(saude, 45)
saude = int(max(0, min(100, round(saude))))
return self._item(item, self._resolver_status_item(conectado, saude, condicoes), saude, motivos, condicoes, controlar, {"resposta": resposta, "comando": dbg_cmd, "telemetria": telem["parametros"]}, "servo")
def _avaliar_comando_servo(self, item, motivos, condicoes):
return self._item(
item,
self._resolver_status_item(True, saude, condicoes),
saude,
motivos,
condicoes,
controlar,
{
"resposta": resposta,
"comando": dbg_cmd,
"telemetria": telem["parametros"],
},
"servo",
)
def _avaliar_comando_servo(self, item, motivos, condicoes, runtime):
dados = item["dados"]
label = dados.get("label")
controlar = self._bool(dados.get("controlar", False))
angulo_sp = self._float(dados.get("angulo_desejado", dados.get("angulo_sp", 0)), 0)
angulo = self._float(dados.get("angulo_leitura", self._campo_atual(dados, "ServoAnguloLeitura", -1)), -1)
ultimo_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999)
tem_idade = ultimo_ms < 900000
if angulo < 0:
motivos.append("Servo sem leitura de ângulo")
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": 70 if controlar else 35,
"classe": "processo",
"descricao": f"Servo {label} sem leitura de ângulo válida",
"acoes": ["Verificar retorno de ServoAnguloLeitura."],
})
return (60 if controlar else 85), {"angulo": angulo, "angulo_sp": angulo_sp, "sem_leitura": True}
desejado_presente = (
"angulo_desejado" in dados or
"angulo_sp" in dados
)
leitura_presente = (
"angulo_leitura" in dados or
self._campo_existe(dados, "ServoAnguloLeitura")
)
angulo_sp = self._float(
dados.get("angulo_desejado", dados.get("angulo_sp", 0.0)),
0.0,
)
angulo = self._float(
dados.get(
"angulo_leitura",
self._campo_atual(dados, "ServoAnguloLeitura", -1.0),
),
-1.0,
)
evidencia = self._evidencia_comando(dados)
debug = {
"controlar": controlar,
"angulo": angulo,
"angulo_sp": angulo_sp,
**evidencia,
}
if not controlar:
self._resetar_estado_comando(runtime, manter_desejado=False)
return 100, debug
if not desejado_presente or not leitura_presente:
self._resetar_estado_comando(runtime, manter_desejado=False)
debug["contrato_comando_completo"] = False
return 92, debug
token_desejado = round(angulo_sp, 3)
self._registrar_desejado(runtime, token_desejado)
erro = abs(angulo - angulo_sp)
if not controlar or erro <= self.SERVO_TOL_GRAUS:
return 100, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "controlar": controlar}
if tem_idade and ultimo_ms < self.COMANDO_GRACE_MS:
return 96, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms}
if erro >= self.SERVO_FALHA_GRAUS and tem_idade and ultimo_ms >= self.COMANDO_FALHA_MS:
motivos.append(f"Servo {label} não convergiu para o SP ({angulo:.1f}/{angulo_sp:.1f})")
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": 92,
"classe": "falha_hardware",
"descricao": f"Servo {label} não acompanhou o comando de ângulo",
"acoes": ["Verificar travamento mecânico ou alimentação do servo."],
})
return 25, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms}
if erro >= self.SERVO_ALERTA_GRAUS:
motivos.append(f"Servo {label} distante do SP ({angulo:.1f}/{angulo_sp:.1f})")
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": min(89, max(45, int(erro * 4))),
"classe": "processo",
"descricao": f"Servo {label} distante do ângulo desejado",
"acoes": ["Acompanhar se o erro persiste."],
})
return 70, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms}
motivos.append(f"Servo {label} fora da tolerância momentaneamente ({erro:.1f}°)")
return 88, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms}
debug["erro"] = erro
if erro <= self.SERVO_TOL_GRAUS:
return self._confirmar_recuperacao_servo(
runtime,
label,
angulo,
angulo_sp,
erro,
motivos,
condicoes,
debug,
)
runtime["coerencia_desde"] = None
if not evidencia["possui_comando"]:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "aguardando primeiro comando"
return 96, debug
if not evidencia["telemetria_pos_comando"]:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "aguardando telemetria posterior ao comando"
return 96, debug
if evidencia["ultimo_comando_ms"] < self.COMANDO_GRACE_MS:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "período de resposta ao comando"
return 96, debug
agora = time.monotonic()
tempo_retorno_ms = (
agora - runtime["componente_recuperado_em"]
) * 1000.0
if tempo_retorno_ms < self.COMPONENTE_RECUPERACAO_GRACE_MS:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "estabilização após reconexão"
debug["tempo_desde_retorno_componente_ms"] = tempo_retorno_ms
return 96, debug
if runtime["divergencia_desde"] is None:
runtime["divergencia_desde"] = agora
tempo_divergente_ms = (
agora - runtime["divergencia_desde"]
) * 1000.0
debug["tempo_divergente_ms"] = tempo_divergente_ms
if runtime["nivel_divergencia"] == "falha":
return self._falha_servo(
label, angulo, angulo_sp, erro,
motivos, condicoes, debug,
)
if tempo_divergente_ms < self.COMANDO_ALERTA_MS:
return 96, debug
if (
erro >= self.SERVO_FALHA_GRAUS and
tempo_divergente_ms >= self.COMANDO_FALHA_MS
):
runtime["nivel_divergencia"] = "falha"
return self._falha_servo(
label, angulo, angulo_sp, erro,
motivos, condicoes, debug,
)
runtime["nivel_divergencia"] = "alerta"
motivos.append(
f"Servo {label} distante do SP ({angulo:.1f}/{angulo_sp:.1f})"
)
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": min(89, max(55, int(erro * 4))),
"classe": "processo",
"descricao": f"Servo {label} não convergiu para o ângulo desejado",
"acoes": [
"Acompanhar a convergência do servo.",
"Verificar esforço mecânico se o erro permanecer.",
],
})
return (68 if erro >= self.SERVO_ALERTA_GRAUS else 82), debug
def _falha_servo(
self,
label,
angulo,
angulo_sp,
erro,
motivos,
condicoes,
debug,
):
motivos.append(
f"Servo {label} não convergiu para o SP "
f"({angulo:.1f}/{angulo_sp:.1f})"
)
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": 92,
"classe": "falha_hardware",
"descricao": f"Servo {label} não acompanhou o comando de ângulo",
"acoes": [
"Verificar travamento mecânico.",
"Verificar alimentação e retorno do servo.",
],
})
debug["erro"] = erro
return 25, debug
def _confirmar_recuperacao_servo(
self,
runtime,
label,
angulo,
angulo_sp,
erro,
motivos,
condicoes,
debug,
):
runtime["divergencia_desde"] = None
if runtime["nivel_divergencia"] is None:
runtime["coerencia_desde"] = None
return 100, debug
agora = time.monotonic()
if runtime["coerencia_desde"] is None:
runtime["coerencia_desde"] = agora
tempo_coerente_ms = (
agora - runtime["coerencia_desde"]
) * 1000.0
debug["recuperacao_em_confirmacao"] = True
debug["tempo_coerente_ms"] = tempo_coerente_ms
if tempo_coerente_ms >= self.COMANDO_RECUPERACAO_MS:
runtime["nivel_divergencia"] = None
runtime["coerencia_desde"] = None
return 100, debug
motivos.append(f"Servo {label} recuperou o SP; confirmando estabilidade")
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": 55,
"classe": "processo",
"descricao": f"Servo {label} voltou ao SP e está em confirmação",
"acoes": ["Aguardar confirmação da estabilidade do retorno."],
})
return 78, debug
def _avaliar_rele(self, item, modulo_disponivel=True):
return self._avaliar_saida_binaria(
@ -493,8 +677,11 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
modulo_disponivel=modulo_disponivel,
)
def _avaliar_sinaleiro(self, item):
return self._avaliar_sinaleiro_impl(item)
def _avaliar_sinaleiro(self, item, modulo_disponivel=True):
return self._avaliar_sinaleiro_impl(
item,
modulo_disponivel=modulo_disponivel,
)
def _avaliar_saida_binaria(
self,
@ -672,8 +859,155 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
"componente_recuperado_em": 0.0,
"divergencia_desde": None,
"coerencia_desde": None,
"nivel_divergencia": None,
"ultimo_desejado": None,
})
def _preparar_atuador_auxiliar(
self,
item,
grupo,
nome,
modulo_disponivel,
):
dados = item["dados"]
controlar = self._bool(dados.get("controlar", False))
conectado = self._bool(dados.get("conectado", False))
agora = time.monotonic()
runtime = self._runtime_saida_binaria(item)
if not modulo_disponivel:
runtime["modulo_disponivel_anterior"] = False
runtime["componente_disponivel_anterior"] = False
self._resetar_estado_comando(runtime, manter_desejado=False)
return runtime, self._item(
item,
StatusModulo.DESCONECTADO,
0,
[],
[],
False,
{
"avaliacao_suspensa": True,
"motivo_suspensao": "Módulo SEN indisponível",
},
grupo,
)
if not runtime["modulo_disponivel_anterior"]:
runtime["modulo_recuperado_em"] = agora
runtime["componente_disponivel_anterior"] = False
self._resetar_estado_comando(runtime, manter_desejado=False)
runtime["modulo_disponivel_anterior"] = True
tempo_retorno_modulo_ms = (
agora - runtime["modulo_recuperado_em"]
) * 1000.0
if not conectado:
runtime["componente_disponivel_anterior"] = False
self._resetar_estado_comando(runtime, manter_desejado=False)
if tempo_retorno_modulo_ms < self.MODULO_RECUPERACAO_GRACE_MS:
return runtime, self._item(
item,
StatusModulo.DESCONECTADO,
0,
[],
[],
False,
{
"avaliacao_suspensa": True,
"motivo_suspensao": (
"Aguardando reinicialização após retorno do SEN"
),
"tempo_desde_retorno_modulo_ms": tempo_retorno_modulo_ms,
},
grupo,
)
return runtime, self._item(
item,
StatusModulo.DESCONECTADO,
0,
["desconectado após período de recuperação"],
[{
"label": nome,
"valor": 0,
"severidade": 92,
"classe": "falha_hardware",
"descricao": (
f"{dados.get('label')} não reinicializou após o retorno do SEN"
),
"acoes": [
"Verificar configuração do componente.",
"Verificar retorno de telemetria.",
"Verificar firmware do SEN.",
],
}],
controlar,
{
"tempo_desde_retorno_modulo_ms": tempo_retorno_modulo_ms,
},
grupo,
)
if not runtime["componente_disponivel_anterior"]:
runtime["componente_recuperado_em"] = agora
self._resetar_estado_comando(runtime, manter_desejado=False)
runtime["componente_disponivel_anterior"] = True
return runtime, None
def _evidencia_comando(self, dados):
ultimo_comando_ms = self._float(
dados.get("ultimo_comando_ms", 999999),
999999,
)
ultima_resposta_ms = self._float(
dados.get("ultima_resposta_ms", 999999),
999999,
)
possui_comando = self._bool(
dados.get(
"possui_comando",
ultimo_comando_ms < 900000,
)
)
if "telemetria_pos_comando" in dados:
telemetria_pos_comando = self._bool(
dados.get("telemetria_pos_comando", False)
)
else:
telemetria_pos_comando = (
possui_comando and
ultima_resposta_ms <= ultimo_comando_ms + 100.0
)
return {
"possui_comando": possui_comando,
"telemetria_pos_comando": telemetria_pos_comando,
"ultimo_comando_ms": ultimo_comando_ms,
"ultima_resposta_ms": ultima_resposta_ms,
}
def _registrar_desejado(self, runtime, token_desejado):
if runtime.get("ultimo_desejado") == token_desejado:
return
self._resetar_estado_comando(runtime, manter_desejado=False)
runtime["ultimo_desejado"] = token_desejado
@staticmethod
def _resetar_estado_comando(runtime, manter_desejado=True):
runtime["divergencia_desde"] = None
runtime["coerencia_desde"] = None
runtime["nivel_divergencia"] = None
if not manter_desejado:
runtime["ultimo_desejado"] = None
def _avaliar_comando_binario(
self,
item,
@ -874,57 +1208,213 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
return 30, debug
def _avaliar_sinaleiro_impl(self, item):
def _avaliar_sinaleiro_impl(self, item, modulo_disponivel=True):
dados = item["dados"]
conectado = self._bool(dados.get("conectado", False))
controlar = self._bool(dados.get("controlar", False))
if not conectado:
return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], controlar, {}, "sinaleiro")
runtime, suspenso = self._preparar_atuador_auxiliar(
item=item,
grupo="sinaleiro",
nome="Sinaleiro SEN",
modulo_disponivel=modulo_disponivel,
)
if suspenso is not None:
return suspenso
motivos, condicoes = [], []
resposta = self._score_resposta(dados, motivos, condicoes, "Sinaleiro SEN")
comando, dbg_cmd = self._avaliar_comando_sinaleiro(item, motivos, condicoes)
resposta = self._score_resposta(
dados,
motivos,
condicoes,
"Sinaleiro SEN",
)
comando, dbg_cmd = self._avaliar_comando_sinaleiro(
item,
motivos,
condicoes,
runtime,
)
telem = self._avaliar_telemetria(dados, motivos, [])
# Sinaleiro é diagnóstico visual: não derruba operação sozinho.
# Sinaleiro é diagnóstico visual: uma divergência confirmada
# gera alerta, mas não transforma o SEN inteiro em falha.
saude = resposta * 0.45 + comando * 0.45 + telem["score"] * 0.10
saude = int(max(0, min(100, round(saude))))
return self._item(item, self._resolver_status_item(conectado, saude, condicoes), saude, motivos, condicoes, controlar, {"resposta": resposta, "comando": dbg_cmd, "telemetria": telem["parametros"]}, "sinaleiro")
def _avaliar_comando_sinaleiro(self, item, motivos, condicoes):
return self._item(
item,
self._resolver_status_item(True, saude, condicoes),
saude,
motivos,
condicoes,
controlar,
{
"resposta": resposta,
"comando": dbg_cmd,
"telemetria": telem["parametros"],
},
"sinaleiro",
)
def _avaliar_comando_sinaleiro(
self,
item,
motivos,
condicoes,
runtime,
):
dados = item["dados"]
label = dados.get("label")
controlar = self._bool(dados.get("controlar", False))
id_sp = self._safe_int(dados.get("status_led_id_sp", dados.get("led_id_sp", -1)), -1)
st_sp = self._safe_int(dados.get("status_led_sp", dados.get("led_status_sp", -1)), -1)
id_lido = self._safe_int(dados.get("status_led_id", self._campo_atual(dados, "StatusLedID", -1)), -1)
st_lido = self._safe_int(dados.get("status_led_leitura", self._campo_atual(dados, "StatusLedLeitura", -1)), -1)
ultimo_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999)
id_sp = self._safe_int(
dados.get("status_led_id_sp", dados.get("led_id_sp", -1)),
-1,
)
st_sp = self._safe_int(
dados.get("status_led_sp", dados.get("led_status_sp", -1)),
-1,
)
id_lido = self._safe_int(
dados.get(
"status_led_id",
self._campo_atual(dados, "StatusLedID", -1),
),
-1,
)
# O publicador C# envia as propriedades em todos os ciclos.
# Portanto, presença lógica é determinada pelo valor válido,
# não apenas pela existência da chave no dicionário.
id_sp_presente = id_sp >= 0
status_sp_presente = st_sp >= 0
st_lido = self._safe_int(
dados.get(
"status_led_leitura",
self._campo_atual(dados, "StatusLedLeitura", -1),
),
-1,
)
evidencia = self._evidencia_comando(dados)
debug = {
"controlar": controlar,
"status_lido": st_lido,
"status_sp": st_sp,
"id_lido": id_lido,
"id_sp": id_sp,
**evidencia,
}
if not controlar:
return 100, {"controlar": False, "status_lido": st_lido, "id_lido": id_lido}
if id_sp < 0 and st_sp < 0:
return 94, {"contrato_comando_completo": False, "status_lido": st_lido, "id_lido": id_lido}
self._resetar_estado_comando(runtime, manter_desejado=False)
return 100, debug
if not id_sp_presente and not status_sp_presente:
self._resetar_estado_comando(runtime, manter_desejado=False)
debug["contrato_comando_completo"] = False
return 94, debug
token_desejado = (
id_sp if id_sp_presente else None,
st_sp if status_sp_presente else None,
)
self._registrar_desejado(runtime, token_desejado)
# Se o SP existe, uma leitura ausente (-1) também é divergência.
coerente_id = (
not id_sp_presente or
(id_lido >= 0 and id_lido == id_sp)
)
coerente_status = (
not status_sp_presente or
(st_lido >= 0 and st_lido == st_sp)
)
coerente = coerente_id and coerente_status
debug["divergente"] = not coerente
coerente = True
if id_sp >= 0 and id_lido >= 0 and id_sp != id_lido:
coerente = False
if st_sp >= 0 and st_lido >= 0 and st_sp != st_lido:
coerente = False
if coerente:
return 100, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp}
if ultimo_ms < self.COMANDO_GRACE_MS:
return 96, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp, "ultimo_comando_ms": ultimo_ms}
runtime["divergencia_desde"] = None
motivos.append(f"Sinaleiro {label} diferente do comportamento desejado")
if runtime["nivel_divergencia"] is None:
runtime["coerencia_desde"] = None
return 100, debug
agora = time.monotonic()
if runtime["coerencia_desde"] is None:
runtime["coerencia_desde"] = agora
tempo_coerente_ms = (
agora - runtime["coerencia_desde"]
) * 1000.0
debug["recuperacao_em_confirmacao"] = True
debug["tempo_coerente_ms"] = tempo_coerente_ms
if tempo_coerente_ms >= self.COMANDO_RECUPERACAO_MS:
runtime["nivel_divergencia"] = None
runtime["coerencia_desde"] = None
return 100, debug
motivos.append(
f"Sinaleiro {label} recuperou o estado; confirmando estabilidade"
)
return 88, debug
runtime["coerencia_desde"] = None
if not evidencia["possui_comando"]:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "aguardando primeiro comando"
return 96, debug
if not evidencia["telemetria_pos_comando"]:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "aguardando telemetria posterior ao comando"
return 96, debug
if evidencia["ultimo_comando_ms"] < self.COMANDO_GRACE_MS:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "período de resposta ao comando"
return 96, debug
agora = time.monotonic()
tempo_retorno_ms = (
agora - runtime["componente_recuperado_em"]
) * 1000.0
if tempo_retorno_ms < self.COMPONENTE_RECUPERACAO_GRACE_MS:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "estabilização após reconexão"
debug["tempo_desde_retorno_componente_ms"] = tempo_retorno_ms
return 96, debug
if runtime["divergencia_desde"] is None:
runtime["divergencia_desde"] = agora
tempo_divergente_ms = (
agora - runtime["divergencia_desde"]
) * 1000.0
debug["tempo_divergente_ms"] = tempo_divergente_ms
if tempo_divergente_ms < self.COMANDO_ALERTA_MS:
return 96, debug
runtime["nivel_divergencia"] = "alerta"
motivos.append(
f"Sinaleiro {label} diferente do comportamento desejado"
)
condicoes.append({
"label": "Sinaleiro SEN",
"valor": st_lido,
"severidade": 45,
"severidade": 55,
"classe": "processo",
"descricao": f"Sinaleiro {label} não está refletindo o comportamento desejado",
"acoes": ["Conferir LED/sinaleiro físico e comando pelo SEN."],
"descricao": (
f"Sinaleiro {label} não está refletindo o comportamento desejado"
),
"acoes": [
"Conferir LED/sinaleiro físico.",
"Conferir comando e retorno publicados pelo SEN.",
],
})
return 82, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp, "ultimo_comando_ms": ultimo_ms}
return 78, debug
# ============================================================
# Processo geral
@ -1013,30 +1503,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
score = min(score, 60)
energia_critica = True
temp_motor_max = max(ctx["temperaturas_motor"]) if ctx["temperaturas_motor"] else 0
if temp_motor_max >= 90:
motivos.append(f"Temperatura máxima dos motores crítica ({temp_motor_max:.1f} °C)")
condicoes.append({
"label": "Temperatura motores",
"valor": temp_motor_max,
"severidade": 95,
"classe": "falha_hardware",
"descricao": f"Temperatura de motor crítica ({temp_motor_max:.1f} °C)",
"acoes": ["Reduzir carga/parar movimentação e verificar refrigeração."],
})
score = min(score, 45)
elif temp_motor_max >= 80:
motivos.append(f"Temperatura máxima dos motores elevada ({temp_motor_max:.1f} °C)")
condicoes.append({
"label": "Temperatura motores",
"valor": temp_motor_max,
"severidade": 75,
"classe": "risco_operacional",
"descricao": f"Temperatura de motor elevada ({temp_motor_max:.1f} °C)",
"acoes": ["Reduzir esforço se persistir."],
})
score = min(score, 75)
if ctx["temp_bateria"] >= 60:
motivos.append(f"Temperatura da bateria crítica ({ctx['temp_bateria']:.1f} °C)")
condicoes.append({

View File

@ -29,6 +29,9 @@ class RegrasTaticas:
# Pulverizador automático.
"atu_indisponivel": self._nova_persistencia(),
"bomba_cavitada": self._nova_persistencia(),
# MOV / possível atolamento ou bloqueio mecânico.
"mov_travamento": self._nova_persistencia(),
}
# ============================================================
@ -51,6 +54,13 @@ class RegrasTaticas:
motivos.extend(novos_motivos)
freio_necessario = freio_necessario or freio
novos_motivos, freio = self._avaliar_travamento_mov(
controle=controle,
operacao=operacao
)
motivos.extend(novos_motivos)
freio_necessario = freio_necessario or freio
novos_motivos, freio = self._avaliar_oak_parada_por_bloqueio(
controle=controle,
operacao=operacao
@ -126,6 +136,92 @@ class RegrasTaticas:
return motivos, freio_necessario
def _avaliar_travamento_mov(self, controle: dict, operacao: dict):
movimento_automatico = self._bool(
controle.get("movimento_automatico", False)
)
controle_manual = self._bool(
controle.get("controle_manual_acionado", False)
)
status_operacao = self._enum_or(
StatusOperacao,
operacao.get(
"status",
StatusOperacao.NaoIniciado.value
),
StatusOperacao.NaoIniciado
)
pausa = self._bool(operacao.get("pausa", False))
emergencia = self._bool(operacao.get("emergencia", False))
calibrando = self._bool(operacao.get("calibrando", False))
finalizando = self._bool(operacao.get("finalizando", False))
deve_avaliar = (
status_operacao == StatusOperacao.EmAndamento
and movimento_automatico
and not controle_manual
and not pausa
and not emergencia
and not calibrando
and not finalizando
)
if not deve_avaliar:
self._resetar_persistencia("mov_travamento")
return [], False
mov, _, operante = self._modulo_operante(
T_Code.Mov,
aceitar_alerta=True
)
if not operante:
# Indisponibilidade do módulo não deve ser confundida com atolamento.
self._resetar_persistencia("mov_travamento")
return [], False
saude = self._dict(mov.get("saude", {}))
detalhes = self._dict(saude.get("detalhes", {}))
travamento_health = self._bool(
detalhes.get("travamento_confirmado", False)
)
# O health já exige aproximadamente 2 segundos e 3 amostras.
# Esta persistência curta apenas protege a regra contra troca de
# snapshot ou inconsistência momentânea no Redis.
travamento_tatico = self._avaliar_persistencia(
nome="mov_travamento",
estado_bruto=travamento_health,
tempo_acionar=0.30,
tempo_liberar=2.00
)
if not travamento_tatico:
return [], False
drivers = detalhes.get("drivers_com_travamento", []) or []
ids = [
str(driver.get("label") or driver.get("id"))
for driver in drivers
if isinstance(driver, dict)
]
complemento = (
f" | motores: {', '.join(ids)}"
if ids
else ""
)
return [
"Possível atolamento ou bloqueio mecânico detectado"
f"{complemento}. Operação pausada para intervenção do operador."
], True
def _avaliar_oak_parada_por_bloqueio(self, controle: dict, operacao: dict):
"""
Bloqueia somente se a função de parada por bloqueio estiver ativa.

View File

@ -630,6 +630,90 @@ class ContextoGlobalRedis:
"motivos": motivos_controle_runtime,
}
# ------------------------------------------------------------
# 5.1) Solicitação de intertravamento sistêmico
# ------------------------------------------------------------
parada_seguranca = cls._dict(
controle.get("parada_seguranca", {})
)
parada_seguranca_ativa = cls._bool(
parada_seguranca.get("ativa", False)
)
codigo_parada_seguranca = str(
parada_seguranca.get("codigo", "") or ""
).strip().upper()
codigos_que_solicitam_emergencia = {
"MOV_TRAVAMENTO",
}
codigo_valido_emergencia = (
codigo_parada_seguranca
in codigos_que_solicitam_emergencia
)
solicitacao_da_regra = cls._bool(
parada_seguranca.get(
"solicitar_emergencia",
False
)
)
emergencia_sistema_solicitada = bool(
parada_seguranca_ativa
and codigo_valido_emergencia
and solicitacao_da_regra
)
motivo_emergencia_sistema = (
str(
parada_seguranca.get(
"motivo",
"Intertravamento sistêmico solicitado"
)
)
if emergencia_sistema_solicitada
else None
)
origem_emergencia_sistema = (
parada_seguranca.get("origem")
if emergencia_sistema_solicitada
else None
)
severidade_emergencia_sistema = (
cls._int(
parada_seguranca.get("severidade", 90),
90
)
if emergencia_sistema_solicitada
else 0
)
ts_evento_emergencia_sistema = (
parada_seguranca.get("ts_evento")
if emergencia_sistema_solicitada
else None
)
detalhes_regras["intertravamento_sistema"] = {
"ativo": emergencia_sistema_solicitada,
"codigo": (
codigo_parada_seguranca
if emergencia_sistema_solicitada
else None
),
"origem": origem_emergencia_sistema,
"severidade": severidade_emergencia_sistema,
"motivo": motivo_emergencia_sistema,
"ts_evento": ts_evento_emergencia_sistema,
"drivers": parada_seguranca.get("drivers", []),
}
# ------------------------------------------------------------
# 6) Decisão final
# ------------------------------------------------------------
@ -667,6 +751,22 @@ class ContextoGlobalRedis:
liberado=liberou,
motivo_nao_liberado="\n\n".join(motivos) if not liberou else None,
motivos_nao_liberado_lista=motivos,
emergencia_sistema_solicitada=emergencia_sistema_solicitada,
emergencia_sistema={
"solicitada": emergencia_sistema_solicitada,
"codigo": (
codigo_parada_seguranca
if emergencia_sistema_solicitada
else None
),
"origem": origem_emergencia_sistema,
"severidade": severidade_emergencia_sistema,
"motivo": motivo_emergencia_sistema,
"ts_evento": ts_evento_emergencia_sistema,
"drivers": parada_seguranca.get("drivers", []),
},
operacao_liberacao_debug={
"ts": time.time(),
"liberado": liberou,

View File

@ -1,7 +1,9 @@
import datetime
import json
import os
import threading
import time
from pathlib import Path
import cv2
import numpy as np
@ -24,6 +26,13 @@ from visual_worker.utils import converter_valores_numpy
from shared.perf_monitor import VisualPerfMonitor
CAMERA_MANAGER_VERSION = "production_v1_2026_08_24"
PRODUCT_ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
PRODUCT_MODULE_SCHEMA = "multispec_module_params_v3"
PHYSICAL_RAW5_CHANNELS = 5
class CameraManager:
"""
CameraManager v1 do Weed Worker.
@ -61,6 +70,12 @@ class CameraManager:
self.weed_detector = None
self.seg_config = None
# Contrato imutável da sessão produto.
# Não faz parte do reset operacional porque descreve modelo + MP + hardware.
self._pipeline_contract = {}
self._module_params_snapshot = {}
self._module_params_path = None
self.operante = False
self.iniciando = False
self.debug_visual = False
@ -226,6 +241,488 @@ class CameraManager:
self._proxima_inicializacao_monotonic = time.monotonic() + atraso
return atraso
@staticmethod
def _normalizar_size_wh(value, field_name):
if not isinstance(value, (list, tuple)) or len(value) != 2:
raise RuntimeError(
f"{field_name} deve ser [W,H]; recebido={value!r}"
)
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(
f"{field_name} inválido: {value!r}"
)
return [w, h]
@staticmethod
def _path_normalizado(value):
if not value:
return None
try:
p = Path(str(value))
if p.is_file():
return str(p.resolve())
# Mesmo quando ainda não existe, normaliza sem exigir existência.
return str(p.expanduser().absolute())
except Exception:
return str(value)
@staticmethod
def _carregar_json_obrigatorio(path_value, field_name):
if not path_value:
raise RuntimeError(f"{field_name} não definido.")
p = Path(str(path_value))
if not p.is_file():
raise FileNotFoundError(
f"{field_name} não encontrado: {p}"
)
with p.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"{field_name} deve conter JSON object/dict."
)
return p.resolve(), data
def _obter_tamanho_entrada_modelo(self):
if self.model_svc is None:
raise RuntimeError("Modelo ONNX ainda não inicializado.")
shape = list(
getattr(self.model_svc, "onnx_input_shape", None)
or []
)
if len(shape) != 4:
raise RuntimeError(
f"ONNX input deve ser BCHW 4D; shape={shape}"
)
h = shape[2]
w = shape[3]
if not isinstance(h, (int, np.integer)) or not isinstance(w, (int, np.integer)):
raise RuntimeError(
"Produto exige resolução ONNX estática em H/W; "
f"shape={shape}"
)
if int(w) <= 0 or int(h) <= 0:
raise RuntimeError(
f"Resolução ONNX inválida: {shape}"
)
return [int(w), int(h)]
def _validar_module_params_produto(self, seg_config, mx_id):
module_path, mp = self._carregar_json_obrigatorio(
seg_config.get("module_calibration_json"),
"module_calibration_json",
)
require_product = bool(
seg_config.get("require_product_contract", True)
)
if mp.get("schema") != PRODUCT_MODULE_SCHEMA:
raise RuntimeError(
f"module_params schema inválido: {mp.get('schema')!r}. "
f"Esperado={PRODUCT_MODULE_SCHEMA!r}"
)
assembly = mp.get("assembly_metadata", {}) or {}
assembly_schema = assembly.get("schema")
product_contract = assembly_schema == PRODUCT_ASSEMBLY_SCHEMA
if require_product and not product_contract:
raise RuntimeError(
"CameraManager produto exige module_params gerado pelo "
"module_params_assembler_production.py. "
f"assembly_metadata.schema={assembly_schema!r}"
)
fusion = mp.get("fusion_config", {}) or {}
mp_target = self._normalizar_size_wh(
fusion.get("target_size"),
"module_params.fusion_config.target_size",
)
sizes_raw = mp.get("sensor_size_by_role", {}) or {}
hardware = mp.get("camera_hardware", {}) or {}
sizes = {}
for role in ("rgb", "re", "nir"):
size = sizes_raw.get(role)
if size is None:
hw_role = hardware.get(role, {}) or {}
size = hw_role.get("size")
if require_product and size is None:
raise RuntimeError(
f"module_params sem sensor_size_by_role.{role}"
)
if size is not None:
sizes[role] = self._normalizar_size_wh(
size,
f"module_params.sensor_size_by_role.{role}",
)
if require_product and set(sizes) != {"rgb", "re", "nir"}:
raise RuntimeError(
f"module_params incompleto em sensor_size_by_role: {sizes}"
)
provenance = mp.get("calibration_provenance", {}) or {}
calibrated_mx = provenance.get("device_mx_id")
if calibrated_mx and str(calibrated_mx) != str(mx_id):
raise RuntimeError(
"MX ID solicitado diverge do módulo calibrado: "
f"solicitado={mx_id} calibrado={calibrated_mx}"
)
return {
"path": str(module_path),
"module_params": mp,
"product_contract": product_contract,
"require_product_contract": require_product,
"target_size": mp_target,
"sensor_size_by_role": sizes,
"camera_hardware": hardware,
"calibrated_mx_id": calibrated_mx,
}
def _validar_contrato_pipeline(self, seg_config, mx_id):
"""
Fecha a autoridade da resolução antes de abrir a OAK:
ONNX input [W,H]
==
module_params.fusion_config.target_size
==
seg_config.ia_resolution
camera_width/camera_height deixam de participar da decisão física.
"""
model_target = self._obter_tamanho_entrada_modelo()
mp_info = self._validar_module_params_produto(
seg_config,
mx_id,
)
mp_target = list(mp_info["target_size"])
if model_target != mp_target:
raise RuntimeError(
"Contrato de resolução incompatível entre ONNX e module_params: "
f"ONNX={model_target} module_params={mp_target}"
)
cfg_target_raw = seg_config.get("ia_resolution")
if cfg_target_raw is not None:
cfg_target = self._normalizar_size_wh(
cfg_target_raw,
"seg_config.ia_resolution",
)
if cfg_target != model_target:
raise RuntimeError(
"Contrato de resolução incompatível entre config, ONNX e MP: "
f"config={cfg_target} ONNX={model_target} MP={mp_target}"
)
else:
cfg_target = list(model_target)
# Canonicaliza o config em memória. A partir daqui quem consulta
# ia_resolution recebe o contrato já validado, não um default solto.
seg_config["ia_resolution"] = list(model_target)
seg_config["module_calibration_json"] = mp_info["path"]
model_channels = int(
getattr(self.model_svc, "channels", 0)
or 0
)
input_names = list(
getattr(
self.model_svc,
"input_channel_names",
[],
)
or []
)
if model_channels <= 0 or len(input_names) != model_channels:
raise RuntimeError(
"Contrato de canais do modelo inválido: "
f"C={model_channels} names={input_names}"
)
physical_allowed = {
"R", "G", "B", "RE", "NIR", "NDVI", "NDRE",
}
invalid = [
str(x)
for x in input_names
if str(x).upper() not in physical_allowed
]
if invalid:
raise RuntimeError(
f"Canais ONNX não suportados pelo pipeline: {invalid}"
)
rgb_native = (
list(mp_info["sensor_size_by_role"].get("rgb"))
if mp_info["sensor_size_by_role"].get("rgb") is not None
else None
)
if mp_info["require_product_contract"] and rgb_native is None:
raise RuntimeError(
"Produto exige resolução RGB nativa no module_params."
)
model_path = getattr(
self.model_svc,
"onnx_path",
None,
)
contract = {
"validated": True,
"camera_manager_version": CAMERA_MANAGER_VERSION,
"product_contract": bool(mp_info["product_contract"]),
"require_product_contract": bool(
mp_info["require_product_contract"]
),
"tensor_size": list(model_target),
"onnx_input_shape": list(
getattr(
self.model_svc,
"onnx_input_shape",
[],
)
or []
),
"onnx_input_channels": input_names,
"onnx_model_path": (
str(model_path)
if model_path is not None
else None
),
"module_params_path": mp_info["path"],
"sensor_size_by_role": dict(
mp_info["sensor_size_by_role"]
),
"camera_hardware": dict(
mp_info["camera_hardware"]
),
"rgb_native_size": rgb_native,
"calibrated_mx_id": mp_info[
"calibrated_mx_id"
],
}
self._pipeline_contract = contract
self._module_params_snapshot = mp_info[
"module_params"
]
self._module_params_path = mp_info[
"path"
]
self.mostrar_log(
"[weed][CONTRACT] "
f"tensor={contract['tensor_size']} "
f"channels={contract['onnx_input_channels']} "
f"product={contract['product_contract']} "
f"mp={contract['module_params_path']}"
)
return contract
def _validar_tensor_camera(self, tensor5, contexto="runtime"):
if not isinstance(tensor5, np.ndarray):
raise RuntimeError(
f"[{contexto}] tensor da câmera deve ser np.ndarray; "
f"recebido={type(tensor5)}"
)
if tensor5.ndim != 3:
raise RuntimeError(
f"[{contexto}] Raw5 deve ser CHW 3D; shape={tensor5.shape}"
)
if int(tensor5.shape[0]) != PHYSICAL_RAW5_CHANNELS:
raise RuntimeError(
f"[{contexto}] câmera deve entregar Raw5 com C=5; "
f"shape={tensor5.shape}"
)
expected = (
self._pipeline_contract.get(
"tensor_size"
)
or []
)
if len(expected) == 2:
expected_w = int(expected[0])
expected_h = int(expected[1])
if (
int(tensor5.shape[2]) != expected_w
or int(tensor5.shape[1]) != expected_h
):
raise RuntimeError(
f"[{contexto}] tensor H/W incompatível: "
f"recebido={tensor5.shape} "
f"esperado=(5,{expected_h},{expected_w})"
)
if tensor5.dtype != np.float32:
raise RuntimeError(
f"[{contexto}] Raw5 produto deve ser float32; "
f"dtype={tensor5.dtype}"
)
return True
def _validar_config_dinamica_contrato(self, cfg):
"""
Somente parâmetros operacionais podem mudar durante a sessão.
Modelo, MP, canais e resolução exigem reinicialização completa.
"""
if not self._pipeline_contract.get("validated", False):
return cfg
expected_size = list(
self._pipeline_contract[
"tensor_size"
]
)
requested_size = cfg.get(
"ia_resolution"
)
if requested_size is not None:
requested_size = self._normalizar_size_wh(
requested_size,
"config dinâmica ia_resolution",
)
if requested_size != expected_size:
raise RuntimeError(
"ia_resolution não pode mudar com a sessão ativa: "
f"atual={expected_size} solicitado={requested_size}. "
"Reinicie CameraManager com o novo modelo/MP."
)
cfg["ia_resolution"] = list(
expected_size
)
expected_mp = self._pipeline_contract.get(
"module_params_path"
)
requested_mp = self._path_normalizado(
cfg.get(
"module_calibration_json"
)
)
if (
expected_mp
and requested_mp
and requested_mp != self._path_normalizado(
expected_mp
)
):
raise RuntimeError(
"module_calibration_json não pode mudar com a sessão ativa. "
"Feche/reinicialize o CameraManager."
)
current_model = self._path_normalizado(
self._pipeline_contract.get(
"onnx_model_path"
)
)
requested_model = self._path_normalizado(
cfg.get(
"onnx_model_path"
)
or cfg.get(
"ia_model_path"
)
)
if (
current_model
and requested_model
and current_model != requested_model
):
raise RuntimeError(
"Modelo ONNX não pode ser trocado dinamicamente na sessão ativa. "
"Feche/reinicialize o CameraManager."
)
requested_channels = cfg.get(
"input_channels"
)
if isinstance(
requested_channels,
str,
):
requested_channels = [
x.strip().upper()
for x in requested_channels.split(",")
if x.strip()
]
elif requested_channels is not None:
requested_channels = [
str(x).upper()
for x in requested_channels
]
current_channels = [
str(x).upper()
for x in self._pipeline_contract.get(
"onnx_input_channels",
[]
)
]
if (
requested_channels is not None
and requested_channels != current_channels
):
raise RuntimeError(
"input_channels não pode mudar com ONNX ativo: "
f"atual={current_channels} solicitado={requested_channels}"
)
return cfg
def inicializar(self, mx_id):
if mx_id is None:
return False
@ -271,7 +768,20 @@ class CameraManager:
self.seg_config.get("posproc_intervalo_min_s", 5.0)
)
camera_nova = self._inicializar_camera(mx_id, self.seg_config)
# Produto: valida o modelo e o MP ANTES de abrir a OAK.
# Assim não ocupamos USB/pipeline para só depois descobrir
# que ONNX, module_params e ia_resolution são incompatíveis.
self._inicializar_modelo()
self._validar_contrato_pipeline(
self.seg_config,
mx_id,
)
camera_nova = self._inicializar_camera(
mx_id,
self.seg_config,
)
if camera_nova is None:
atraso = self._registrar_falha_inicializacao()
self.mostrar_log(
@ -294,7 +804,6 @@ class CameraManager:
self.debug_visual = bool(self.seg_config.get("debug_visual", False))
self.debug_perf = bool(self.seg_config.get("debug_perf", False))
self._inicializar_modelo()
self._inicializar_detector()
self.operante = False
@ -374,15 +883,56 @@ class CameraManager:
nova = None
try:
contract = self._pipeline_contract or {}
if not contract.get("validated", False):
raise RuntimeError(
"Contrato pipeline não validado antes da câmera."
)
target_size = list(
contract["tensor_size"]
)
rgb_native = (
contract.get(
"rgb_native_size"
)
or [1280, 800]
)
# camera_width/camera_height do config deixam de ser autoridade.
# Estes valores são apenas fallback de compatibilidade do construtor
# e vêm do MP homologado.
fallback_w = int(
rgb_native[0]
)
fallback_h = int(
rgb_native[1]
)
nova = CameraMultispectral(
self.mostrar_log,
mx_id=mx_id,
module_calibration_json=seg_config.get("module_calibration_json"),
width=int(seg_config.get("camera_width", 1280)),
height=int(seg_config.get("camera_height", 800)),
# Default conservador para campo. Se o JSON definir 40, ele será respeitado.
fps=int(seg_config.get("camera_fps", 20)),
target_size=seg_config.get("ia_resolution", [1024, 640]),
module_calibration_json=contract[
"module_params_path"
],
width=fallback_w,
height=fallback_h,
# FPS continua sendo política operacional.
fps=int(
seg_config.get(
"camera_fps",
20,
)
),
target_size=target_size,
require_product_contract=bool(
contract.get(
"require_product_contract",
True,
)
),
)
if not nova.iniciado:
@ -392,6 +942,62 @@ class CameraManager:
pass
return None
if bool(
contract.get(
"require_product_contract",
True,
)
):
if not bool(
getattr(
nova,
"product_contract",
False,
)
):
raise RuntimeError(
"CameraMultispectral iniciou sem contrato produto."
)
camera_tensor_size = list(
getattr(
nova,
"tensor_size",
[],
)
or []
)
if camera_tensor_size != target_size:
raise RuntimeError(
"CameraMultispectral divergiu do tamanho final: "
f"camera={camera_tensor_size} "
f"contrato={target_size}"
)
camera_sizes = dict(
getattr(
nova,
"sensor_size_by_role",
{},
)
or {}
)
expected_sizes = dict(
contract.get(
"sensor_size_by_role",
{},
)
or {}
)
if camera_sizes != expected_sizes:
raise RuntimeError(
"CameraMultispectral divergiu do hardware homologado: "
f"camera={camera_sizes} esperado={expected_sizes}"
)
return nova
except Exception as e:
@ -400,12 +1006,51 @@ class CameraManager:
nova.parar()
except Exception:
pass
self.mostrar_log(f"⚠️ Camera com ID {mx_id} não conectada: {e}")
self.mostrar_log(
f"⚠️ Camera com ID {mx_id} não conectada: {e}"
)
return None
def _inicializar_modelo(self):
requested = (
self.seg_config.get("onnx_model_path")
or self.seg_config.get("ia_model_path")
or self.seg_config.get("model_path")
or self.seg_config.get("onnx_path")
)
if self.model_svc is not None:
return
current = getattr(
self.model_svc,
"onnx_path",
None,
)
current_norm = self._path_normalizado(
current
)
requested_norm = self._path_normalizado(
requested
)
if (
current_norm
and requested_norm
and current_norm != requested_norm
):
self.mostrar_log(
"[weed][MODEL] modelo mudou entre sessões; "
f"recriando runtime | antigo={current_norm} "
f"novo={requested_norm}"
)
# Libera a referência. O ORT/TRT anterior será coletado
# quando não houver mais referências ao serviço.
self.model_svc = None
else:
return
self.model_svc = MultiSpecSegformerService(
model_config=self.seg_config,
@ -414,7 +1059,8 @@ class CameraManager:
self.mostrar_log(
f"[weed][MODEL] backend={getattr(self.model_svc, 'runtime_backend', 'onnx')} "
f"runtime_mode={getattr(self.model_svc, 'runtime_mode', None)}"
f"runtime_mode={getattr(self.model_svc, 'runtime_mode', None)} "
f"input_shape={getattr(self.model_svc, 'onnx_input_shape', None)}"
)
def _inicializar_detector(self):
@ -689,6 +1335,11 @@ class CameraManager:
time.sleep(0.05)
continue
self._validar_tensor_camera(
tensor5,
contexto="warmup",
)
frame_id = int(
res.get("frame_id")
or res.get("async_packet_seq")
@ -910,6 +1561,7 @@ class CameraManager:
from weed_worker.config import load_seg_config
cfg = load_seg_config()
cfg = self._validar_config_dinamica_contrato(cfg)
self.seg_config = cfg
self.qtd_bicos = int(cfg.get("qtd_bicos", self.qtd_bicos or 7) or 7)
self.posproc_intervalo_min_s = float(cfg.get("posproc_intervalo_min_s", self.posproc_intervalo_min_s))
@ -974,7 +1626,9 @@ class CameraManager:
motivos = []
if self.camera is None:
if not self._pipeline_contract.get("validated", False):
motivos.append("contrato ONNX/module_params não validado")
elif self.camera is None:
motivos.append("câmera indisponível")
elif self.iniciando:
motivos.append("câmera inicializando")
@ -1010,6 +1664,23 @@ class CameraManager:
"timeout_tensor_s": timeout_tensor,
"timeout_inferencia_s": timeout_inferencia,
"timeout_deteccao_s": timeout_deteccao,
"contract": {
"validated": bool(
self._pipeline_contract.get("validated", False)
),
"product_contract": bool(
self._pipeline_contract.get("product_contract", False)
),
"tensor_size": list(
self._pipeline_contract.get("tensor_size", []) or []
),
"onnx_input_channels": list(
self._pipeline_contract.get("onnx_input_channels", []) or []
),
"module_params_path": self._pipeline_contract.get(
"module_params_path"
),
},
}
self.pipeline_ia_ok = ok
@ -1017,6 +1688,20 @@ class CameraManager:
return resultado
def get_pipeline_contract(self):
"""Snapshot somente-leitura do contrato ONNX + MP da sessão atual."""
try:
return json.loads(
json.dumps(
self._pipeline_contract,
default=str,
)
)
except Exception:
return dict(
self._pipeline_contract
)
# ============================================================
# Caches
# ============================================================
@ -1415,6 +2100,11 @@ class CameraManager:
time.sleep(0.50 if reiniciou else 0.05)
continue
self._validar_tensor_camera(
tensor5,
contexto="capture",
)
if not self._sessao_camera_valida(camera_atual, generation):
self.perf.inc("tensor_descartado_geracao")
continue