From c9ad8d2affbed33c3cddcb27f9179cbdf044c46d Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Mon, 31 Aug 2026 09:30:08 -0300 Subject: [PATCH] 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 --- .../Forms/Direcional/frmDirConfig.Designer.cs | 194 +- .../AgroBase/Forms/Direcional/frmDirConfig.cs | 6 + .../AgroBase/Models/Modules/AtuadorModel.cs | 60 +- .../Models/Modules/DirecionalModel.cs | 158 +- .../Modules/MovimentacaoUnificadoModel.cs | 2 + .../Models/Modules/SensoriamentoModel.cs | 10 +- .../Models/Operacoes/OperacaoModel.cs | 44 +- .../AgroBase/Services/MKS057DCanService.cs | 2 + .../Operadores/HealthWorkerService.cs | 134 +- .../camera_worker/camera_multispectral.py | 442 ++- .../oak_fcc3_core/oak_fcc3_client.py | 556 +++- .../oak_fcc3_core/oak_fcc3_manager.py | 918 +++++- .../oak_fcc3_core/oak_fcc3_service.py | 1080 +++++- .../oak_fcc3_core/raw_processor_core.py | 2911 ++++++++++++++--- .../oak_fcc3_core/raw_processor_preview.py | 742 ++++- .../oak_fcc3_core/segformer_service.py | 2904 +++++++++++++--- .../workers/camera_worker/segformer_runner.py | 2 +- .../workers/health_worker/modulos/atuador.py | 18 +- .../health_worker/modulos/movimentacao.py | 21 + .../health_worker/modulos/sensoriamento.py | 708 +++- .../manager_worker/modulos/regras_taticas.py | 96 + .../workers/shared/contexto_global_redis.py | 100 + .../workers/weed_worker/camera_manager.py | 714 +++- .../Scripts/workers/weed_worker/config.py | 1173 +++++-- 24 files changed, 11007 insertions(+), 1988 deletions(-) diff --git a/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.Designer.cs b/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.Designer.cs index 44162dff7..f5829e259 100644 --- a/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.Designer.cs +++ b/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.Designer.cs @@ -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; } } \ No newline at end of file diff --git a/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.cs b/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.cs index f68e8ef3d..89c88328d 100644 --- a/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.cs +++ b/AgroBase/AgroBase/Forms/Direcional/frmDirConfig.cs @@ -72,6 +72,9 @@ namespace AgroBase.Forms.Direcional TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo"); txtOffsetAngulo.Text = Modulo.DirMotor.OffsetAnguloReal.ToString(); + TextBox txtOffsetRef = FuncoesGlobais.FindControlRecursive(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetRef"); + txtOffsetRef.Text = Modulo.DirMotor.OffsetReferenciamento.ToString(); + CheckBox chbUsar = FuncoesGlobais.FindControlRecursive(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar"); chbUsar.Checked = Modulo.DirMotor.UsarAnguloSensor; } @@ -119,6 +122,9 @@ namespace AgroBase.Forms.Direcional TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo"); Modulo.DirMotor.OffsetAnguloReal = Convert.ToDouble(txtOffsetAngulo.Text); + TextBox txtOffsetRef = FuncoesGlobais.FindControlRecursive(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetRef"); + Modulo.DirMotor.OffsetReferenciamento = Convert.ToDouble(txtOffsetRef.Text); + CheckBox chbUsar = FuncoesGlobais.FindControlRecursive(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar"); Modulo.DirMotor.UsarAnguloSensor = chbUsar.Checked; } diff --git a/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs b/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs index cb3fc7195..66e99f9cb 100644 --- a/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs +++ b/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs @@ -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(); 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()) + { + bico?.AtualizarEstadoTrecho(false); + } + } + _ultimoTickMetricas = agoraTicks; + _tempoMovimentoAnteriorSeg = tempoMovimentoAtualSeg; + _bicosAtivosIntervaloAnterior = new HashSet(); + _bombasAtivasIntervaloAnterior = new HashSet(); + _potenciaBombasIntervaloAnterior = new Dictionary(); + _vazaoIntervaloAnteriorMLs = 0.0; + ZerarVazoesInstantaneas(); + } + private void IntegrarIntervaloAnterior(double dt, double tempoMovimentoIntervaloSeg) { var bicosDoIntervalo = (BicosPulverizadores ?? diff --git a/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs b/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs index ef1765a16..36df2de18 100644 --- a/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs +++ b/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs @@ -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 ConfigSentidos { get; set; } public List 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++; diff --git a/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs b/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs index e805a380d..2a948e5a7 100644 --- a/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs +++ b/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs @@ -494,6 +494,8 @@ namespace AgroBase.Models.Modules modulo.DirMotor.EnviarComandoControle(controleDir.UltimaDirecao); enviouControle = true; } + + modulo.DirMotor.AtualizarSaudeSeguimentoAngular(agora); } if (enviouControle) diff --git a/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs b/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs index f391e4058..2731aa3c0 100644 --- a/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs +++ b/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs @@ -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() diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs index 520175a28..fac9045db 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs @@ -4094,6 +4094,21 @@ namespace AgroBase.Models op.TempoIniciarOperacao = Math.Max(0, Convert.ToInt32(Math.Round(tempoAguardarInicio))); + + JObject emergenciaSistema = RedisService.GetField(CtxKey.DadosOperacao, "emergencia_sistema"); + bool emergenciaSistemicaSolicitada = emergenciaSistema?["solicitada"]?.Value() ?? RedisService.GetField(CtxKey.DadosOperacao, "emergencia_sistema_solicitada", false); + + bool emergenciaAnterior = Operacao.Emergencia; + bool emergencia = emergenciaAnterior || emergenciaSistemicaSolicitada; + + if (emergenciaSistemicaSolicitada && !emergenciaAnterior) + { + string codigo = emergenciaSistema?["codigo"]?.Value() ?? "INTERTRAVAMENTO_SISTEMA"; + string motivo = emergenciaSistema?["motivo"]?.Value() ?? "Intertravamento sistêmico solicitado"; + int severidade = emergenciaSistema?["severidade"]?.Value() ?? 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) 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 Modulos { get; set; } = new List(); @@ -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; } diff --git a/AgroBase/AgroBase/Services/MKS057DCanService.cs b/AgroBase/AgroBase/Services/MKS057DCanService.cs index 85f04006c..e3e1e410c 100644 --- a/AgroBase/AgroBase/Services/MKS057DCanService.cs +++ b/AgroBase/AgroBase/Services/MKS057DCanService.cs @@ -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) diff --git a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs index 73e050a6c..47dc5e5db 100644 --- a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs +++ b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs @@ -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}"); diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py index 0eb7e8719..a91ddb836 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py @@ -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, } diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py index 3cb60e76c..d38e6c6e1 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_client.py @@ -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. """ diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py index 4544dcebf..a2f75e506 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_manager.py @@ -9,28 +9,63 @@ import cv2 import depthai as dai import numpy as np +from itertools import product + + +OAK_FCC3_MANAGER_VERSION = "production_v1_2026_08_24" + class OakFcc3Manager: """ - Manager OAK-FFC-3 com dois fluxos principais: + Hardware manager de produção para OAK-FFC-3 multiespectral. - 1) RAW_BRUTO - - Mantém o comportamento antigo. - - CAM_A/CAM_B/CAM_C enviam RAW10 packed direto para o PC. - - O PC faz decode, flat/radiometric, homografia/fusão/crop/resize. + Contrato oficial: + CAM_A = RGB = OV9782 1280x800 OU AR0234 1920x1200 + CAM_B = RE = OV9282 1280x800 + CAM_C = NIR = OV9282 1280x800 - 2) MULTISPEC - - Câmeras sempre em 800p nativo. - - OAK aplica homografia/crop/resize via ImageManip. - - PC recebe frames já alinhados: - CAM_A/rgb -> BGR uint8 - CAM_B/re -> GRAY uint8 - CAM_C/nir -> GRAY uint8 - - O Client deve montar o tensor sem reaplicar homografia. + Caminho oficial de produto: + RAW_BRUTO -> RAW10 packed nativo por câmera -> RawProcessorCore. + + O Manager não calibra imagem. Ele abre/valida hardware, aplica a política + inicial da câmera, sincroniza RAW e publica metadata fiel por câmera. + + PREVIEW/MULTISPEC permanecem apenas para compatibilidade legada. + Um module_params produzido pelo assembler oficial exige RAW_BRUTO. """ - SENSOR_W = 1280 - SENSOR_H = 800 + PRODUCT_SCHEMA = "multispec_module_params_v3" + ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1" + + LEGACY_SENSOR_W = 1280 + LEGACY_SENSOR_H = 800 + + PRODUCT_TOPOLOGY = { + "rgb": {"socket": "CAM_A", "allowed_sensors": ("OV9782", "AR0234")}, + "re": {"socket": "CAM_B", "allowed_sensors": ("OV9282",)}, + "nir": {"socket": "CAM_C", "allowed_sensors": ("OV9282",)}, + } + + SENSOR_PROFILES = { + "OV9782": { + "kind": "color", + "resolution_name": "THE_800_P", + "width": 1280, + "height": 800, + }, + "AR0234": { + "kind": "color", + "resolution_name": "THE_1200_P", + "width": 1920, + "height": 1200, + }, + "OV9282": { + "kind": "mono", + "resolution_name": "THE_800_P", + "width": 1280, + "height": 800, + }, + } def __init__( self, @@ -43,42 +78,74 @@ class OakFcc3Manager: raw_policy="allow_single", roles=None, sync_mode="best", + hardware_sync_enabled=True, + frame_sync_master="CAM_A", sync_tolerance_ms=12.0, buffer_size=8, only_camera=None, mx_id=None, module_calibration_json=None, module_params=None, + require_product_contract=False, imu_modo="rotation_vector", imu_freq_hz=200, ): - self.fps = fps + self.hardware_sync_enabled = bool(hardware_sync_enabled) + self.frame_sync_master = str(frame_sync_master).upper() + self.fps = float(fps) - # Para compatibilidade, mantemos width/height. - # No RAW_BRUTO isso não muda o sensor, pois usamos 800p fixo. - # No MULTISPEC isso representa a saída final alinhada da OAK. + # width/height são apenas saída LEGADA. RAW_BRUTO usa resolução nativa. self.width = int(width) self.height = int(height) self.size = (self.width, self.height) - self.sensor_width = self.SENSOR_W - self.sensor_height = self.SENSOR_H - self.frame_type = str(frame_type).upper() self.output_dtype = output_dtype - self.capture_mode = capture_mode - self.raw_policy = raw_policy + self.capture_mode = str(capture_mode).upper() + self.raw_policy = str(raw_policy).lower() self.only_camera = only_camera - self.roles = roles or { - "CAM_A": "rgb", - "CAM_B": "re", - "CAM_C": "nir", - } + self.module_calibration_json = module_calibration_json + self.module_params = ( + copy.deepcopy(module_params) + if isinstance(module_params, dict) + else self._load_module_params(module_calibration_json) + ) + self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {} + self.require_product_contract = bool(require_product_contract) - self.sync_mode = sync_mode - self.sync_tolerance_ms = sync_tolerance_ms - self.buffer_size = buffer_size + self.product_contract = self._is_product_module_params() + self.expected_device_mx_id = self._expected_device_mx_id() + + if self.product_contract: + self.roles = { + contract["socket"]: role + for role, contract in self.PRODUCT_TOPOLOGY.items() + } + else: + self.roles = roles or { + "CAM_A": "rgb", + "CAM_B": "re", + "CAM_C": "nir", + } + + self.expected_camera_hardware = self._resolve_expected_camera_hardware() + self.sensor_size_by_role = self._resolve_sensor_size_by_role() + + rgb_size = self.sensor_size_by_role.get( + "rgb", + [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], + ) + self.sensor_width = int(rgb_size[0]) + self.sensor_height = int(rgb_size[1]) + + self.bayer_pattern = str( + (self.module_params or {}).get("bayer_pattern", "BGGR") + ).upper() + + self.sync_mode = str(sync_mode).lower() + self.sync_tolerance_ms = float(sync_tolerance_ms) + self.buffer_size = int(buffer_size) self.mx_id = str(mx_id) if mx_id else None self.dev_info = None @@ -90,14 +157,12 @@ class OakFcc3Manager: self.imu_modo = self._validate_imu_modo(imu_modo) self.imu_freq_hz = int(imu_freq_hz) - if self.imu_freq_hz <= 0: raise ValueError( f"imu_freq_hz deve ser maior que zero: {self.imu_freq_hz}" ) self.imu_sensor_type = None - self.has_imu_pipeline = False self.tem_imu = False self.q_imu = None @@ -105,20 +170,18 @@ class OakFcc3Manager: self.running = False self.frame_id = 0 - # Serializa start/stop e leituras nativas de telemetria. - # Evita getChipTemperature/getUsbSpeed concorrendo com device.close(). self._device_lock = threading.RLock() self.control_queues = {} + self._last_raw_dims = {} + self.aligned_geometry = None + + # Startup Profile entra ANTES do pipeline existir. self.camera_controls = { cam_id: self._default_controls_for_role(role) for cam_id, role in self.roles.items() } - self._last_raw_dims = {} - - self.module_calibration_json = module_calibration_json - self.module_params = module_params if isinstance(module_params, dict) else self._load_module_params(module_calibration_json) - self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {} - self.aligned_geometry = None + self._hydrate_camera_controls_from_module_params() + self._validate_static_product_contract() self.async_capture_enabled = True self.async_capture_mode = "latest" # latest | queue @@ -165,11 +228,327 @@ class OakFcc3Manager: # ============================================================ def _load_module_params(self, path): - if not path or not os.path.isfile(path): + if not path: return {} + if not os.path.isfile(path): + raise FileNotFoundError( + f"module_params não encontrado: {path}" + ) + 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 + + def _is_product_module_params(self): + mp = self.module_params or {} + assembly = mp.get("assembly_metadata", {}) or {} + + return bool( + mp.get("schema") == self.PRODUCT_SCHEMA + and assembly.get("schema") == self.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) + ) + + def _expected_device_mx_id(self): + prov = (self.module_params or {}).get("calibration_provenance", {}) or {} + value = prov.get("device_mx_id") + return str(value) if value else None + + def _normalize_size(self, 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_expected_camera_hardware(self): + if not self.product_contract: + return {} + + raw = (self.module_params or {}).get("camera_hardware", {}) or {} + out = {} + + for role, contract in self.PRODUCT_TOPOLOGY.items(): + item = raw.get(role) + + if not isinstance(item, dict): + raise RuntimeError( + f"module_params.camera_hardware sem role={role}" + ) + + socket = str(item.get("socket") or "").upper() + sensor = str(item.get("sensor") or "").upper() + size = self._normalize_size( + item.get("size"), + label=f"camera_hardware.{role}.size", + ) + + if socket != contract["socket"]: + raise RuntimeError( + f"camera_hardware.{role}.socket={socket!r}, " + f"esperado={contract['socket']!r}" + ) + + if sensor not in contract["allowed_sensors"]: + raise RuntimeError( + f"camera_hardware.{role}.sensor={sensor!r}, " + f"permitidos={contract['allowed_sensors']}" + ) + + profile = self.SENSOR_PROFILES.get(sensor) + + if profile is None: + raise RuntimeError( + f"Sensor sem profile de runtime: {sensor}" + ) + + native = [int(profile["width"]), int(profile["height"])] + + if size != native: + raise RuntimeError( + f"camera_hardware.{role}.size={size} " + f"não corresponde ao nativo de {sensor}: {native}" + ) + + out[role] = { + "role": role, + "socket": socket, + "sensor": sensor, + "size": size, + "kind": profile["kind"], + "resolution_name": profile["resolution_name"], + } + + return out + + def _resolve_sensor_size_by_role(self): + if self.product_contract: + raw = (self.module_params or {}).get("sensor_size_by_role", {}) or {} + out = {} + + for role in ("rgb", "re", "nir"): + size = self._normalize_size( + raw.get(role), + label=f"sensor_size_by_role.{role}", + ) + expected = self.expected_camera_hardware[role]["size"] + + if size != expected: + raise RuntimeError( + f"sensor_size_by_role.{role}={size} != " + f"camera_hardware={expected}" + ) + + out[role] = size + + return out + + return { + "rgb": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], + "re": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], + "nir": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H], + } + + def _validate_static_product_contract(self): + if self.require_product_contract and not self.product_contract: + raise RuntimeError( + "Contrato de produção obrigatório, mas o module_params " + "não foi gerado pelo assembler oficial." + ) + + if not self.product_contract: + return + + if self.frame_type != "RAW_BRUTO": + raise RuntimeError( + "module_params de produção aceita somente frame_type=RAW_BRUTO. " + f"Recebido={self.frame_type!r}" + ) + + if self.capture_mode not in ("TRIPLE", "AUTO"): + raise RuntimeError( + "Produto multiespectral exige capture_mode TRIPLE " + f"(ou AUTO com require_triple). Recebido={self.capture_mode!r}" + ) + + if self.raw_policy != "require_triple": + raise RuntimeError( + "module_params de produção exige raw_policy='require_triple'. " + f"Recebido={self.raw_policy!r}" + ) + + if self.only_camera is not None: + raise RuntimeError( + "only_camera não é permitido no contrato de produção." + ) + + if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"): + raise RuntimeError( + f"bayer_pattern inválido no module_params: {self.bayer_pattern!r}" + ) + + if ( + self.mx_id is not None + and self.expected_device_mx_id is not None + and self.mx_id != self.expected_device_mx_id + ): + raise RuntimeError( + "MX ID solicitado diverge da calibração homologada: " + f"requested={self.mx_id}, calibrated={self.expected_device_mx_id}" + ) + + def _role_to_cam_id(self, role): + role = str(role).lower() + + for cam_id, mapped_role in self.roles.items(): + if str(mapped_role).lower() == role: + return cam_id + + return None + + def _hydrate_camera_controls_from_module_params(self): + settings = (self.module_params or {}).get("camera_settings", {}) or {} + + if not isinstance(settings, dict): + if self.product_contract: + raise RuntimeError( + "module_params de produção sem camera_settings." + ) + return + + for role, cfg in settings.items(): + if not isinstance(cfg, dict): + continue + + cam_id = self._role_to_cam_id(role) + + if cam_id is None: + continue + + state = self.camera_controls.setdefault( + cam_id, + self._default_controls_for_role(role), + ) + + for key in ( + "ae_enable", + "awb_enable", + "exposure_time_us", + "analogue_gain", + "colour_gains", + ): + if key in cfg: + state[key] = copy.deepcopy(cfg[key]) + + if self.product_contract: + for role in ("rgb", "re", "nir"): + cam_id = self._role_to_cam_id(role) + + if cam_id is None or cam_id not in self.camera_controls: + raise RuntimeError( + f"camera_settings não resolveu role={role}" + ) + + def _sensor_profile(self, sensor_name, role=None): + sensor = str(sensor_name or "").upper() + profile = self.SENSOR_PROFILES.get(sensor) + + if profile is None: + raise RuntimeError( + f"Sensor não suportado pelo runtime: {sensor!r}" + ) + + if role is not None: + role = str(role).lower() + expected_kind = "color" if role == "rgb" else "mono" + + if profile["kind"] != expected_kind: + raise RuntimeError( + f"Sensor {sensor} é {profile['kind']}, " + f"mas role={role} exige {expected_kind}." + ) + + return profile + + def _feature_rows(self, features): + rows = [] + + for f in features: + socket = f.socket.name + sensor = str(f.sensorName or "").upper() + + rows.append({ + "socket": socket, + "sensor": sensor, + "width": int(getattr(f, "width", 0) or 0), + "height": int(getattr(f, "height", 0) or 0), + "role": self.roles.get(socket, "unknown"), + }) + + return rows + + def _validate_connected_hardware(self, features): + rows = self._feature_rows(features) + by_socket = {row["socket"]: row for row in rows} + + if not self.product_contract: + return rows + + if ( + self.expected_device_mx_id is not None + and self.mx_id is not None + and self.mx_id != self.expected_device_mx_id + ): + raise RuntimeError( + "OAK conectada não corresponde ao módulo calibrado: " + f"connected={self.mx_id}, calibrated={self.expected_device_mx_id}" + ) + + errors = [] + + for role, expected in self.expected_camera_hardware.items(): + row = by_socket.get(expected["socket"]) + + if row is None: + errors.append(f"{role}: {expected['socket']} ausente") + continue + + if row["sensor"] != expected["sensor"]: + errors.append( + f"{role}: {expected['socket']} sensor={row['sensor']}, " + f"esperado={expected['sensor']}" + ) + + if row["width"] > 0 and row["height"] > 0: + advertised = [row["width"], row["height"]] + + if advertised != expected["size"]: + errors.append( + f"{role}: {expected['socket']}/{row['sensor']} anunciou " + f"{advertised}, esperado={expected['size']}" + ) + + if errors: + raise RuntimeError( + "Hardware OAK não corresponde ao module_params homologado:\n - " + + "\n - ".join(errors) + ) + + return rows def _default_controls_for_role(self, role: str): role = str(role).lower() @@ -200,12 +579,31 @@ class OakFcc3Manager: with dai.Device(dev_info) as dev: result = [] - for f in dev.getConnectedCameraFeatures(): + features = dev.getConnectedCameraFeatures() + + for row in self._feature_rows(features): + role = str(row.get("role", "unknown")).lower() + expected = self.expected_camera_hardware.get(role) + result.append({ - "socket": f.socket.name, - "sensor": f.sensorName, - "role": self.roles.get(f.socket.name, "unknown"), + **row, + "expected": copy.deepcopy(expected), + "matches_product_contract": ( + None + if not self.product_contract + else bool( + expected is not None + and row["socket"] == expected["socket"] + and row["sensor"] == expected["sensor"] + and ( + row["width"] <= 0 + or row["height"] <= 0 + or [row["width"], row["height"]] == expected["size"] + ) + ) + ), }) + return result def _device_id_from_info(self, dev_info): @@ -279,35 +677,48 @@ class OakFcc3Manager: def _create_camera_node_classic(self, socket, sensor_name: str, role: str): """ - Fluxo clássico. + RAW/PREVIEW sensor-aware. - RGB/OV9782: - ColorCamera raw para RAW_BRUTO. + Produto: + OV9782 -> ColorCamera THE_800_P 1280x800 + AR0234 -> ColorCamera THE_1200_P 1920x1200 + OV9282 -> MonoCamera THE_800_P 1280x800 - MONO/OV9282: - MonoCamera raw quando disponível. + No produto não existe fallback silencioso de resolução. """ - sensor_name_u = str(sensor_name or "").upper() - role_u = str(role or "").lower() + sensor = str(sensor_name or "").upper() + role = str(role or "").lower() - is_rgb = ( - role_u == "rgb" - or "OV9782" in sensor_name_u - or socket == dai.CameraBoardSocket.CAM_A - ) + if self.product_contract: + profile = self._sensor_profile(sensor, role=role) + else: + profile = self.SENSOR_PROFILES.get(sensor) - if is_rgb: + if profile is None: + profile = { + "kind": "color" if role == "rgb" else "mono", + "resolution_name": "THE_800_P", + "width": self.LEGACY_SENSOR_W, + "height": self.LEGACY_SENSOR_H, + } + + if profile["kind"] == "color": cam = self.pipeline.createColorCamera() cam.setBoardSocket(socket) - try: - cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_800_P) - except Exception: - try: - cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_1080_P) - except Exception: - pass + resolution = getattr( + dai.ColorCameraProperties.SensorResolution, + profile["resolution_name"], + None, + ) + if resolution is None: + raise RuntimeError( + f"DepthAI não expõe ColorCamera " + f"{profile['resolution_name']} para {sensor}." + ) + + cam.setResolution(resolution) cam.setInterleaved(False) cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.RGB) cam.setFps(float(self.fps)) @@ -319,31 +730,42 @@ class OakFcc3Manager: except Exception: return cam, cam.preview + if not hasattr(cam, "raw"): + raise RuntimeError( + f"ColorCamera {sensor} não expõe raw output." + ) + return cam, cam.raw mono = self.pipeline.create(dai.node.MonoCamera) mono.setBoardSocket(socket) - try: - mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_800_P) - except Exception: - try: - mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_720_P) - except Exception: - try: - mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P) - except Exception: - pass + resolution = getattr( + dai.MonoCameraProperties.SensorResolution, + profile["resolution_name"], + None, + ) + if resolution is None: + raise RuntimeError( + f"DepthAI não expõe MonoCamera " + f"{profile['resolution_name']} para {sensor}." + ) + + mono.setResolution(resolution) mono.setFps(float(self.fps)) if self._is_preview_mode(): return mono, mono.out - if hasattr(mono, "raw"): - return mono, mono.raw + if not hasattr(mono, "raw"): + if self.product_contract: + raise RuntimeError( + f"MonoCamera {sensor} não expõe raw output." + ) + return mono, mono.out - return mono, mono.out + return mono, mono.raw def _create_imu_node(self, pipeline): self.has_imu_pipeline = False @@ -432,6 +854,49 @@ class OakFcc3Manager: # Pipeline creation, MULTISPEC aligned on OAK # ============================================================ + def _aplicar_frame_sync(self, cam, cam_id): + cam_id_normalizado = str(cam_id).strip().upper() + master = str(self.frame_sync_master).strip().upper() + + if not self.hardware_sync_enabled: + print( + f"[OAK FSYNC] cam={cam_id_normalizado} " + f"habilitado=False modo=DISABLED" + ) + return + + cameras_validas = {"CAM_A", "CAM_B", "CAM_C"} + + if master not in cameras_validas: + raise ValueError( + f"frame_sync_master inválido: {self.frame_sync_master}. " + f"Esperado: CAM_A, CAM_B ou CAM_C." + ) + + if cam_id_normalizado not in cameras_validas: + print( + f"[OAK FSYNC] Câmera ignorada: " + f"cam_id={cam_id_normalizado}" + ) + return + + if cam_id_normalizado == master: + modo = dai.CameraControl.FrameSyncMode.OUTPUT + nome_modo = "OUTPUT" + else: + modo = dai.CameraControl.FrameSyncMode.INPUT + nome_modo = "INPUT" + + cam.initialControl.setFrameSyncMode(modo) + + print( + f"[OAK FSYNC] " + f"cam={cam_id_normalizado} " + f"master={master} " + f"habilitado=True " + f"modo={nome_modo}" + ) + def _create_color_camera_multispec(self, socket): cam = self.pipeline.create(dai.node.ColorCamera) cam.setBoardSocket(socket) @@ -591,6 +1056,7 @@ class OakFcc3Manager: raise RuntimeError(f"Role não suportada no MULTISPEC: cam_id={cam_id}, role={role}") self.apply_initial_camera_controls_to_node(cam, cam_id) + self._aplicar_frame_sync(cam, cam_id) xin_ctrl = self.pipeline.create(dai.node.XLinkIn) xin_ctrl.setStreamName(f"{cam_id}_ctrl") @@ -840,6 +1306,13 @@ class OakFcc3Manager: self.pipeline = dai.Pipeline() features = self.device.getConnectedCameraFeatures() + self._validate_connected_hardware(features) + + if self.product_contract and self._is_multispec_mode(): + raise RuntimeError( + "MULTISPEC alinhado na OAK é legado e não faz parte do " + "contrato de produção. Use RAW_BRUTO." + ) self.queues.clear() self.buffers.clear() @@ -871,6 +1344,7 @@ class OakFcc3Manager: ) self.apply_initial_camera_controls_to_node(cam, socket_name) + self._aplicar_frame_sync(cam, socket_name) xin_ctrl = self.pipeline.create(dai.node.XLinkIn) xin_ctrl.setStreamName(f"{socket_name}_ctrl") @@ -886,11 +1360,44 @@ class OakFcc3Manager: self.buffers[cam_id] = deque(maxlen=self.buffer_size) self.control_queues[cam_id] = None + sensor_u = str(f.sensorName or "").upper() + if self.product_contract or sensor_u in self.SENSOR_PROFILES: + profile = self._sensor_profile(sensor_u, role=role) + else: + profile = { + "kind": "unknown", + "width": int(getattr(f, "width", 0) or self.LEGACY_SENSOR_W), + "height": int(getattr(f, "height", 0) or self.LEGACY_SENSOR_H), + "resolution_name": None, + } + self.camera_info[cam_id] = { "id": cam_id, "socket": socket_name, - "sensor": f.sensorName, + "sensor": sensor_u, "role": role, + "native_width": int(profile["width"]), + "native_height": int(profile["height"]), + "native_size": [ + int(profile["width"]), + int(profile["height"]), + ], + "sensor_kind": profile["kind"], + "resolution_mode": profile.get("resolution_name"), + "bayer_pattern": ( + self.bayer_pattern + if str(role).lower() == "rgb" + else None + ), + "product_expected": ( + copy.deepcopy( + self.expected_camera_hardware.get( + str(role).lower() + ) + ) + if self.product_contract + else None + ), } self._validate_capture_mode() @@ -1092,6 +1599,14 @@ class OakFcc3Manager: "height": self.height, "sensor_width": self.sensor_width, "sensor_height": self.sensor_height, + "sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role), + "camera_hardware_expected": copy.deepcopy(self.expected_camera_hardware), + "bayer_pattern": self.bayer_pattern, + "product_contract": bool(self.product_contract), + "require_product_contract": bool(self.require_product_contract), + "manager_version": OAK_FCC3_MANAGER_VERSION, + "module_params_schema": (self.module_params or {}).get("schema"), + "module_calibration_json": self.module_calibration_json, "frame_type": self.frame_type, "output_dtype": self.output_dtype, "capture_mode": self.capture_mode, @@ -1241,6 +1756,41 @@ class OakFcc3Manager: f"Tolerância atual={self.sync_tolerance_ms} ms." ) + def _encontrar_melhor_tripleta(self, required_cam_ids): + listas = [ + list(self.buffers[cam_id]) + for cam_id in required_cam_ids + ] + + melhor_selecao = None + melhor_score = None + + for combinacao in product(*listas): + timestamps = [ + item["timestamp"] + for item in combinacao + ] + + menor_ts = min(timestamps) + maior_ts = max(timestamps) + spread_ms = (maior_ts - menor_ts) * 1000.0 + + # Primeiro prioriza menor dispersão. + # Em empate, prefere o pacote mais recente. + score = ( + spread_ms, + -menor_ts, + ) + + if melhor_score is None or score < melhor_score: + melhor_score = score + melhor_selecao = { + cam_id: item + for cam_id, item in zip(required_cam_ids, combinacao) + } + + return melhor_selecao + def _extract_frame_controls(self, msg): controls = { @@ -1330,9 +1880,19 @@ class OakFcc3Manager: t0_ts = self._cap_now_ms() try: - ts = msg.getTimestamp().total_seconds() + ts_start = msg.getTimestampDevice( + dai.CameraExposureOffset.START + ).total_seconds() + + ts_end = msg.getTimestampDevice( + dai.CameraExposureOffset.END + ).total_seconds() + except Exception: - ts = time.time() + ts_start = msg.getTimestamp().total_seconds() + ts_end = ts_start + + ts = ts_start if perf is not None: perf["drain_get_timestamp_ms"] += self._cap_now_ms() - t0_ts @@ -1395,7 +1955,9 @@ class OakFcc3Manager: self.buffers[cam_id].append({ "frame": frame, - "timestamp": ts, + "timestamp": ts_start, + "timestamp_start": ts_start, + "timestamp_end": ts_end, "controls": frame_controls, }) @@ -1420,34 +1982,25 @@ class OakFcc3Manager: def _try_get_synced_packet(self, perf=None): required_cam_ids = self._get_required_cam_ids() + if not required_cam_ids: + if perf is not None: + perf["wait_reason"] = "no_required_cameras" + return None + + # Todas as câmeras precisam ter pelo menos um frame disponível. for cam_id in required_cam_ids: if cam_id not in self.buffers or len(self.buffers[cam_id]) == 0: if perf is not None: perf["wait_reason"] = f"empty_buffer:{cam_id}" return None - ref_cam_id = min(required_cam_ids, key=lambda cid: len(self.buffers[cid])) - ref_item = self.buffers[ref_cam_id][0] - ref_ts = ref_item["timestamp"] + # Procura a melhor combinação entre todos os frames disponíveis. + selected = self._encontrar_melhor_tripleta(required_cam_ids) - selected = {} - - for cam_id in required_cam_ids: - best_item = None - best_dt = None - - for item in self.buffers[cam_id]: - dt = abs(item["timestamp"] - ref_ts) - if best_dt is None or dt < best_dt: - best_dt = dt - best_item = item - - if best_item is None: - if perf is not None: - perf["wait_reason"] = f"no_best_item:{cam_id}" - return None - - selected[cam_id] = best_item + if not selected or len(selected) != len(required_cam_ids): + if perf is not None: + perf["wait_reason"] = "no_valid_selection" + return None timestamps = { cam_id: item["timestamp"] @@ -1459,44 +2012,80 @@ class OakFcc3Manager: for cam_id, item in selected.items() } + ts_values = list(timestamps.values()) + + sync_dt_ms = ( + (max(ts_values) - min(ts_values)) * 1000.0 + if len(ts_values) >= 2 + else 0.0 + ) + + sync_ok = sync_dt_ms <= self.sync_tolerance_ms + if perf is not None: - perf["selected_ts_by_cam"] = {cam_id: float(ts) for cam_id, ts in timestamps.items()} + perf["selected_ts_by_cam"] = { + cam_id: float(ts) + for cam_id, ts in timestamps.items() + } + perf["selected_seq_by_cam"] = { cam_id: item.get("controls", {}).get("sequence_num") for cam_id, item in selected.items() } - ts_values = list(timestamps.values()) - sync_dt_ms = (max(ts_values) - min(ts_values)) * 1000.0 if len(ts_values) >= 2 else 0.0 - sync_ok = sync_dt_ms <= self.sync_tolerance_ms + perf["sync_dt_ms"] = float(sync_dt_ms) + perf["sync_ok"] = bool(sync_ok) + + modo_sync = str(self.sync_mode).strip().lower() + + # No modo estrito, nunca entrega uma tripleta fora da tolerância. + if not sync_ok and modo_sync == "strict": + #Remove o frame globalmente mais antigo entre as cabeças + #dos buffers. Frames futuros somente estarão mais distantes + #desse frame, então ele não conseguirá formar uma combinação + #melhor posteriormente. + oldest_cam_id = min( + required_cam_ids, + key=lambda cam_id: self.buffers[cam_id][0]["timestamp"] + ) + + dropped_item = self.buffers[oldest_cam_id].popleft() - if not sync_ok and self.sync_mode == "strict": - oldest_cam_id = min(timestamps, key=timestamps.get) - if len(self.buffers[oldest_cam_id]) > 0: - self.buffers[oldest_cam_id].popleft() if perf is not None: perf["wait_reason"] = f"strict_drop_oldest:{oldest_cam_id}" - perf["sync_dt_ms"] = float(sync_dt_ms) - perf["sync_ok"] = False + perf["dropped_timestamp"] = float(dropped_item["timestamp"]) + return None + # Em best/best_effort, entrega a melhor combinação disponível, + # mesmo quando estiver fora da tolerância. frames = { cam_id: item["frame"] for cam_id, item in selected.items() } + # Consome todos os frames anteriores e o próprio frame selecionado. for cam_id, used_item in selected.items(): - while len(self.buffers[cam_id]) > 0: + while self.buffers[cam_id]: item = self.buffers[cam_id].popleft() + if item is used_item: break if perf is not None: - perf["wait_reason"] = "synced_selected" - perf["sync_dt_ms"] = float(sync_dt_ms) - perf["sync_ok"] = bool(sync_ok) + perf["wait_reason"] = ( + "synced_selected" + if sync_ok + else "best_effort_selected_outside_tolerance" + ) - return frames, timestamps, sync_dt_ms, sync_ok, frame_controls + return ( + frames, + timestamps, + sync_dt_ms, + sync_ok, + frame_controls, + ) def _get_available_cam_ids_ordered(self): role_order = ["rgb", "re", "nir"] @@ -1595,6 +2184,32 @@ class OakFcc3Manager: item["shape"] = list(arr.shape) item["dtype"] = str(arr.dtype) item["packed"] = True + item["native_width"] = int( + item.get("native_width", item["width"]) + ) + item["native_height"] = int( + item.get("native_height", item["height"]) + ) + item["native_size"] = [ + item["native_width"], + item["native_height"], + ] + + if str(item.get("role", "")).lower() == "rgb": + item["bayer_pattern"] = self.bayer_pattern + + if self.product_contract: + role = str(item.get("role", "")).lower() + expected = self.expected_camera_hardware.get(role) + + if expected is not None: + actual = [int(item["width"]), int(item["height"])] + + if actual != expected["size"]: + raise RuntimeError( + f"{cam_id}/{role}: RAW frame {actual} != " + f"hardware homologado {expected['size']}" + ) camera_info[cam_id] = item @@ -1602,6 +2217,18 @@ class OakFcc3Manager: "frame_id": self.frame_id, "backend": "oak_fcc3", "frame_type": self.frame_type, + "product_contract": bool(self.product_contract), + "module_params_schema": (self.module_params or {}).get("schema"), + "module_calibration_json": self.module_calibration_json, + "device_mx_id": self.mx_id, + "reference_camera": "rgb", + "sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role), + "camera_hardware": copy.deepcopy( + self.expected_camera_hardware + if self.product_contract + else {} + ), + "bayer_pattern": self.bayer_pattern, "capture_mode": self.capture_mode, "output_dtype": self.output_dtype, "dtype": self.output_dtype, @@ -1790,18 +2417,65 @@ class OakFcc3Manager: return result def apply_initial_camera_controls_to_node(self, cam, cam_id): + """ + Aplica Startup Profile antes de startPipeline(). + + O Client pode reaplicar depois do start como confirmação, mas os + primeiros frames já nascem sob a política homologada. + """ ctrl_state = self.camera_controls.get(cam_id, {}) ae = bool(ctrl_state.get("ae_enable", False)) + awb = bool(ctrl_state.get("awb_enable", False)) exp_us = int(ctrl_state.get("exposure_time_us") or 15000) gain = float(ctrl_state.get("analogue_gain") or 1.0) - if not ae: + if ae: + fn = getattr(cam.initialControl, "setAutoExposureEnable", None) + + if callable(fn): + fn() + elif self.product_contract: + raise RuntimeError( + f"{cam_id}: DepthAI sem setAutoExposureEnable no initialControl." + ) + else: cam.initialControl.setManualExposure( exp_us, self._gain_to_iso(gain), ) + role = str(self.roles.get(cam_id, "")).lower() + + if role == "rgb": + if awb: + fn = getattr( + cam.initialControl, + "setAutoWhiteBalanceLock", + None, + ) + + if callable(fn): + fn(False) + else: + try: + cam.initialControl.setAutoWhiteBalanceMode( + dai.CameraControl.AutoWhiteBalanceMode.AUTO + ) + except Exception: + if self.product_contract: + raise RuntimeError( + f"{cam_id}: não foi possível habilitar AWB inicial." + ) + else: + fn = getattr( + cam.initialControl, + "setAutoWhiteBalanceLock", + None, + ) + if callable(fn): + fn(True) + def _send_control(self, cam_id, ctrl): if cam_id not in self.control_queues: raise RuntimeError(f"Fila de controle não existe para {cam_id}") diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py index 29e511bac..371cc4dc4 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/oak_fcc3_service.py @@ -1,29 +1,157 @@ +import copy import time + from .oak_fcc3_manager import OakFcc3Manager +OAK_FCC3_SERVICE_VERSION = "production_v1_2026_08_24" + + class OakFcc3Service: + """ + Camada de serviço do OakFcc3Manager. + + Responsabilidades: + - ciclo de vida connect/begin/stop/disconnect; + - captura de frame; + - controles de câmera; + - exposição de status/config; + - impedir que APIs legadas alterem o contrato físico de produto. + + Importante: + Em module_params homologado, resolução/sensor/Bayer são propriedades + calibradas do módulo e NÃO podem ser alteradas via Service. + + width/height continuam visíveis somente para compatibilidade e representam + a saída legada do Manager, não o raster nativo das três câmeras. + """ + def __init__(self, timeout=10, **kwargs): - self.timeout = timeout + self.timeout = float(timeout) self.manager = OakFcc3Manager(**kwargs) self.connected = False + # ============================================================ + # Contrato + # ============================================================ + + @property + def product_contract(self): + return bool( + getattr( + self.manager, + "product_contract", + False, + ) + ) + + @property + def require_product_contract(self): + return bool( + getattr( + self.manager, + "require_product_contract", + False, + ) + ) + + def get_contract(self): + """ + Snapshot serializável do contrato físico/runtime conhecido pelo Service. + """ + status = self.manager.get_status() + + return { + "service_version": OAK_FCC3_SERVICE_VERSION, + "manager_version": status.get("manager_version"), + "product_contract": bool( + status.get( + "product_contract", + self.product_contract, + ) + ), + "require_product_contract": bool( + status.get( + "require_product_contract", + self.require_product_contract, + ) + ), + "module_params_schema": status.get( + "module_params_schema" + ), + "module_calibration_json": status.get( + "module_calibration_json" + ), + "mx_id": status.get( + "mx_id" + ), + "sensor_size_by_role": copy.deepcopy( + status.get( + "sensor_size_by_role", + {}, + ) + or {} + ), + "camera_hardware": copy.deepcopy( + status.get( + "camera_hardware_expected", + {}, + ) + or {} + ), + "bayer_pattern": status.get( + "bayer_pattern" + ), + "frame_type": status.get( + "frame_type" + ), + "raw_policy": status.get( + "raw_policy" + ), + "geometry_stage": status.get( + "geometry_stage" + ), + } + + # ============================================================ + # Ciclo de vida + # ============================================================ + def connect(self): + """ + Marca a camada de serviço como conectada. + + O dispositivo físico é aberto pelo manager.start() dentro de begin(). + Isso preserva a API existente sem fingir que connect() já abriu USB. + """ self.connected = True - return {"ok": True, "backend": "oak_fcc3", "connected": True} + + return { + "ok": True, + "backend": "oak_fcc3", + "connected": True, + "service_version": OAK_FCC3_SERVICE_VERSION, + "product_contract": self.product_contract, + } def disconnect(self): + """ + Único fechamento público completo da camada Service. + """ stopped = False try: - stopped = bool(self.manager.stop()) + stopped = bool( + self.manager.stop() + ) finally: self.connected = False return { - "ok": stopped, + "ok": bool(stopped), "connected": False, - "stopped": stopped, + "stopped": bool(stopped), + "service_version": OAK_FCC3_SERVICE_VERSION, } def ping(self): @@ -32,86 +160,26 @@ class OakFcc3Service: "backend": "oak_fcc3", "msg": "pong", "ts": time.time(), + "connected": bool( + self.connected + ), + "running": bool( + getattr( + self.manager, + "running", + False, + ) + ), + "service_version": OAK_FCC3_SERVICE_VERSION, + "product_contract": self.product_contract, } - def get_status(self): - status = self.manager.get_status() - active_ids = [c["id"] for c in status.get("cameras", [])] - active_roles = { - c.get("role"): c.get("id") - for c in status.get("cameras", []) - } - - running = bool(status.get("running", False)) - realmente_ativo = bool(self.connected and running) - - status.update({ - "ok": realmente_ativo, - "connected": bool(self.connected), - "running": running, - "active_camera_ids": active_ids, - "active_roles": active_roles, - "camera_count_active": len(active_ids), - }) - - return status - - def get_device_metrics(self): - return self.manager.get_device_metrics() - - def get_config(self): - return { - "mx_id": self.manager.mx_id, - "ok": True, - "fps": self.manager.fps, - "width": self.manager.width, - "height": self.manager.height, - "frame_type": self.manager.frame_type, - "output_dtype": self.manager.output_dtype, - "capture_mode": self.manager.capture_mode, - "raw_policy": self.manager.raw_policy, - "sync_mode": getattr(self.manager, "sync_mode", "best"), - "sync_tolerance_ms": self.manager.sync_tolerance_ms, - "imu_modo": self.manager.imu_modo, - "imu_freq_hz": self.manager.imu_freq_hz, - "imu_sensor_type": self.manager.imu_sensor_type, - } - - def set_fps(self, fps): - self._ensure_stopped_for_config() - self.manager.fps = int(fps) - return {"ok": True, "fps": self.manager.fps} - - def set_resolution(self, width, height): - self._ensure_stopped_for_config() - self.manager.width = int(width) - self.manager.height = int(height) - self.manager.size = (self.manager.width, self.manager.height) - return { - "ok": True, - "width": self.manager.width, - "height": self.manager.height, - } - - def set_capture_mode(self, mode): - self._ensure_stopped_for_config() - mode = str(mode).upper() - self.manager.capture_mode = self._validate_capture_mode(mode) - return {"ok": True, "capture_mode": self.manager.capture_mode} - - def set_frame_type(self, frame_type): - self._ensure_stopped_for_config() - frame_type = str(frame_type).upper() - self.manager.frame_type = self._validate_frame_type(frame_type) - return {"ok": True, "frame_type": self.manager.frame_type} - - def set_output_dtype(self, dtype): - self._ensure_stopped_for_config() - dtype = str(dtype).lower() - self.manager.output_dtype = self._validate_output_dtype(dtype) - return {"ok": True, "output_dtype": self.manager.output_dtype} - - def begin(self, frame_type=None, output_dtype=None, capture_mode=None): + def begin( + self, + frame_type=None, + output_dtype=None, + capture_mode=None, + ): if not self.connected: self.connect() @@ -124,129 +192,789 @@ class OakFcc3Service: } if frame_type is not None: - self.manager.frame_type = self._validate_frame_type(frame_type) + frame_type = self._validate_frame_type( + frame_type + ) + self._validate_product_frame_type( + frame_type + ) + self.manager.frame_type = frame_type if output_dtype is not None: - self.manager.output_dtype = self._validate_output_dtype(output_dtype) + self.manager.output_dtype = ( + self._validate_output_dtype( + output_dtype + ) + ) if capture_mode is not None: - self.manager.capture_mode = self._validate_capture_mode(capture_mode) + self.manager.capture_mode = ( + self._validate_capture_mode( + capture_mode + ) + ) + + # Mesmo sem override explícito, bloqueia MP produto com modo legado. + self._validate_product_frame_type( + self.manager.frame_type + ) self.manager.start() + status = self.get_status() + + if not status.get( + "ok", + False, + ): + try: + self.manager.stop() + finally: + raise RuntimeError( + "OakFcc3Manager iniciou sem atingir estado operacional: " + f"{status}" + ) + return { "ok": True, "started": True, "already_running": False, - "status": self.get_status(), + "status": status, } - def capture_frame(self, timeout=None): - if timeout is None: - timeout = self.timeout - - frame, meta = self.manager.get_next_frame(timeout=timeout) - - return frame, meta - def stop(self): - stopped = bool(self.manager.stop()) + stopped = bool( + self.manager.stop() + ) + return { "ok": stopped, "stopped": stopped, + "connected": bool( + self.connected + ), } - def _ensure_stopped_for_config(self): + # ============================================================ + # Status/config + # ============================================================ + + def get_status(self): + status = dict( + self.manager.get_status() + ) + + cameras = list( + status.get( + "cameras", + [], + ) + or [] + ) + + active_ids = [ + c.get("id") + for c in cameras + if c.get("id") is not None + ] + + active_roles = { + str( + c.get( + "role", + "", + ) + ).lower(): c.get( + "id" + ) + for c in cameras + if c.get("role") + and c.get("id") is not None + } + + running = bool( + status.get( + "running", + False, + ) + ) + + realmente_ativo = bool( + self.connected + and running + ) + + status.update({ + "ok": realmente_ativo, + "connected": bool( + self.connected + ), + "running": running, + "active_camera_ids": active_ids, + "active_roles": active_roles, + "camera_count_active": len( + active_ids + ), + "service_version": OAK_FCC3_SERVICE_VERSION, + + # Contrato explícito para consumers novos. + "sensor_size_by_role": copy.deepcopy( + status.get( + "sensor_size_by_role", + {}, + ) + or {} + ), + "camera_hardware": copy.deepcopy( + status.get( + "camera_hardware_expected", + {}, + ) + or {} + ), + }) + + return status + + def get_device_metrics(self): + metrics = dict( + self.manager.get_device_metrics() + ) + + metrics[ + "service_version" + ] = OAK_FCC3_SERVICE_VERSION + + metrics[ + "connected" + ] = bool( + self.connected + ) + + return metrics + + def get_config(self): + """ + Configuração exposta ao Client/debug. + + width/height são mantidos por compatibilidade e explicitamente marcados + como LEGACY OUTPUT SIZE. Não descrevem a resolução nativa do módulo. + """ + status = self.manager.get_status() + + return { + "mx_id": self.manager.mx_id, + "ok": True, + "service_version": OAK_FCC3_SERVICE_VERSION, + "manager_version": status.get( + "manager_version" + ), + + "fps": self.manager.fps, + + # Compatibilidade antiga. + "width": self.manager.width, + "height": self.manager.height, + "legacy_output_size": [ + int( + self.manager.width + ), + int( + self.manager.height + ), + ], + "legacy_output_size_authoritative": False, + + # Contrato físico real. + "sensor_width": status.get( + "sensor_width" + ), + "sensor_height": status.get( + "sensor_height" + ), + "sensor_size_by_role": copy.deepcopy( + status.get( + "sensor_size_by_role", + {}, + ) + or {} + ), + "camera_hardware": copy.deepcopy( + status.get( + "camera_hardware_expected", + {}, + ) + or {} + ), + "bayer_pattern": status.get( + "bayer_pattern" + ), + "product_contract": bool( + status.get( + "product_contract", + False, + ) + ), + "require_product_contract": bool( + status.get( + "require_product_contract", + False, + ) + ), + "module_params_schema": status.get( + "module_params_schema" + ), + "module_calibration_json": status.get( + "module_calibration_json" + ), + + "frame_type": self.manager.frame_type, + "output_dtype": self.manager.output_dtype, + "capture_mode": self.manager.capture_mode, + "raw_policy": self.manager.raw_policy, + + "sync_mode": getattr( + self.manager, + "sync_mode", + "best", + ), + "sync_tolerance_ms": self.manager.sync_tolerance_ms, + "hardware_sync_enabled": bool( + getattr( + self.manager, + "hardware_sync_enabled", + False, + ) + ), + "frame_sync_master": getattr( + self.manager, + "frame_sync_master", + None, + ), + + "geometry_stage": status.get( + "geometry_stage" + ), + + "imu_modo": self.manager.imu_modo, + "imu_freq_hz": self.manager.imu_freq_hz, + "imu_sensor_type": self.manager.imu_sensor_type, + } + + # ============================================================ + # Configuração estrutural + # ============================================================ + + def set_fps(self, fps): + self._ensure_stopped_for_config() + + fps = float( + fps + ) + + if fps <= 0: + raise ValueError( + f"fps deve ser > 0: {fps}" + ) + + self.manager.fps = fps + + return { + "ok": True, + "fps": self.manager.fps, + } + + def set_resolution( + self, + width, + height, + ): + """ + API legada. + + Em produto NÃO pode alterar resolução física. O raster nativo faz parte + da calibração/module_params e é selecionado pelo sensor real. + """ + self._ensure_stopped_for_config() + + if self.product_contract: + raise RuntimeError( + "set_resolution() é proibido com module_params de produção. " + "A resolução nativa é definida por camera_hardware/" + "sensor_size_by_role e pelo sensor físico. " + "Para mudar o tensor final use um novo modelo + module_params " + "homologado e reinicialize o pipeline." + ) + + width = int( + width + ) + height = int( + height + ) + + if width <= 0 or height <= 0: + raise ValueError( + f"Resolução inválida: {width}x{height}" + ) + + self.manager.width = width + self.manager.height = height + self.manager.size = ( + width, + height, + ) + + return { + "ok": True, + "width": width, + "height": height, + "legacy_only": True, + } + + def set_capture_mode( + self, + mode, + ): + self._ensure_stopped_for_config() + + mode = self._validate_capture_mode( + mode + ) + + self.manager.capture_mode = mode + + return { + "ok": True, + "capture_mode": mode, + } + + def set_frame_type( + self, + frame_type, + ): + self._ensure_stopped_for_config() + + frame_type = ( + self._validate_frame_type( + frame_type + ) + ) + + self._validate_product_frame_type( + frame_type + ) + + self.manager.frame_type = frame_type + + return { + "ok": True, + "frame_type": frame_type, + } + + def set_output_dtype( + self, + dtype, + ): + self._ensure_stopped_for_config() + + dtype = ( + self._validate_output_dtype( + dtype + ) + ) + + self.manager.output_dtype = dtype + + return { + "ok": True, + "output_dtype": dtype, + } + + # ============================================================ + # Captura + # ============================================================ + + def capture_frame( + self, + timeout=None, + ): + if timeout is None: + timeout = self.timeout + + if not self.connected: + raise RuntimeError( + "OakFcc3Service não está conectado." + ) + + if not self.manager.running: + raise RuntimeError( + "OakFcc3Manager não está rodando. " + "Chame begin() antes de capture_frame()." + ) + + frame, meta = ( + self.manager.get_next_frame( + timeout=float( + timeout + ) + ) + ) + + return frame, meta + + # ============================================================ + # Controles de câmera + # ============================================================ + + def resolve_camera_id( + self, + cam_id=None, + role=None, + ): + if role is not None: + role = str( + role + ).lower() + + status = ( + self.manager.get_status() + ) + + for cam in status.get( + "cameras", + [], + ): + if ( + str( + cam.get( + "role", + "", + ) + ).lower() + == role + ): + return cam[ + "id" + ] + + raise ValueError( + f"Nenhuma câmera ativa encontrada para role={role}" + ) + + if cam_id is None: + raise ValueError( + "Informe cam_id ou role." + ) + + return str( + cam_id + ) + + def get_camera_controls( + self, + cam_id=None, + role=None, + ): + cam_id = self.resolve_camera_id( + cam_id=cam_id, + role=role, + ) + + ctrl = dict( + self.manager.get_camera_controls( + cam_id + ) + ) + + ctrl[ + "ok" + ] = True + + ctrl[ + "camera_id" + ] = cam_id + + ctrl[ + "role" + ] = ( + self._resolve_role_from_camera_id( + cam_id + ) + ) + + return ctrl + + def set_ae_enable( + self, + cam_id=None, + role=None, + enable=False, + ): + cam_id = self.resolve_camera_id( + cam_id=cam_id, + role=role, + ) + + ctrl = dict( + self.manager.set_ae_enable( + cam_id, + bool( + enable + ), + ) + ) + + return self._decorate_control_response( + ctrl, + cam_id, + ) + + def set_awb_enable( + self, + cam_id=None, + role=None, + enable=False, + ): + cam_id = self.resolve_camera_id( + cam_id=cam_id, + role=role, + ) + + ctrl = dict( + self.manager.set_awb_enable( + cam_id, + bool( + enable + ), + ) + ) + + return self._decorate_control_response( + ctrl, + cam_id, + ) + + def set_exposure_time( + self, + cam_id=None, + role=None, + exposure_time_us=None, + ): + if exposure_time_us is None: + raise ValueError( + "exposure_time_us é obrigatório." + ) + + cam_id = self.resolve_camera_id( + cam_id=cam_id, + role=role, + ) + + ctrl = dict( + self.manager.set_exposure_time( + cam_id, + int( + exposure_time_us + ), + ) + ) + + return self._decorate_control_response( + ctrl, + cam_id, + ) + + def set_analogue_gain( + self, + cam_id=None, + role=None, + analogue_gain=None, + ): + if analogue_gain is None: + raise ValueError( + "analogue_gain é obrigatório." + ) + + cam_id = self.resolve_camera_id( + cam_id=cam_id, + role=role, + ) + + ctrl = dict( + self.manager.set_analogue_gain( + cam_id, + float( + analogue_gain + ), + ) + ) + + return self._decorate_control_response( + ctrl, + cam_id, + ) + + def apply_camera_controls( + self, + cam_id=None, + role=None, + controls=None, + ): + cam_id = self.resolve_camera_id( + cam_id=cam_id, + role=role, + ) + + controls = ( + dict( + controls + ) + if isinstance( + controls, + dict, + ) + else {} + ) + + ctrl = dict( + self.manager.apply_camera_controls( + cam_id, + controls, + ) + ) + + return self._decorate_control_response( + ctrl, + cam_id, + ) + + # ============================================================ + # Helpers + # ============================================================ + + def _decorate_control_response( + self, + ctrl, + cam_id, + ): + ctrl[ + "ok" + ] = True + + ctrl[ + "camera_id" + ] = cam_id + + ctrl[ + "role" + ] = ( + self._resolve_role_from_camera_id( + cam_id + ) + ) + + return ctrl + + def _resolve_role_from_camera_id( + self, + cam_id, + ): + roles = getattr( + self.manager, + "roles", + {}, + ) or {} + + return str( + roles.get( + str( + cam_id + ), + "", + ) + ).lower() or None + + def _ensure_stopped_for_config( + self, + ): if self.manager.running: raise RuntimeError( "Configuração estrutural só pode ser alterada com o manager parado. " "Chame stop() antes." ) - - def resolve_camera_id(self, cam_id=None, role=None): - if role is not None: - role = str(role).lower() - status = self.manager.get_status() - for cam in status.get("cameras", []): - if str(cam.get("role", "")).lower() == role: - return cam["id"] + def _validate_product_frame_type( + self, + frame_type, + ): + if ( + self.product_contract + and str( + frame_type + ).upper() + != "RAW_BRUTO" + ): + raise RuntimeError( + "module_params de produção exige frame_type=RAW_BRUTO. " + f"Recebido={frame_type!r}" + ) - raise ValueError(f"Nenhuma câmera ativa encontrada para role={role}") + @staticmethod + def _validate_frame_type( + frame_type, + ): + frame_type = str( + frame_type + ).upper() - if cam_id is None: - raise ValueError("Informe cam_id ou role.") + if frame_type not in ( + "RAW_BRUTO", + "RGB", + "MULTISPEC", + "PREVIEW", + ): + raise ValueError( + f"frame_type inválido: {frame_type}" + ) - return str(cam_id) - - - def get_camera_controls(self, cam_id=None, role=None): - cam_id = self.resolve_camera_id(cam_id=cam_id, role=role) - ctrl = self.manager.get_camera_controls(cam_id) - ctrl["ok"] = True - ctrl["camera_id"] = cam_id - ctrl["role"] = role - return ctrl - - def set_ae_enable(self, cam_id=None, role=None, enable=False): - cam_id = self.resolve_camera_id(cam_id=cam_id, role=role) - ctrl = self.manager.set_ae_enable(cam_id, bool(enable)) - ctrl["ok"] = True - ctrl["camera_id"] = cam_id - ctrl["role"] = role - return ctrl - - def set_awb_enable(self, cam_id=None, role=None, enable=False): - cam_id = self.resolve_camera_id(cam_id=cam_id, role=role) - ctrl = self.manager.set_awb_enable(cam_id, bool(enable)) - ctrl["ok"] = True - ctrl["camera_id"] = cam_id - ctrl["role"] = role - return ctrl - - def set_exposure_time(self, cam_id=None, role=None, exposure_time_us=None): - if exposure_time_us is None: - raise ValueError("exposure_time_us é obrigatório.") - cam_id = self.resolve_camera_id(cam_id=cam_id, role=role) - ctrl = self.manager.set_exposure_time(cam_id, int(exposure_time_us)) - ctrl["ok"] = True - ctrl["camera_id"] = cam_id - ctrl["role"] = role - return ctrl - - def set_analogue_gain(self, cam_id=None, role=None, analogue_gain=None): - if analogue_gain is None: - raise ValueError("analogue_gain é obrigatório.") - cam_id = self.resolve_camera_id(cam_id=cam_id, role=role) - ctrl = self.manager.set_analogue_gain(cam_id, float(analogue_gain)) - ctrl["ok"] = True - ctrl["camera_id"] = cam_id - ctrl["role"] = role - return ctrl - - def apply_camera_controls(self, cam_id=None, role=None, controls=None): - cam_id = self.resolve_camera_id(cam_id=cam_id, role=role) - ctrl = self.manager.apply_camera_controls(cam_id, controls or {}) - ctrl["ok"] = True - ctrl["camera_id"] = cam_id - ctrl["role"] = role - return ctrl - - - def _validate_frame_type(self, frame_type): - frame_type = str(frame_type).upper() - if frame_type not in ("RAW_BRUTO", "RGB", "MULTISPEC", "PREVIEW"): - raise ValueError(f"frame_type inválido: {frame_type}") return frame_type - def _validate_output_dtype(self, dtype): - dtype = str(dtype).lower() - if dtype not in ("uint8", "uint16", "float32"): - raise ValueError(f"output_dtype inválido: {dtype}") + @staticmethod + def _validate_output_dtype( + dtype, + ): + dtype = str( + dtype + ).lower() + + if dtype not in ( + "uint8", + "uint16", + "float32", + ): + raise ValueError( + f"output_dtype inválido: {dtype}" + ) + return dtype - def _validate_capture_mode(self, mode): - mode = str(mode).upper() - if mode not in ("AUTO", "SINGLE", "DOUBLE", "TRIPLE"): - raise ValueError(f"capture_mode inválido: {mode}") + @staticmethod + def _validate_capture_mode( + mode, + ): + mode = str( + mode + ).upper() + + if mode not in ( + "AUTO", + "SINGLE", + "DOUBLE", + "TRIPLE", + ): + raise ValueError( + f"capture_mode inválido: {mode}" + ) + return mode diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py index c105e7b8f..3a2ba9d24 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py @@ -1,9 +1,42 @@ +""" +RawProcessorCore - Production +============================= + +Canonical multispectral preprocessing core for training and inference. + +Product contract: +- CAM_A RGB: OV9782 1280x800 OR AR0234 1920x1200 +- CAM_B RE : OV9282 1280x800 +- CAM_C NIR: OV9282 1280x800 +- Physical output tensor: [R, G, B, RE, NIR] + +Production revision highlights: +- mixed-resolution homography with independent source/reference spaces; +- OpenCV pixel-center-aware geometry scaling; +- native RE/NIR warp without pre-resizing to RGB; +- strict validation of final module_params and per-frame hardware metadata; +- product RAW_BRUTO fail-closed input contract; +- radiometric frame-control fallbacks for saved datasets; +- single-resample fusion directly to requested tensor size; +- intrinsics contract loading/validation with runtime undistort gated off; +- faster Flat-Field saturation guard using fused Numba kernel; +- lower-allocation in-place Flat-Field path; +- faster frame-quality statistics while retaining exact critical percentages. + +This module intentionally keeps legacy behavior available when a non-product +module_params is loaded. The strict fail-closed behavior is enabled only for +module_params generated by the production assembler. +""" + +RAW_PROCESSOR_CORE_VERSION = "production_v1_2026_08_24" + import json import os import time import cv2 import numpy as np import math +from copy import deepcopy from typing import Optional @@ -215,6 +248,97 @@ if _HAS_NUMBA: out[y, x] = v + @_numba.njit(cache=True, fastmath=True, parallel=True) + def _apply_flat_gain_guard_numba( + base, + gain, + saturation_mask, + out, + height, + width, + strength, + gain_min_runtime, + gain_max_runtime, + sat_soft_start, + sat_hard, + clip_output, + mode_code, + ): + """ + Flat-field com saturation guard em uma única passada. + + mode_code: + 1 = fade_strength + 2 = skip + 0 = simple + """ + denom = sat_hard - sat_soft_start + if denom < 1e-6: + denom = 1e-6 + + for y in _numba.prange(height): + for x in range(width): + v = float(base[y, x]) + g = float(gain[y, x]) + + if mode_code == 1: + t = (v - sat_soft_start) / denom + + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + + local_strength = 1.0 - t + + if saturation_mask[y, x]: + local_strength = 0.0 + + gain_eff = 1.0 + ( + strength + * local_strength + * (g - 1.0) + ) + + if gain_eff < gain_min_runtime: + gain_eff = gain_min_runtime + elif gain_eff > gain_max_runtime: + gain_eff = gain_max_runtime + + value = v * gain_eff + + elif mode_code == 2: + if saturation_mask[y, x]: + value = v + else: + gain_eff = 1.0 + strength * (g - 1.0) + + if gain_eff < gain_min_runtime: + gain_eff = gain_min_runtime + elif gain_eff > gain_max_runtime: + gain_eff = gain_max_runtime + + value = v * gain_eff + + else: + gain_eff = 1.0 + strength * (g - 1.0) + + if gain_eff < gain_min_runtime: + gain_eff = gain_min_runtime + elif gain_eff > gain_max_runtime: + gain_eff = gain_max_runtime + + value = v * gain_eff + + if clip_output: + if value < 0.0: + value = 0.0 + elif value > 1.0: + value = 1.0 + + out[y, x] = value + + @_numba.njit(cache=True, fastmath=True, parallel=True) def _apply_tensor_flat_gain_chw_numba(tensor, gain, channels, height, width, clip_output): for c in _numba.prange(channels): @@ -389,6 +513,7 @@ class RawProcessorCore: "save_debug": True, } self.last_radiometric_normalization_result = None + self.last_flatfield_result = None self.radiometric_config = {} self.patch_normalization_config = { @@ -440,37 +565,160 @@ class RawProcessorCore: self.last_fusion_result = None self.camera_settings = {} + # Contrato de produto carregado pelo assembler final. + self.strict_product_contract = False + self.module_params_schema = None + self.sensor_size_by_role = {} + self.camera_hardware = {} + self.intrinsics_config = { + "enabled": False, + "calibration_space": "native_stream_no_external_undistort", + "cameras": {}, + "runtime_undistort": {"enabled": False}, + } + self.calibration_provenance = {} + self.assembly_metadata = {} + self._last_frame_controls_source = None + self._flatfield_runtime_cache = {} self.last_decode_perf = {} self._last_decode_perf_log_ts = 0.0 self.last_rgb_enhancement_result = None + # QC: percentis são diagnóstico, não parte do tensor. + # Mantemos saturação/dark/range exatos e limitamos apenas estatísticas + # descritivas para não gastar dezenas/centenas de ms por frame. + self.quality_stats_max_samples = 65536 + if calibration_json_path: self.load_config_json(calibration_json_path) def warmup_numba_raw10_decode(self): + """ + Warmup sensor-aware. + + Numba especializa por dtype/layout, não por shape, mas usar os tamanhos + reais também aquece alocações e o caminho OpenCV do RGB correto. + """ if not _HAS_NUMBA: return {"ok": False, "reason": "numba_not_available"} - w, h = 1280, 800 - packed_w = int(np.ceil(w * 10 / 8)) - dummy = np.zeros((h, packed_w), dtype=np.uint8) + sizes = getattr(self, "sensor_size_by_role", {}) or {} - _ = self._raw10_mono_to_float01_aggressive(dummy, w, h, 10) - _ = self._raw10_rgb_bayer_planes_to_rgb_float01_aggressive(dummy, w, h, "RGGB", 10) - _ = self._raw10_to_raw16_aggressive(dummy, w, h) + rgb_size = sizes.get("rgb") or [ + int(self.sensor_width), + int(self.sensor_height), + ] + re_size = sizes.get("re") or [1280, 800] - raw16 = self._raw10_to_raw16_aggressive(dummy, w, h) - rgb16 = cv2.cvtColor(raw16, cv2.COLOR_BayerRG2RGB) + rgb_w, rgb_h = int(rgb_size[0]), int(rgb_size[1]) + spec_w, spec_h = int(re_size[0]), int(re_size[1]) + + rgb_packed_w = self._raw10_expected_packed_width_fast(rgb_w) + spec_packed_w = self._raw10_expected_packed_width_fast(spec_w) + + dummy_rgb = np.zeros((rgb_h, rgb_packed_w), dtype=np.uint8) + dummy_spec = np.zeros((spec_h, spec_packed_w), dtype=np.uint8) + + _ = self._raw10_mono_to_float01_aggressive( + dummy_spec, + spec_w, + spec_h, + 10, + ) + + _ = self._raw10_rgb_bayer_planes_to_rgb_float01_aggressive( + dummy_rgb, + rgb_w, + rgb_h, + self.bayer_pattern, + 10, + ) + + raw16 = self._raw10_to_raw16_aggressive( + dummy_rgb, + rgb_w, + rgb_h, + ) + + cv_code, _ = self._get_bayer_cv2_code( + bayer_pattern=self.bayer_pattern, + algorithm=str( + (self.rgb_processing_config or {}).get( + "demosaic_algorithm", + "ea", + ) + ), + ) + + rgb16 = cv2.cvtColor(raw16, cv_code) _ = self._rgb16_to_float32_gain_clip_aggressive(rgb16, 10) - dummy_tensor = np.zeros((5, 640, 1024), dtype=np.float32) - dummy_gain = np.ones((5, 640, 1024), dtype=np.float32) - _apply_tensor_flat_gain_chw_numba(dummy_tensor, dummy_gain, 5, 640, 1024, True) + # Warmup do flat final/Numba com target real, se houver. + target = (self.fusion_config or {}).get("target_size") or [ + max(1, rgb_w // 2), + max(1, rgb_h // 2), + ] + tw, th = int(target[0]), int(target[1]) - return {"ok": True, "backend": "numba", "shape": [h, packed_w]} + dummy_tensor = np.zeros((5, th, tw), dtype=np.float32) + dummy_gain = np.ones((5, th, tw), dtype=np.float32) + + _apply_tensor_flat_gain_chw_numba( + dummy_tensor, + dummy_gain, + 5, + th, + tw, + True, + ) + + # Warmup do kernel fused do Flat-Field com saturation guard. + kh = min(th, 64) + kw = min(tw, 64) + + dummy_base = np.zeros( + (kh, kw), + dtype=np.float32, + ) + dummy_gain_ch = np.ones( + (kh, kw), + dtype=np.float32, + ) + dummy_mask = np.zeros( + (kh, kw), + dtype=np.bool_, + ) + dummy_out = np.empty( + (kh, kw), + dtype=np.float32, + ) + + _apply_flat_gain_guard_numba( + dummy_base, + dummy_gain_ch, + dummy_mask, + dummy_out, + kh, + kw, + 1.0, + 0.75, + 1.35, + 0.88, + 0.97, + True, + 1, + ) + + return { + "ok": True, + "backend": "numba", + "rgb_native": [rgb_w, rgb_h], + "spectral_native": [spec_w, spec_h], + "target_size": [tw, th], + } def unpack_raw10_packed( self, @@ -910,8 +1158,19 @@ class RawProcessorCore: output_dtype: str = "float32", bit_depth: int = 10, ) -> np.ndarray: + """ + RAW Bayer uint16 -> RGB CHW usando exatamente o rgb_processing atual. + + Suporta: + linear_demosaic + linear_demosaic_half + bayer_planes + """ rgb_mode = str( - (getattr(self, "rgb_processing_config", {}) or {}).get("mode", "linear_demosaic") + (getattr(self, "rgb_processing_config", {}) or {}).get( + "mode", + "linear_demosaic", + ) ).lower() if rgb_mode in ("linear_demosaic", "demosaic", "full_res"): @@ -920,6 +1179,23 @@ class RawProcessorCore: bit_depth=bit_depth, ) + elif rgb_mode in ( + "linear_demosaic_half", + "demosaic_half", + "full_demosaic_half", + ): + r, g, b = self.demosaic_raw16_to_rgb_linear( + raw16, + bit_depth=bit_depth, + ) + + h, w = r.shape[:2] + size = (w // 2, h // 2) + + r = cv2.resize(r, size, interpolation=cv2.INTER_AREA) + g = cv2.resize(g, size, interpolation=cv2.INTER_AREA) + b = cv2.resize(b, size, interpolation=cv2.INTER_AREA) + elif rgb_mode in ("bayer_planes", "bayer", "half_res"): r, g, b = self.bayer_planes_to_rgb_linear( raw16, @@ -927,7 +1203,9 @@ class RawProcessorCore: ) else: - raise ValueError(f"rgb_processing.mode inválido: {rgb_mode}") + raise ValueError( + f"rgb_processing.mode inválido: {rgb_mode}" + ) rgb_cal = getattr(self, "rgb_calibration", {}) or {} if rgb_cal.get("enabled", False): @@ -936,30 +1214,58 @@ class RawProcessorCore: g = g * float(gains.get("G", 1.0)) b = b * float(gains.get("B", 1.0)) - rgb_hwc = np.stack([r, g, b], axis=2).astype(np.float32) - np.clip(rgb_hwc, 0.0, 1.0, out=rgb_hwc) + rgb_hwc = np.stack( + [r, g, b], + axis=2, + ).astype(np.float32) + + np.clip( + rgb_hwc, + 0.0, + 1.0, + out=rgb_hwc, + ) - # Pós-processamento RGB opcional. - # Aplicado aqui para o caminho de treino/offline que chama build_training_rgb(). rgb_hwc = self.apply_rgb_enhancement_to_hwc_float01( rgb_hwc, stage="build_training_rgb.after_decode", source_kind="raw", ) - chw = np.transpose(rgb_hwc, (2, 0, 1)).astype(np.float32, copy=False) - np.clip(chw, 0.0, 1.0, out=chw) + chw = np.transpose( + rgb_hwc, + (2, 0, 1), + ).astype(np.float32, copy=False) + + np.clip( + chw, + 0.0, + 1.0, + out=chw, + ) if output_dtype == "float32": return chw if output_dtype == "uint8": - return (chw * 255.0).clip(0, 255).astype(np.uint8) + return ( + chw * 255.0 + ).clip( + 0, + 255, + ).astype(np.uint8) if output_dtype == "uint16": - return (chw * 65535.0).clip(0, 65535).astype(np.uint16) + return ( + chw * 65535.0 + ).clip( + 0, + 65535, + ).astype(np.uint16) - raise ValueError(f"output_dtype não suportado: {output_dtype}") + raise ValueError( + f"output_dtype não suportado: {output_dtype}" + ) def _channel_names_from_decoded(self, decoded): names = ["R", "G", "B"] @@ -1049,38 +1355,106 @@ class RawProcessorCore: return decoded - def build_multispectral_tensor(self, bins_data, bins_meta, target_size=None): - decoded = self.decode_bins_cameras(bins_data, bins_meta) + def build_multispectral_tensor( + self, + bins_data, + bins_meta, + target_size=None, + capture_meta=None, + ): + """ + Caminho offline/legado para dados já carregados. - rgb_cam_id = self._find_cam_by_role(decoded, "rgb") + Em module_params de produção, radiometric_normalization é obrigatória. + Portanto capture_meta deve acompanhar o sample; caso contrário este método + recusará gerar um tensor silenciosamente diferente do runtime. + + Para datasets RAW_BRUTO novos, prefira build_infer_tensor_from_stream(). + """ + decoded = self.decode_bins_cameras( + bins_data, + bins_meta, + ) + + rgb_cam_id = self._find_cam_by_role( + decoded, + "rgb", + ) if rgb_cam_id is None: raise RuntimeError("RGB obrigatório") - channel_names = self._channel_names_from_decoded(decoded) + channel_names = self._channel_names_from_decoded( + decoded + ) + + if ( + getattr(self, "strict_product_contract", False) + and bool( + (self.radiometric_normalization_config or {}).get( + "enabled", + False, + ) + ) + and not isinstance(capture_meta, dict) + ): + raise RuntimeError( + "build_multispectral_tensor() em modo produto exige capture_meta " + "com controles reais da captura. Prefira build_infer_tensor_from_stream()." + ) tensor = self.fuse_multispec_cameras( decoded, - meta=None, + meta=capture_meta, channels_expected=len(channel_names), + target_size=target_size, ) - tensor = self.resize_tensor_chw(tensor, target_size=target_size) + if bool( + (self.patch_normalization_config or {}).get( + "enabled", + False, + ) + ): + tensor = self.apply_patch_normalization_to_tensor( + tensor + ) - if bool((self.patch_normalization_config or {}).get("enabled", False)): - tensor = self.apply_patch_normalization_to_tensor(tensor) - - self.last_frame_quality_result = self.evaluate_frame_quality(tensor) + self.last_frame_quality_result = self.evaluate_frame_quality( + tensor + ) return tensor, channel_names def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None): channels_expected = self._validate_physical_channel_count(channels_expected) - frame_type = meta.get("frame_type") + stream_meta = ( + meta.get("stream_meta", {}) + if isinstance(meta, dict) + else {} + ) or {} + frame_type = ( + meta.get("frame_type") + if isinstance(meta, dict) + else None + ) or stream_meta.get("frame_type") + + if ( + getattr(self, "strict_product_contract", False) + and frame_type != "RAW_BRUTO" + ): + raise RuntimeError( + "module_params de produção aceita somente frame_type='RAW_BRUTO' " + "neste core. RGB/MULTISPEC pré-processado contornaria calibrações." + ) if frame_type == "RAW_BRUTO": decoded = self.decode_stream_cameras(frame, meta) - tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected) - tensor = self.resize_tensor_chw(tensor, target_size=target_size) + tensor = self.fuse_multispec_cameras( + decoded, + meta, + channels_expected, + target_size=target_size, + ) # Sem cartões neste modo novo. # patch_normalization deve ficar desligado no JSON. @@ -1125,42 +1499,92 @@ class RawProcessorCore: if not isinstance(frame, dict): raise RuntimeError("RAW_BRUTO esperado como dict de câmeras no modo multi") - camera_frames = meta.get("camera_frames", {}) or {} - camera_info = meta.get("camera_info", {}) or {} + if not isinstance(meta, dict): + raise RuntimeError("RAW_BRUTO exige meta dict.") + + self._validate_stream_camera_contract(frame, meta) + + stream_meta = ( + meta.get("stream_meta", {}) + if isinstance(meta.get("stream_meta"), dict) + else {} + ) + camera_frames = ( + meta.get("camera_frames", {}) + or stream_meta.get("camera_frames", {}) + or {} + ) + camera_info = ( + meta.get("camera_info", {}) + or stream_meta.get("camera_info", {}) + or {} + ) decoded = {} rgb_mode = str( - (getattr(self, "rgb_processing_config", {}) or {}).get("mode", "linear_demosaic") + (getattr(self, "rgb_processing_config", {}) or {}).get( + "mode", + "linear_demosaic", + ) ).lower() for cam_id, data in frame.items(): - cam_meta = camera_frames.get(cam_id) or camera_info.get(cam_id) or {} + cam_meta = {} + if isinstance(camera_info.get(cam_id), dict): + cam_meta.update(camera_info.get(cam_id) or {}) + if isinstance(camera_frames.get(cam_id), dict): + cam_meta.update(camera_frames.get(cam_id) or {}) role = str(cam_meta.get("role", "")).lower() if not role: - raise RuntimeError(f"Meta da câmera {cam_id} sem role. Esperado role='rgb', 'nir' ou 're'.") + raise RuntimeError( + f"Meta da câmera {cam_id} sem role. " + "Esperado role='rgb', 'nir' ou 're'." + ) bit_depth = int(cam_meta.get("bit_depth", 10)) raw_format = str(cam_meta.get("raw_format", "")).upper() - packed = bool(cam_meta.get("packed", False)) - is_raw10 = raw_format == "RAW10_PACKED" or packed or bit_depth == 10 + packed_flag = bool(cam_meta.get("packed", False)) if role == "rgb": - if is_raw10 and data.ndim == 2: - sensor_w = int(cam_meta.get("width", self.sensor_width)) - sensor_h = int(cam_meta.get("height", self.sensor_height)) + sensor_w = int(cam_meta.get("width", self.sensor_width)) + sensor_h = int(cam_meta.get("height", self.sensor_height)) - bayer = ( - cam_meta.get("bayer_pattern") - or cam_meta.get("bayer") - or self.bayer_pattern - or "RGGB" + bayer = str( + cam_meta.get("bayer_pattern") + or cam_meta.get("bayer") + or self.bayer_pattern + or "RGGB" + ).upper() + + arr = np.asarray(data) + + expected_packed_w = self._raw10_expected_packed_width_fast(sensor_w) + packed_width_meta = int(cam_meta.get("packed_width", 0) or 0) + + looks_like_raw10_packed = ( + arr.ndim == 2 + and arr.dtype == np.uint8 + and arr.shape[0] == sensor_h + and ( + raw_format == "RAW10_PACKED" + or packed_flag + or ( + packed_width_meta > 0 + and arr.shape[1] >= packed_width_meta + ) + or ( + bit_depth == 10 + and arr.shape[1] >= expected_packed_w + ) ) + ) + if looks_like_raw10_packed: if rgb_mode in ("bayer_planes", "bayer", "half_res"): rgb_hwc = self._raw10_rgb_bayer_planes_to_rgb_float01_aggressive( - data, + arr, width=sensor_w, height=sensor_h, bayer_pattern=bayer, @@ -1176,7 +1600,7 @@ class RawProcessorCore: "full_demosaic_half", ): rgb_hwc = self._raw10_rgb_linear_demosaic_to_rgb_float01_fast( - data, + arr, width=sensor_w, height=sensor_h, bayer_pattern=bayer, @@ -1184,10 +1608,12 @@ class RawProcessorCore: ) else: - raise ValueError(f"rgb_processing.mode inválido: {rgb_mode}") + raise ValueError( + f"rgb_processing.mode inválido: {rgb_mode}" + ) - # Pós-processamento RGB opcional. - # Este é o caminho principal de inferência RAW_BRUTO. + # Em produto enhancement é validado como OFF. + # Em modo legado permanece compatível com os experimentos antigos. rgb_hwc = self.apply_rgb_enhancement_to_hwc_float01( rgb_hwc, stage="decode_stream_cameras.after_raw_decode", @@ -1201,18 +1627,50 @@ class RawProcessorCore: "meta": cam_meta, } + elif arr.ndim == 2 and arr.dtype == np.uint16: + # RAW Bayer já desempacotado. Útil em fluxo offline/diagnóstico. + old_bayer = self.bayer_pattern + try: + self.bayer_pattern = bayer + rgb_chw = self.build_training_rgb( + arr, + output_dtype="float32", + bit_depth=bit_depth, + ) + finally: + self.bayer_pattern = old_bayer + + rgb_hwc = np.transpose( + rgb_chw, + (1, 2, 0), + ).astype(np.float32, copy=False) + + decoded[cam_id] = { + "name": "RGB", + "role": "rgb", + "image": rgb_hwc, + "meta": cam_meta, + } + else: - # Caso preview/processado antigo: BGR HWC uint8. - if data.ndim != 3 or data.shape[2] != 3: - raise RuntimeError(f"{cam_id} RGB inválida: shape={data.shape}") + # Preview/processado antigo: BGR HWC. + if arr.ndim != 3 or arr.shape[2] != 3: + raise RuntimeError( + f"{cam_id} RGB inválida: shape={arr.shape}, " + f"dtype={arr.dtype}" + ) - rgb = data[:, :, ::-1].astype(np.float32) / 255.0 + if arr.dtype == np.uint8: + rgb = arr[:, :, ::-1].astype(np.float32) / 255.0 + elif arr.dtype == np.uint16: + rgb = arr[:, :, ::-1].astype(np.float32) / 65535.0 + else: + rgb = arr[:, :, ::-1].astype(np.float32) + if np.nanmax(rgb) > 1.5: + rgb /= 255.0 - rgb = np.clip(rgb, 0.0, 1.0) + np.clip(rgb, 0.0, 1.0, out=rgb) - # Por padrão não mexe em preview/RGB já processado. - # Se quiser aplicar também neste caminho, use: - # rgb_processing.enhancement.apply_to_preview_input=true rgb = self.apply_rgb_enhancement_to_hwc_float01( rgb, stage="decode_stream_cameras.preview_input", @@ -1230,7 +1688,10 @@ class RawProcessorCore: decoded[cam_id] = { "name": "RE", "role": "re", - "image": self._decode_spectral_frame_to_float01(data, cam_meta), + "image": self._decode_spectral_frame_to_float01( + data, + cam_meta, + ), "meta": cam_meta, } @@ -1238,12 +1699,17 @@ class RawProcessorCore: decoded[cam_id] = { "name": "NIR", "role": "nir", - "image": self._decode_spectral_frame_to_float01(data, cam_meta), + "image": self._decode_spectral_frame_to_float01( + data, + cam_meta, + ), "meta": cam_meta, } else: - raise RuntimeError(f"Role não suportada em {cam_id}: {role}") + raise RuntimeError( + f"Role não suportada em {cam_id}: {role}" + ) return decoded @@ -1313,7 +1779,13 @@ class RawProcessorCore: return np.clip(arr01, 0.0, 1.0) - def fuse_multispec_cameras(self, decoded, meta, channels_expected): + def fuse_multispec_cameras( + self, + decoded, + meta, + channels_expected, + target_size=None, + ): t_total0 = time.perf_counter() t0 = time.perf_counter() @@ -1344,6 +1816,7 @@ class RawProcessorCore: decoded, meta, channels_expected, + target_size_override=target_size, ) t_flat_ms = float(t_flat_native_ms + float(direct_perf.get("final_flat_ms", 0.0))) @@ -1465,13 +1938,55 @@ class RawProcessorCore: ) def _warp_with_valid_mask(self, img, role, ref_shape, meta): - ref_h, ref_w = ref_shape + """ + Legacy spatial path mantido correto para mixed-resolution. - if img.shape[:2] != (ref_h, ref_w): - img = cv2.resize(img, (ref_w, ref_h), interpolation=cv2.INTER_LINEAR) + Em homography, RE/NIR permanecem no tamanho nativo até o warp. + Nos modos identity/manual antigos, resize para a referência continua + permitido apenas por compatibilidade. + """ + ref_h, ref_w = int(ref_shape[0]), int(ref_shape[1]) cfg = self.fusion_config - mode = cfg.get("alignment_mode", "identity") + mode = str(cfg.get("alignment_mode", "identity")).lower() + + if mode == "homography": + H = self._direct_fusion_get_role_homography_fast( + role, + meta, + (ref_h, ref_w), + source_size=img.shape[:2], + ) + + source_mask = np.ones(img.shape[:2], dtype=np.uint8) * 255 + + warped = cv2.warpPerspective( + img, + H, + (ref_w, ref_h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + warped_mask = cv2.warpPerspective( + source_mask, + H, + (ref_w, ref_h), + flags=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + return warped, (warped_mask > 0).astype(np.uint8) + + # Modos legados operavam em uma grade comum. + if img.shape[:2] != (ref_h, ref_w): + img = cv2.resize( + img, + (ref_w, ref_h), + interpolation=cv2.INTER_LINEAR, + ) mask = np.ones((ref_h, ref_w), dtype=np.uint8) * 255 @@ -1496,36 +2011,10 @@ class RawProcessorCore: warped = self._affine_image(img, dx, dy, theta_deg) warped_mask = self._affine_image(mask, dx, dy, theta_deg) - elif mode == "homography": - H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) - H = self._scale_homography_to_runtime( - H, - calib_size=calib_size, - runtime_size=(ref_w, ref_h), - ) - - if H.shape != (3, 3): - raise RuntimeError(f"Homografia inválida para {role}: shape={H.shape}") - - warped = cv2.warpPerspective( - img, H, (ref_w, ref_h), - flags=cv2.INTER_LINEAR, - borderMode=cv2.BORDER_CONSTANT, - borderValue=0 - ) - - warped_mask = cv2.warpPerspective( - mask, H, (ref_w, ref_h), - flags=cv2.INTER_NEAREST, - borderMode=cv2.BORDER_CONSTANT, - borderValue=0 - ) - else: raise RuntimeError(f"alignment_mode inválido: {mode}") - warped_mask = (warped_mask > 0).astype(np.uint8) - return warped, warped_mask + return warped, (warped_mask > 0).astype(np.uint8) def _compute_common_crop_box(self, masks): if not masks: @@ -1599,56 +2088,157 @@ class RawProcessorCore: return np.stack(chans, axis=0) - def _scale_homography_to_runtime(self, H, calib_size, runtime_size): + def _scale_homography_to_runtime( + self, + H, + calib_size=None, + runtime_size=None, + *, + calib_source_size=None, + calib_reference_size=None, + runtime_source_size=None, + runtime_reference_size=None, + ): """ - Ajusta uma homografia calculada em calib_size para ser aplicada em runtime_size. + Escala H entre dois espaços independentes usando convenção de centro + de pixel compatível com cv2.resize: - H original: - ponto_spec_calib -> ponto_rgb_calib + x_dst = (x_src + 0.5) * scale - 0.5 - H runtime: - ponto_spec_runtime -> ponto_rgb_runtime + Caso geral: + H_calib : source_calib -> reference_calib + H_runtime : source_runtime -> reference_runtime + + H_runtime = A_dst @ H_calib @ inv(A_src) + + Chamadas antigas com calib_size/runtime_size seguem compatíveis. """ if H is None: return None - H = np.asarray(H, dtype=np.float32) + H = np.asarray( + H, + dtype=np.float64, + ) - if calib_size is None: - return H + if H.shape != (3, 3): + raise RuntimeError( + f"Homografia inválida: shape={H.shape}" + ) - calib_w, calib_h = calib_size - runtime_w, runtime_h = runtime_size + if calib_source_size is None: + calib_source_size = calib_size - calib_w = float(calib_w) - calib_h = float(calib_h) - runtime_w = float(runtime_w) - runtime_h = float(runtime_h) + if calib_reference_size is None: + calib_reference_size = calib_size - if calib_w <= 0 or calib_h <= 0: - return H + if runtime_source_size is None: + runtime_source_size = runtime_size - sx = runtime_w / calib_w - sy = runtime_h / calib_h + if runtime_reference_size is None: + runtime_reference_size = runtime_size - S = np.array([ - [sx, 0.0, 0.0], - [0.0, sy, 0.0], - [0.0, 0.0, 1.0], - ], dtype=np.float32) + if ( + calib_source_size is None + or calib_reference_size is None + or runtime_source_size is None + or runtime_reference_size is None + ): + out = H.copy() - S_inv = np.array([ - [1.0 / sx, 0.0, 0.0], - [0.0, 1.0 / sy, 0.0], - [0.0, 0.0, 1.0], - ], dtype=np.float32) + if abs( + float( + out[2, 2] + ) + ) > 1e-12: + out /= out[2, 2] - H_runtime = S @ H @ S_inv + return out.astype( + np.float32 + ) - if abs(H_runtime[2, 2]) > 1e-9: - H_runtime = H_runtime / H_runtime[2, 2] + def resize_matrix( + src_size, + dst_size, + ): + src_w, src_h = [ + float(v) + for v in src_size + ] + dst_w, dst_h = [ + float(v) + for v in dst_size + ] - return H_runtime.astype(np.float32) + if ( + src_w <= 0 + or src_h <= 0 + or dst_w <= 0 + or dst_h <= 0 + ): + raise RuntimeError( + f"Tamanho inválido: " + f"src={src_size}, dst={dst_size}" + ) + + sx = dst_w / src_w + sy = dst_h / src_h + + tx = ( + 0.5 * sx + - 0.5 + ) + ty = ( + 0.5 * sy + - 0.5 + ) + + return np.array( + [ + [sx, 0.0, tx], + [0.0, sy, ty], + [0.0, 0.0, 1.0], + ], + dtype=np.float64, + ) + + A_src = resize_matrix( + calib_source_size, + runtime_source_size, + ) + + A_dst = resize_matrix( + calib_reference_size, + runtime_reference_size, + ) + + H_runtime = ( + A_dst + @ H + @ np.linalg.inv( + A_src + ) + ) + + if abs( + float( + H_runtime[2, 2] + ) + ) > 1e-12: + H_runtime /= H_runtime[2, 2] + + if not np.all( + np.isfinite( + H_runtime + ) + ): + raise RuntimeError( + "Homografia runtime contém valores não finitos." + ) + + return H_runtime.astype( + np.float32 + ) def apply_patch_normalization_to_tensor(self, tensor: np.ndarray) -> np.ndarray: @@ -1941,36 +2531,114 @@ class RawProcessorCore: def _array01_stats(self, arr: np.ndarray) -> dict: """ - Estatísticas compactas para debug radiométrico de arrays float. - Mantém tudo serializável em JSON e leve o bastante para log por frame. + Estatísticas compactas para debug/quality. + + Performance: + - sat/dark/over/under, min/max e finitude são EXATOS; + - percentis, mean e std usam amostragem determinística quando o array + ultrapassa quality_stats_max_samples. + + Essa amostragem não altera o tensor nem decisões de saturação/range. """ if arr is None: return {} - vals = np.asarray(arr, dtype=np.float32).reshape(-1) + vals = np.asarray( + arr, + dtype=np.float32, + ).reshape(-1) + if vals.size <= 0: return {} - finite = vals[np.isfinite(vals)] - if finite.size <= 0: - return {"count": int(vals.size), "finite_count": 0} + finite_mask = np.isfinite(vals) + finite_count = int( + np.count_nonzero(finite_mask) + ) + + if finite_count <= 0: + return { + "count": int(vals.size), + "finite_count": 0, + } + + if finite_count == vals.size: + finite = vals + else: + finite = vals[finite_mask] + + n = int(finite.size) + + max_samples = int( + getattr( + self, + "quality_stats_max_samples", + 65536, + ) + or 65536 + ) + + if max_samples <= 0: + max_samples = 65536 + + if n > max_samples: + stride = int( + math.ceil( + n / float(max_samples) + ) + ) + sample = finite[::stride] + else: + stride = 1 + sample = finite + + # Percentis em uma única chamada. + p01, p05, p50, p95, p99 = np.percentile( + sample, + [1, 5, 50, 95, 99], + ) + + sat_count = int( + np.count_nonzero( + finite >= 0.98 + ) + ) + dark_count = int( + np.count_nonzero( + finite <= 0.02 + ) + ) + over_count = int( + np.count_nonzero( + finite > 1.0 + ) + ) + under_count = int( + np.count_nonzero( + finite < 0.0 + ) + ) + + inv_n = 100.0 / float(n) return { "count": int(vals.size), - "finite_count": int(finite.size), + "finite_count": finite_count, + "sample_count": int(sample.size), + "sample_stride": int(stride), "min": float(np.min(finite)), - "p01": float(np.percentile(finite, 1)), - "p05": float(np.percentile(finite, 5)), - "p50": float(np.percentile(finite, 50)), - "p95": float(np.percentile(finite, 95)), - "p99": float(np.percentile(finite, 99)), + "p01": float(p01), + "p05": float(p05), + "p50": float(p50), + "p95": float(p95), + "p99": float(p99), "max": float(np.max(finite)), - "mean": float(np.mean(finite)), - "std": float(np.std(finite)), - "sat_pct": float((finite >= 0.98).mean() * 100.0), - "dark_pct": float((finite <= 0.02).mean() * 100.0), - "over_1_pct": float((finite > 1.0).mean() * 100.0), - "under_0_pct": float((finite < 0.0).mean() * 100.0), + "mean": float(np.mean(sample)), + "std": float(np.std(sample)), + "sat_pct": float(sat_count * inv_n), + "dark_pct": float(dark_count * inv_n), + "over_1_pct": float(over_count * inv_n), + "under_0_pct": float(under_count * inv_n), } def _tensor_channel_stats(self, tensor: np.ndarray, channel_names: list) -> dict: @@ -2503,6 +3171,107 @@ class RawProcessorCore: f"patch_warning:{warning_text}", ) + # ======================================================== + # Contrato obrigatório de calibração + # ======================================================== + + rad_norm_cfg = self.radiometric_normalization_config or {} + if bool(rad_norm_cfg.get("enabled", False)): + rad_result = self.last_radiometric_normalization_result or {} + + if not isinstance(rad_result, dict) or not bool(rad_result.get("applied", False)): + mark("bad", "radiometric_normalization_required_but_not_applied") + else: + roles_done = set((rad_result.get("by_role", {}) or {}).keys()) + missing_roles = [ + role + for role in ("rgb", "re", "nir") + if role not in roles_done + ] + + if missing_roles: + mark( + "bad", + "radiometric_normalization_missing_roles:" + + ",".join(missing_roles), + ) + + rad_warnings = [ + str(x) + for x in (rad_result.get("warnings", []) or []) + ] + + hard_rad_tokens = ( + "missing_frame_controls", + "invalid_factor", + "unsupported_method", + ) + + for warning in rad_warnings: + if any(token in warning for token in hard_rad_tokens): + mark("bad", f"radiometric_normalization:{warning}") + + quality["metrics"]["radiometric_normalization"] = rad_result + + flat_cfg = self.flatfield_config or {} + if bool(flat_cfg.get("enabled", False)): + if not bool(getattr(self, "flatfield_loaded", False)): + mark("bad", "flatfield_required_but_not_loaded") + + loaded_channels = sorted( + str(x) + for x in (getattr(self, "flatfield_maps", {}) or {}).keys() + ) + quality["metrics"]["flatfield_loaded_channels"] = loaded_channels + + required_flat_channels = {"R", "G", "B", "RE", "NIR"} + if not required_flat_channels.issubset(set(loaded_channels)): + mark( + "bad", + "flatfield_missing_channels:" + + ",".join( + sorted(required_flat_channels - set(loaded_channels)) + ), + ) + + flat_result = self.last_flatfield_result or {} + quality["metrics"]["flatfield_result"] = flat_result + + if ( + str(flat_cfg.get("apply_space", "native_camera_space")).lower() + == "native_camera_space" + and ( + not isinstance(flat_result, dict) + or not bool(flat_result.get("applied", False)) + ) + ): + mark( + "bad", + "flatfield_required_but_not_applied", + ) + + if getattr(self, "strict_product_contract", False): + fusion_result = self.last_fusion_result or {} + profiles_used = fusion_result.get("homography_profiles_used", {}) or {} + + for role in ("re", "nir"): + if role not in profiles_used: + mark("bad", f"homography_not_applied:{role}") + + intr = self.intrinsics_config or {} + if bool((intr.get("runtime_undistort", {}) or {}).get("enabled", False)): + mark("bad", "runtime_undistort_enabled_but_not_supported") + + quality["metrics"]["product_contract"] = { + "strict": True, + "frame_controls_source": getattr( + self, + "_last_frame_controls_source", + None, + ), + "homography_profiles_used": profiles_used, + } + # ======================================================== # Resultado final # ======================================================== @@ -2861,53 +3630,725 @@ class RawProcessorCore: ) + def _validate_product_runtime_contract(self, data: dict): + """ + Validação fail-closed do module_params montado pelo assembler de produção. + + Arquivos legados continuam aceitos fora do strict_product_contract. + """ + if not getattr(self, "strict_product_contract", False): + return True + + if self.module_params_schema != "multispec_module_params_v3": + raise RuntimeError( + f"Schema de module_params inválido: {self.module_params_schema!r}" + ) + + if str(data.get("frame_type", "RAW_BRUTO")).upper() != "RAW_BRUTO": + raise RuntimeError( + f"Produto exige frame_type='RAW_BRUTO', veio {data.get('frame_type')!r}." + ) + + if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"): + raise RuntimeError( + f"Bayer inválido no module_params: {self.bayer_pattern!r}" + ) + + if not isinstance(self.sensor_size_by_role, dict): + raise RuntimeError("sensor_size_by_role ausente/inválido.") + + if not isinstance(self.camera_hardware, dict): + raise RuntimeError("camera_hardware ausente/inválido.") + + for role in ("rgb", "re", "nir"): + size = self.sensor_size_by_role.get(role) + hw = self.camera_hardware.get(role) + + if not ( + isinstance(size, (list, tuple)) + and len(size) == 2 + and int(size[0]) > 0 + and int(size[1]) > 0 + ): + raise RuntimeError( + f"sensor_size_by_role.{role} inválido: {size}" + ) + + if not isinstance(hw, dict): + raise RuntimeError( + f"camera_hardware.{role} ausente." + ) + + hw_size = hw.get("size") + if hw_size is not None: + hw_size = [ + int(hw_size[0]), + int(hw_size[1]), + ] + expected_size = [ + int(size[0]), + int(size[1]), + ] + + if hw_size != expected_size: + raise RuntimeError( + f"Hardware/size divergente em {role}: " + f"hw={hw_size}, size={expected_size}" + ) + + rgb_size = [ + int(self.sensor_width), + int(self.sensor_height), + ] + expected_rgb = [ + int(x) + for x in self.sensor_size_by_role["rgb"] + ] + + if rgb_size != expected_rgb: + raise RuntimeError( + f"sensor_width/height root {rgb_size} != RGB {expected_rgb}" + ) + + rgb_mode = str( + (self.rgb_processing_config or {}).get( + "mode", + "", + ) + ).lower() + + allowed_rgb_modes = { + "linear_demosaic", + "demosaic", + "full_res", + "linear_demosaic_half", + "demosaic_half", + "full_demosaic_half", + "bayer_planes", + "bayer", + "half_res", + } + + if rgb_mode not in allowed_rgb_modes: + raise RuntimeError( + f"rgb_processing.mode inválido no produto: {rgb_mode!r}" + ) + + if ( + str( + (self.fusion_config or {}).get( + "alignment_mode", + "", + ) + ).lower() + != "homography" + ): + raise RuntimeError( + "Produto exige fusion_config.alignment_mode='homography'." + ) + + if not bool( + (self.flatfield_config or {}).get( + "enabled", + False, + ) + ): + raise RuntimeError( + "Produto exige flatfield_config.enabled=true." + ) + + flat_space = str( + (self.flatfield_config or {}).get( + "apply_space", + "native_camera_space", + ) + ).lower() + + if flat_space != "native_camera_space": + raise RuntimeError( + f"Produto exige Flat-Field no espaço nativo; " + f"apply_space={flat_space!r}" + ) + + if bool( + (self.flatfield_config or {}).get( + "subtract_dark", + False, + ) + ): + raise RuntimeError( + "Produto atual exige flatfield_config.subtract_dark=false." + ) + + if not self.flatfield_loaded: + raise RuntimeError( + "Flat-field produto não foi carregado integralmente." + ) + + rad_norm = self.radiometric_normalization_config or {} + + if not bool( + rad_norm.get( + "enabled", + False, + ) + ): + raise RuntimeError( + "Produto exige radiometric_normalization.enabled=true." + ) + + if ( + str( + rad_norm.get( + "method", + "", + ) + ).lower() + != "oak_ae_frame_controls_v1" + ): + raise RuntimeError( + f"Método radiométrico inválido no produto: " + f"{rad_norm.get('method')!r}" + ) + + factor_model = str( + rad_norm.get( + "factor_model", + "exposure_time_us_x_iso", + ) + ).lower() + + if factor_model != "exposure_time_us_x_iso": + raise RuntimeError( + f"factor_model radiométrico inválido: {factor_model!r}" + ) + + apply_stage = str( + rad_norm.get( + "apply_stage", + "after_dark_before_flat_gain", + ) + ).lower() + + if apply_stage != "after_dark_before_flat_gain": + raise RuntimeError( + f"apply_stage radiométrico incompatível: {apply_stage!r}" + ) + + refs = rad_norm.get( + "reference_controls", + {}, + ) or {} + + for role in ("rgb", "re", "nir"): + ref = refs.get(role) + if not isinstance(ref, dict): + raise RuntimeError( + f"radiometric_normalization sem reference_controls.{role}" + ) + + try: + exp = float( + ref.get( + "exposure_time_us", + 0, + ) + ) + iso = float( + ref.get( + "sensitivity_iso", + 0, + ) + ) + except Exception as exc: + raise RuntimeError( + f"reference_controls inválido em {role}: {ref}" + ) from exc + + if exp <= 0 or iso <= 0: + raise RuntimeError( + f"reference_controls inválido em {role}: {ref}" + ) + + if bool( + (self.radiometric_config or {}).get( + "enabled", + False, + ) + ): + raise RuntimeError( + "radiometric_config controller deve estar desligado no produto." + ) + + if bool( + (self.patch_normalization_config or {}).get( + "enabled", + False, + ) + ): + raise RuntimeError( + "patch_normalization deve estar desligado no produto." + ) + + if bool( + (self.rgb_calibration or {}).get( + "enabled", + False, + ) + ): + raise RuntimeError( + "rgb_calibration manual deve estar desligado no produto." + ) + + intr = self.intrinsics_config or {} + + if not bool( + intr.get( + "enabled", + False, + ) + ): + raise RuntimeError( + "Produto exige intrinsics_config.enabled=true." + ) + + und_cfg = ( + intr.get( + "runtime_undistort", + {}, + ) + or {} + ) + + if bool( + und_cfg.get( + "enabled", + False, + ) + ): + raise RuntimeError( + "runtime_undistort=true ainda não é permitido neste core. " + "Recalibre Homography no espaço undistorted e implemente esse " + "estágio antes de ativar." + ) + + intr_space = str( + intr.get( + "calibration_space", + "native_stream_no_external_undistort", + ) + ) + + for role in ("re", "nir"): + entry = self._resolve_homography_geometry_entry_for_role( + role + ) + + hom_space = str( + entry.get( + "coordinate_space", + ) + or "" + ) + + if ( + hom_space + and intr_space + and hom_space != intr_space + ): + raise RuntimeError( + f"Domínio geométrico divergente em {role}: " + f"intrinsics={intr_space}, homography={hom_space}" + ) + + cameras = ( + intr.get( + "cameras", + {}, + ) + or {} + ) + + for role in ("rgb", "re", "nir"): + cam = cameras.get(role) + + if not isinstance(cam, dict): + raise RuntimeError( + f"intrinsics_config.cameras.{role} ausente." + ) + + image_size = cam.get( + "image_size" + ) + expected = [ + int(x) + for x in self.sensor_size_by_role[role] + ] + + if not ( + isinstance(image_size, (list, tuple)) + and len(image_size) == 2 + and [ + int(image_size[0]), + int(image_size[1]), + ] + == expected + ): + raise RuntimeError( + f"Intrinsics size divergente em {role}: " + f"{image_size} != {expected}" + ) + + K = np.asarray( + cam.get( + "camera_matrix" + ), + dtype=np.float64, + ) + + D = np.asarray( + cam.get( + "dist_coeffs" + ), + dtype=np.float64, + ).reshape(-1) + + if ( + K.shape != (3, 3) + or not np.all( + np.isfinite(K) + ) + ): + raise RuntimeError( + f"Intrinsics K inválida em {role}." + ) + + if ( + D.size < 4 + or not np.all( + np.isfinite(D) + ) + ): + raise RuntimeError( + f"Intrinsics D inválida em {role}." + ) + + # Enhancement é não linear e, no caminho legado atual, acontece antes + # da normalização radiométrica. Produto calibrado não pode ativá-lo. + enh = self._get_rgb_enhancement_config() + + if bool( + enh.get( + "enabled", + False, + ) + ): + raise RuntimeError( + "rgb_processing.enhancement deve ficar disabled no pipeline " + "calibrado de produto." + ) + + for role in ("rgb", "re", "nir"): + if role not in ( + self.camera_settings + or {} + ): + raise RuntimeError( + f"camera_settings sem role {role}." + ) + + return True + + + def _validate_stream_camera_contract(self, frame: dict, meta: dict): + """ + Confere por frame se o RAW recebido pertence ao hardware homologado. + + Em modo produto: + - exige CAM_A/CAM_B/CAM_C por role; + - exige resolução nativa correta; + - exige RAW10 packed uint8 2D; + - exige sensor/socket coerentes quando informados; + - exige Bayer RGB coerente. + """ + if not getattr(self, "strict_product_contract", False): + return True + + if not isinstance(frame, dict) or not isinstance(meta, dict): + raise RuntimeError("Contrato stream inválido para produto.") + + stream_meta = ( + meta.get("stream_meta", {}) + if isinstance(meta.get("stream_meta"), dict) + else {} + ) + camera_frames = ( + meta.get("camera_frames", {}) + or stream_meta.get("camera_frames", {}) + or {} + ) + camera_info = ( + meta.get("camera_info", {}) + or stream_meta.get("camera_info", {}) + or {} + ) + + seen_roles = {} + + for cam_id, data in frame.items(): + cam_meta = {} + + if isinstance(camera_info.get(cam_id), dict): + cam_meta.update(camera_info.get(cam_id) or {}) + + if isinstance(camera_frames.get(cam_id), dict): + cam_meta.update(camera_frames.get(cam_id) or {}) + + role = str(cam_meta.get("role", "")).lower() + + if role not in ("rgb", "re", "nir"): + raise RuntimeError( + f"{cam_id}: role inválida/ausente no stream: {role!r}" + ) + + if role in seen_roles: + raise RuntimeError( + f"Role duplicada no stream: {role} " + f"em {seen_roles[role]} e {cam_id}" + ) + + seen_roles[role] = cam_id + + expected_size = self.sensor_size_by_role.get(role) + if not expected_size: + raise RuntimeError(f"MP sem sensor_size_by_role.{role}") + + width = cam_meta.get("width") + height = cam_meta.get("height") + + if width is None or height is None: + raise RuntimeError( + f"{cam_id}/{role}: metadata sem width/height nativos." + ) + + got = [int(width), int(height)] + expected = [int(expected_size[0]), int(expected_size[1])] + + if got != expected: + raise RuntimeError( + f"{cam_id}/{role}: resolução nativa {got} " + f"!= homologada {expected}" + ) + + arr = np.asarray(data) + expected_packed_width = self._raw10_expected_packed_width_fast( + expected[0] + ) + + if arr.ndim != 2 or arr.dtype != np.uint8: + raise RuntimeError( + f"{cam_id}/{role}: produto RAW_BRUTO exige " + f"RAW10 packed uint8 2D; shape={arr.shape}, dtype={arr.dtype}" + ) + + if arr.shape[0] != expected[1]: + raise RuntimeError( + f"{cam_id}/{role}: altura packed={arr.shape[0]} " + f"!= nativa={expected[1]}" + ) + + if arr.shape[1] < expected_packed_width: + raise RuntimeError( + f"{cam_id}/{role}: packed_width={arr.shape[1]} " + f"< mínimo RAW10={expected_packed_width}" + ) + + padding = int(arr.shape[1] - expected_packed_width) + if padding > 64: + raise RuntimeError( + f"{cam_id}/{role}: padding RAW10 excessivo={padding}" + ) + + bit_depth = int(cam_meta.get("bit_depth", 10)) + if bit_depth != 10: + raise RuntimeError( + f"{cam_id}/{role}: produto espera bit_depth=10, " + f"veio {bit_depth}" + ) + + expected_hw = self.camera_hardware.get(role, {}) or {} + expected_sensor = str( + expected_hw.get("sensor", "") or "" + ).upper() + + actual_sensor = str( + cam_meta.get("sensor") + or cam_meta.get("sensor_name") + or "" + ).upper() + + if ( + expected_sensor + and actual_sensor + and actual_sensor != expected_sensor + ): + raise RuntimeError( + f"{cam_id}/{role}: sensor={actual_sensor} " + f"!= homologado={expected_sensor}" + ) + + expected_socket = str( + expected_hw.get("socket", "") or "" + ) + + if ( + expected_socket + and str(cam_id).upper() != expected_socket.upper() + ): + raise RuntimeError( + f"{role}: cam_id={cam_id} " + f"!= socket homologado={expected_socket}" + ) + + if role == "rgb": + actual_bayer = str( + cam_meta.get("bayer_pattern") + or cam_meta.get("bayer") + or self.bayer_pattern + ).upper() + + if actual_bayer != self.bayer_pattern: + raise RuntimeError( + f"RGB Bayer do frame={actual_bayer} " + f"!= MP={self.bayer_pattern}" + ) + + missing = [ + role + for role in ("rgb", "re", "nir") + if role not in seen_roles + ] + + if missing: + raise RuntimeError( + f"Frame produto incompleto. Faltam roles: {missing}" + ) + + return True + def load_config_json(self, path: str): if not path or not os.path.isfile(path): - raise FileNotFoundError(f"Arquivo de calibração não encontrado: {path}") + raise FileNotFoundError( + f"Arquivo de calibração não encontrado: {path}" + ) with open(path, "r", encoding="utf-8") as f: data = json.load(f) - self.bayer_pattern = data.get("bayer_pattern", self.bayer_pattern) + if not isinstance(data, dict): + raise RuntimeError("module_params root deve ser dict/object.") + + self.calibration_json_path = path + self.calibration_base_dir = os.path.dirname(os.path.abspath(path)) + + self.strict_product_contract = bool( + isinstance(data.get("assembly_metadata"), dict) + and isinstance(data.get("camera_hardware"), dict) + ) + + self.module_params_schema = data.get("schema") + self.assembly_metadata = deepcopy(data.get("assembly_metadata", {}) or {}) + self.calibration_provenance = deepcopy( + data.get("calibration_provenance", {}) or {} + ) + self.camera_hardware = deepcopy(data.get("camera_hardware", {}) or {}) + self.sensor_size_by_role = deepcopy( + data.get("sensor_size_by_role", {}) or {} + ) + self.intrinsics_config = deepcopy(data.get("intrinsics_config", {}) or {}) + + if data.get("sensor_width") is not None: + self.sensor_width = int(data["sensor_width"]) + + if data.get("sensor_height") is not None: + self.sensor_height = int(data["sensor_height"]) + + self.bayer_pattern = str( + data.get("bayer_pattern", self.bayer_pattern) + ).upper() rgb_proc = data.get("rgb_processing") if isinstance(rgb_proc, dict): - self.rgb_processing_config = self._merge_config(self.rgb_processing_config, rgb_proc) + self.rgb_processing_config = self._merge_config( + self.rgb_processing_config, + rgb_proc, + ) fusion = data.get("fusion_config") if isinstance(fusion, dict): - self.fusion_config = self._merge_config(self.fusion_config, fusion) + self.fusion_config = self._merge_config( + self.fusion_config, + fusion, + ) + elif self.strict_product_contract: + raise RuntimeError("module_params produto sem fusion_config.") else: print("[WARN] JSON sem fusion_config. Mantendo config padrão.") rgb_cal = data.get("rgb_calibration") if isinstance(rgb_cal, dict): - self.rgb_calibration = self._merge_config(self.rgb_calibration, rgb_cal) + self.rgb_calibration = self._merge_config( + self.rgb_calibration, + rgb_cal, + ) + + radiometric = data.get("radiometric_config") + if isinstance(radiometric, dict): + self.radiometric_config = self._merge_config( + self.radiometric_config, + radiometric, + ) + + rad_norm = data.get("radiometric_normalization") + if isinstance(rad_norm, dict): + self.radiometric_normalization_config = self._merge_config( + self.radiometric_normalization_config, + rad_norm, + ) + + patch_norm = data.get("patch_normalization") + if isinstance(patch_norm, dict): + self.patch_normalization_config = self._merge_config( + self.patch_normalization_config, + patch_norm, + ) + + cam_set = data.get("camera_settings") + if isinstance(cam_set, dict): + self.camera_settings = self._merge_config( + self.camera_settings, + cam_set, + ) flatfield = data.get("flatfield_config") if isinstance(flatfield, dict): - self.flatfield_config = self._merge_config(self.flatfield_config, flatfield) - self.load_flatfield_maps() + self.flatfield_config = self._merge_config( + self.flatfield_config, + flatfield, + ) else: self.flatfield_config["enabled"] = False self.flatfield_maps = {} self.flatfield_loaded = False - radiometric = data.get("radiometric_config") - if isinstance(radiometric, dict): - self.radiometric_config = self._merge_config(self.radiometric_config, radiometric) + self.clear_direct_fusion_geometry_cache() + self._flatfield_runtime_cache = {} - rad_norm_config = data.get("radiometric_normalization") - if isinstance(rad_norm_config, dict): - self.radiometric_normalization_config = self._merge_config(self.radiometric_normalization_config, rad_norm_config) + if self.flatfield_config.get("enabled", False): + self.load_flatfield_maps() - patch_norm = data.get("patch_normalization") - if isinstance(patch_norm, dict): - self.patch_normalization_config = self._merge_config(self.patch_normalization_config, patch_norm) - - cam_set = data.get("camera_settings") - if isinstance(cam_set, dict): - self.camera_settings = self._merge_config(self.camera_settings, cam_set) + self._validate_product_runtime_contract(data) + return data def _merge_config(self, default_cfg: dict, loaded_cfg: dict) -> dict: cfg = json.loads(json.dumps(default_cfg)) @@ -2961,55 +4402,110 @@ class RawProcessorCore: npz_file = cfg.get("npz_file") if not npz_file: - print("[WARN] flatfield_config habilitado, mas sem npz_file.") + msg = "flatfield_config habilitado, mas sem npz_file." self.flatfield_maps = {} self.flatfield_loaded = False + + if getattr(self, "strict_product_contract", False): + raise RuntimeError(msg) + + print(f"[WARN] {msg}") return False npz_path = self._resolve_calibration_path(npz_file) if not os.path.isfile(npz_path): - print(f"[WARN] Arquivo flat-field não encontrado: {npz_file} -> {npz_path}") + msg = f"Arquivo flat-field não encontrado: {npz_file} -> {npz_path}" self.flatfield_maps = {} self.flatfield_loaded = False - return False - data = np.load(npz_path) + if getattr(self, "strict_product_contract", False): + raise FileNotFoundError(msg) + + print(f"[WARN] {msg}") + return False maps = {} channel_maps = cfg.get("channel_maps", {}) or {} channels = cfg.get("channels", ["R", "G", "B", "RE", "NIR"]) - for ch in channels: - ch = str(ch).upper() - ch_cfg = channel_maps.get(ch, {}) or {} + with np.load(npz_path, allow_pickle=False) as data: + for ch in channels: + ch = str(ch).upper() + ch_cfg = channel_maps.get(ch, {}) or {} - gain_key = ch_cfg.get("gain_key", f"gain_{ch}") - dark_key = ch_cfg.get("dark_median_key", f"dark_median_{ch}") + if isinstance(ch_cfg, str): + ch_cfg = {"gain_key": ch_cfg} - if gain_key not in data: - print(f"[WARN] Flat-field sem chave {gain_key} para canal {ch}.") - continue + gain_key = ch_cfg.get("gain_key", f"gain_{ch}") + dark_key = ch_cfg.get("dark_median_key", f"dark_median_{ch}") - entry = { - "gain": data[gain_key].astype(np.float32), - "gain_key": gain_key, - } + if gain_key not in data: + if getattr(self, "strict_product_contract", False): + raise RuntimeError( + f"Flat-field sem chave obrigatória {gain_key} para {ch}." + ) - if dark_key and dark_key in data: - entry["dark"] = data[dark_key].astype(np.float32) - entry["dark_key"] = dark_key + print(f"[WARN] Flat-field sem chave {gain_key} para canal {ch}.") + continue - maps[ch] = entry + gain = np.asarray(data[gain_key], dtype=np.float32) + + if gain.ndim != 2 or gain.size == 0: + raise RuntimeError( + f"Flat-field {ch}/{gain_key} shape inválido: {gain.shape}" + ) + + if not np.all(np.isfinite(gain)): + raise RuntimeError( + f"Flat-field {ch}/{gain_key} contém NaN/Inf." + ) + + if np.any(gain <= 0): + raise RuntimeError( + f"Flat-field {ch}/{gain_key} contém ganho <= 0." + ) + + entry = { + "gain": gain, + "gain_key": gain_key, + } + + if dark_key and dark_key in data: + dark = np.asarray(data[dark_key], dtype=np.float32) + if dark.ndim != 2 or not np.all(np.isfinite(dark)): + raise RuntimeError( + f"Dark map {ch}/{dark_key} inválido." + ) + + entry["dark"] = dark + entry["dark_key"] = dark_key + + maps[ch] = entry + + required = {"R", "G", "B", "RE", "NIR"} self.flatfield_maps = maps - self.flatfield_loaded = len(maps) > 0 + self.flatfield_loaded = required.issubset(set(maps)) + + if getattr(self, "strict_product_contract", False) and not self.flatfield_loaded: + missing = sorted(required - set(maps)) + raise RuntimeError( + f"Flat-field produto incompleto. Faltam canais: {missing}" + ) if self.flatfield_loaded: - print(f"[OK] Flat-field carregado: {npz_path} | canais={list(maps.keys())}") + print( + f"[OK] Flat-field carregado: {npz_path} | " + f"canais={list(maps.keys())}" + ) else: - print(f"[WARN] Flat-field habilitado, mas nenhum mapa foi carregado: {npz_path}") + print( + f"[WARN] Flat-field parcial: {npz_path} | " + f"canais={list(maps.keys())}" + ) + self._flatfield_runtime_cache = {} return self.flatfield_loaded def apply_dark_to_decoded(self, decoded: dict) -> dict: @@ -3077,50 +4573,97 @@ class RawProcessorCore: def apply_flat_gain_to_decoded(self, decoded: dict) -> dict: cfg = self.flatfield_config or {} + result = { + "enabled": bool(cfg.get("enabled", False)), + "applied": False, + "by_role": {}, + "warnings": [], + } + self.last_flatfield_result = result + if not cfg.get("enabled", False): + result["warnings"].append("flatfield_disabled") return decoded if not self.flatfield_loaded: self.load_flatfield_maps() if not self.flatfield_loaded: + result["warnings"].append("flatfield_maps_not_loaded") return decoded clip_output = bool(cfg.get("clip_output", True)) corrected = {} - # Guarda anti-roxo / anti-artefato em saturação - sat_guard_enabled = bool(cfg.get("saturation_guard_enabled", True)) - sat_mode = str(cfg.get("saturation_guard_mode", "fade_strength")).lower() - sat_threshold = float(cfg.get("saturation_guard_threshold", 0.97)) + sat_guard_enabled = bool( + cfg.get( + "saturation_guard_enabled", + True, + ) + ) + sat_mode = str( + cfg.get( + "saturation_guard_mode", + "fade_strength", + ) + ).lower() + sat_threshold = float( + cfg.get( + "saturation_guard_threshold", + 0.97, + ) + ) for cam_id, item in decoded.items(): - role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower() + role = str( + item.get("role") + or item.get("meta", {}).get("role") + or "" + ).lower() + img = item.get("image") if img is None: corrected[cam_id] = item + result["warnings"].append( + f"{cam_id}:missing_image" + ) continue new_item = dict(item) - new_meta = dict(item.get("meta", {}) or {}) + new_meta = dict( + item.get("meta", {}) + or {} + ) if role == "rgb": - if img.ndim != 3 or img.shape[2] < 3: + if ( + img.ndim != 3 + or img.shape[2] < 3 + ): corrected[cam_id] = item + result["warnings"].append( + f"{cam_id}:invalid_rgb_shape:{img.shape}" + ) continue - out = img.astype(np.float32).copy() + out, reused = self._radiometric_get_writable_float32_image( + img + ) - # Máscara comum RGB: - # se qualquer canal estiver perto de saturar, tratamos os 3 canais juntos. - # Isso evita R/G/B receberem correções diferentes e criarem magenta/roxo. - saturation_mask = None + # Máscara comum sem criar rgb_max float32 full-res. if sat_guard_enabled: - rgb_max = np.max(out[:, :, :3], axis=2) - saturation_mask = rgb_max >= sat_threshold + saturation_mask = ( + (out[:, :, 0] >= sat_threshold) + | (out[:, :, 1] >= sat_threshold) + | (out[:, :, 2] >= sat_threshold) + ) + else: + saturation_mask = None - for idx, ch in enumerate(("R", "G", "B")): + for idx, ch in enumerate( + ("R", "G", "B") + ): out[:, :, idx] = self._apply_flat_gain_single_channel( out[:, :, idx], ch, @@ -3130,29 +4673,61 @@ class RawProcessorCore: new_item["image"] = out + result["by_role"]["rgb"] = { + "camera_id": cam_id, + "channels": ["R", "G", "B"], + "applied": True, + "inplace_reused_input": bool(reused), + "shape": list(out.shape), + } + elif role in ("re", "nir"): - ch = "RE" if role == "re" else "NIR" + ch = ( + "RE" + if role == "re" + else "NIR" + ) + + img_f, reused = self._radiometric_get_writable_float32_image( + img + ) - # Para RE/NIR a guarda pode ser por canal mesmo. - # Não existe cor roxa aqui, mas ainda evita mexer em pixels clipados. - saturation_mask = None - img_f = img.astype(np.float32) if sat_guard_enabled: - saturation_mask = img_f >= sat_threshold + saturation_mask = ( + img_f >= sat_threshold + ) + else: + saturation_mask = None - new_item["image"] = self._apply_flat_gain_single_channel( + out = self._apply_flat_gain_single_channel( img_f, ch, clip_output=clip_output, saturation_mask=saturation_mask, ) + new_item["image"] = out + + result["by_role"][role] = { + "camera_id": cam_id, + "channels": [ch], + "applied": True, + "inplace_reused_input": bool(reused), + "shape": list(out.shape), + } + else: corrected[cam_id] = item + result["warnings"].append( + f"{cam_id}:unsupported_role:{role}" + ) continue new_meta["flatfield_applied"] = True - new_meta["flatfield_map_type"] = cfg.get("map_type", "gain") + new_meta["flatfield_map_type"] = cfg.get( + "map_type", + "gain", + ) new_meta["flatfield_saturation_guard"] = { "enabled": sat_guard_enabled, "mode": sat_mode, @@ -3162,6 +4737,45 @@ class RawProcessorCore: new_item["meta"] = new_meta corrected[cam_id] = new_item + required_roles = { + "rgb", + "re", + "nir", + } + applied_roles = { + role + for role, info in result["by_role"].items() + if isinstance(info, dict) + and bool(info.get("applied", False)) + } + + result["applied"] = ( + required_roles.issubset( + applied_roles + ) + ) + result["applied_roles"] = sorted( + applied_roles + ) + result["missing_roles"] = sorted( + required_roles + - applied_roles + ) + + if ( + getattr( + self, + "strict_product_contract", + False, + ) + and not result["applied"] + ): + raise RuntimeError( + "Flat-Field obrigatório não foi aplicado em todas as roles: " + f"missing={result['missing_roles']}" + ) + + self.last_flatfield_result = result return corrected def _subtract_dark_single_channel( @@ -3289,7 +4903,93 @@ class RawProcessorCore: saturation_mask = base >= sat_threshold # ============================================================ - # Calcula ganho efetivo + # Fast path Numba: saturation guard + gain + clip em uma passada. + # ============================================================ + if ( + _HAS_NUMBA + and sat_guard_enabled + and saturation_mask is not None + and sat_mode in ("fade_strength", "skip") + ): + base_c = base + if not base_c.flags.c_contiguous: + base_c = np.ascontiguousarray(base_c) + + gain_c = gain.astype(np.float32, copy=False) + if not gain_c.flags.c_contiguous: + gain_c = np.ascontiguousarray(gain_c) + + mask_c = np.asarray( + saturation_mask, + dtype=np.bool_, + ) + if not mask_c.flags.c_contiguous: + mask_c = np.ascontiguousarray(mask_c) + + if mask_c.shape != base_c.shape: + raise RuntimeError( + f"saturation_mask shape inválido: " + f"{mask_c.shape} != {base_c.shape}" + ) + + mode_code = 1 if sat_mode == "fade_strength" else 2 + + out = np.empty( + base_c.shape, + dtype=np.float32, + ) + + t_numba0 = time.perf_counter() + + _apply_flat_gain_guard_numba( + base_c, + gain_c, + mask_c, + out, + int(base_c.shape[0]), + int(base_c.shape[1]), + float(strength), + float(gain_min_runtime), + float(gain_max_runtime), + float(sat_soft_start), + float(sat_hard), + bool(clip_output), + int(mode_code), + ) + + t_total_ms = ( + time.perf_counter() + - t_total0 + ) * 1000.0 + + if not hasattr( + self, + "_last_flat_ch_perf_log_ts", + ): + self._last_flat_ch_perf_log_ts = 0.0 + + now = time.time() + + if ( + now + - self._last_flat_ch_perf_log_ts + >= 1.0 + ): + self._last_flat_ch_perf_log_ts = now + + print( + "[PERF][FLAT_CH_NUMBA] " + f"ch={ch} " + f"shape={base_c.shape} " + f"total={t_total_ms:.2f}ms " + f"mode={sat_mode} " + f"strength={strength:.2f}" + ) + + return out + + # ============================================================ + # Fallback NumPy: calcula ganho efetivo. # ============================================================ t0 = time.perf_counter() @@ -3382,8 +5082,14 @@ class RawProcessorCore: result["warnings"].append("missing_frame_controls") self.last_radiometric_normalization_result = result - if str(cfg.get("missing_controls_policy", "skip")).lower() == "raise": - raise RuntimeError("radiometric_normalization ativo, mas meta.frame_controls ausente.") + if ( + getattr(self, "strict_product_contract", False) + or str(cfg.get("missing_controls_policy", "skip")).lower() == "raise" + ): + raise RuntimeError( + "radiometric_normalization ativo, mas controles reais " + "da captura estão ausentes." + ) return decoded @@ -3422,8 +5128,14 @@ class RawProcessorCore: f"{role}:invalid_factor actual={actual_factor:.6g} ref={ref_factor:.6g}" ) - if str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise": - raise RuntimeError(f"Controles radiométricos inválidos para role={role}: {actual_ctrl}") + if ( + getattr(self, "strict_product_contract", False) + or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise" + ): + raise RuntimeError( + f"Controles radiométricos inválidos para role={role}: " + f"{actual_ctrl}" + ) continue @@ -3487,53 +5199,87 @@ class RawProcessorCore: self.last_radiometric_normalization_result = result return normalized - def _extract_frame_controls_from_meta_by_role(self, meta: dict | None) -> tuple[dict, dict]: + def _extract_frame_controls_from_meta_by_role( + self, + meta: dict | None, + ) -> tuple[dict, dict]: """ - Retorna: - controls_by_role = { - "rgb": {...}, - "re": {...}, - "nir": {...} - } + Resolve controles reais da captura com compatibilidade histórica. - controls_by_cam = { - "CAM_A": {...}, - "CAM_B": {...}, - "CAM_C": {...} - } + Prioridade: + 1. frame_controls + 2. actual_camera_controls + 3. camera_controls + 4. startup_camera_controls - Fonte principal: - meta["frame_controls"] - - Também aceita: - meta["stream_meta"]["frame_controls"] + Aceita chaves CAM_A/B/C ou rgb/re/nir. """ if not isinstance(meta, dict): return {}, {} - stream_meta = meta.get("stream_meta") if isinstance(meta.get("stream_meta"), dict) else None + stream_meta = ( + meta.get("stream_meta") + if isinstance(meta.get("stream_meta"), dict) + else {} + ) - frame_controls = meta.get("frame_controls") - if not isinstance(frame_controls, dict) and stream_meta is not None: - frame_controls = stream_meta.get("frame_controls") + controls = None + source_key = None - if not isinstance(frame_controls, dict) or not frame_controls: + for key in ( + "frame_controls", + "actual_camera_controls", + "camera_controls", + "startup_camera_controls", + ): + candidate = meta.get(key) + + if not isinstance(candidate, dict) and stream_meta: + candidate = stream_meta.get(key) + + if isinstance(candidate, dict) and candidate: + controls = candidate + source_key = key + break + + if not isinstance(controls, dict) or not controls: return {}, {} camera_info = meta.get("camera_info") - if not isinstance(camera_info, dict) and stream_meta is not None: + if not isinstance(camera_info, dict): camera_info = stream_meta.get("camera_info") camera_info = camera_info if isinstance(camera_info, dict) else {} controls_by_cam = {} controls_by_role = {} + role_to_cam = {} - for cam_id, ctrl in frame_controls.items(): + for cam_id, info in camera_info.items(): + if not isinstance(info, dict): + continue + role = str(info.get("role", "")).lower() + if role in ("rgb", "re", "nir"): + role_to_cam[role] = str(cam_id) + + for key, ctrl in controls.items(): if not isinstance(ctrl, dict): continue - cam_id = str(cam_id) + key_str = str(key) + key_lower = key_str.lower() + + if key_lower in ("rgb", "re", "nir"): + role = key_lower + cam_id = role_to_cam.get(role) + + if cam_id: + controls_by_cam[cam_id] = dict(ctrl) + + controls_by_role[role] = dict(ctrl) + continue + + cam_id = key_str controls_by_cam[cam_id] = dict(ctrl) role = str( @@ -3541,12 +5287,12 @@ class RawProcessorCore: ).lower() if not role: - # fallback defensivo caso algum meta antigo venha sem camera_info role = self._role_from_cam_id_fallback(cam_id) if role: controls_by_role[role] = dict(ctrl) + self._last_frame_controls_source = source_key return controls_by_role, controls_by_cam def _role_from_cam_id_fallback(self, cam_id: str) -> str: @@ -4256,17 +6002,53 @@ class RawProcessorCore: - def _direct_fusion_get_target_size_fast(self, ref_size): + def _direct_fusion_get_target_size_fast( + self, + ref_size, + target_size_override=None, + ): """ - Resolve target_size final como (target_w, target_h). - Usa fusion_config.target_size se existir, senão usa tamanho de referência. + Resolve o target final como (W,H). + + Prioridade: + 1. target_size_override do caller (treino/inferência); + 2. fusion_config.target_size; + 3. tamanho RGB/ref. + + Isso evita fuse -> resize extra no caminho canônico RAW_BRUTO. """ ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) - fusion = getattr(self, "fusion_config", {}) or {} - target_size = fusion.get("target_size", None) - if isinstance(target_size, (list, tuple)) and len(target_size) == 2: - return int(target_size[0]), int(target_size[1]) + if ( + isinstance(target_size_override, (list, tuple)) + and len(target_size_override) == 2 + ): + tw = int(target_size_override[0]) + th = int(target_size_override[1]) + + if tw <= 0 or th <= 0: + raise RuntimeError( + f"target_size_override inválido: {target_size_override}" + ) + + return tw, th + + fusion = getattr(self, "fusion_config", {}) or {} + target_size = fusion.get("target_size") + + if ( + isinstance(target_size, (list, tuple)) + and len(target_size) == 2 + ): + tw = int(target_size[0]) + th = int(target_size[1]) + + if tw <= 0 or th <= 0: + raise RuntimeError( + f"fusion_config.target_size inválido: {target_size}" + ) + + return tw, th return int(ref_w), int(ref_h) @@ -4285,48 +6067,85 @@ class RawProcessorCore: return (0, 0, ref_w, ref_h), False - def _direct_fusion_crop_to_target_matrix_fast(self, crop_box, target_size): + def _direct_fusion_crop_to_target_matrix_fast( + self, + crop_box, + target_size, + ): """ - Matriz C que leva coordenadas do espaço RGB/ref para a saída final. + Matriz C: RGB/reference -> target final. - crop_box está no espaço da imagem de referência: - x0,y0,x1,y1 + Replica crop + cv2.resize com convenção de centro de pixel: - Queremos: - x=x0 -> 0 - x=x1 -> target_w - y=y0 -> 0 - y=y1 -> target_h - - Retorna C_ref_to_target. + x_crop = x_ref - x0 + x_target = (x_crop + 0.5) * sx - 0.5 """ - x0, y0, x1, y1 = [float(v) for v in crop_box] - target_w, target_h = int(target_size[0]), int(target_size[1]) + x0, y0, x1, y1 = [ + float(v) + for v in crop_box + ] - crop_w = max(1.0, x1 - x0) - crop_h = max(1.0, y1 - y0) + target_w, target_h = [ + int(v) + for v in target_size + ] - sx = float(target_w) / crop_w - sy = float(target_h) / crop_h + crop_w = max( + 1.0, + x1 - x0, + ) + crop_h = max( + 1.0, + y1 - y0, + ) - C = np.array( + sx = ( + float(target_w) + / crop_w + ) + sy = ( + float(target_h) + / crop_h + ) + + tx = ( + -x0 * sx + + 0.5 * sx + - 0.5 + ) + ty = ( + -y0 * sy + + 0.5 * sy + - 0.5 + ) + + return np.array( [ - [sx, 0.0, -x0 * sx], - [0.0, sy, -y0 * sy], + [sx, 0.0, tx], + [0.0, sy, ty], [0.0, 0.0, 1.0], ], dtype=np.float32, ) - return C - - def _direct_fusion_scale_homography_for_ref_fast(self, H, meta, ref_size): + def _direct_fusion_scale_homography_for_ref_fast( + self, + H, + meta, + ref_size, + ): """ - Escala a homografia calibrada para o runtime da referência RGB. + Helper legado sem informação da source. - Usa a própria função existente _scale_homography_to_runtime() se existir. - Isso mantém compatibilidade com o contrato atual do core. + Em produto mixed-resolution ele é ambíguo e portanto proibido. + Use _direct_fusion_get_role_homography_fast(role, ..., source_size=...). """ + if getattr(self, "strict_product_contract", False): + raise RuntimeError( + "_direct_fusion_scale_homography_for_ref_fast() não pode ser usado " + "no produto mixed-resolution porque não conhece source_size." + ) + fusion = getattr(self, "fusion_config", {}) or {} ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) @@ -4337,118 +6156,137 @@ class RawProcessorCore: or None ) - H = np.asarray(H, dtype=np.float32) - - if hasattr(self, "_scale_homography_to_runtime"): - try: - return self._scale_homography_to_runtime( - H, - calib_size=calib_size, - runtime_size=(ref_w, ref_h), - ).astype(np.float32) - except TypeError: - try: - return self._scale_homography_to_runtime( - H, - calib_size, - (ref_w, ref_h), - ).astype(np.float32) - except Exception: - pass - except Exception: - pass - - # Fallback local. - if calib_size is None: - if abs(float(H[2, 2])) > 1e-9: - H = H / H[2, 2] - return H.astype(np.float32) - - calib_w, calib_h = float(calib_size[0]), float(calib_size[1]) - if calib_w <= 0 or calib_h <= 0: - return H.astype(np.float32) - - sx = float(ref_w) / calib_w - sy = float(ref_h) / calib_h - - S = np.array([[sx, 0.0, 0.0], [0.0, sy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32) - S_inv = np.array([[1.0 / sx, 0.0, 0.0], [0.0, 1.0 / sy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32) - - H_runtime = S @ H @ S_inv - if abs(float(H_runtime[2, 2])) > 1e-9: - H_runtime = H_runtime / H_runtime[2, 2] - - return H_runtime.astype(np.float32) - - def _direct_fusion_get_role_homography_fast(self, role, meta, ref_size): - """ - Retorna H_role_to_rgb escalada para o espaço da referência RGB. - - Suporta: - - contrato antigo: fusion_config.homographies.re_to_rgb/nir_to_rgb - - contrato novo: fusion_config.homography_profiles..homographies.* - """ - role = str(role).lower() - - H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) - - ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) - - H_scaled = self._scale_homography_to_runtime( + return self._scale_homography_to_runtime( H, calib_size=calib_size, runtime_size=(ref_w, ref_h), + ).astype(np.float32) + + def _direct_fusion_get_role_homography_fast( + self, + role, + meta, + ref_size, + source_size=None, + ): + """ + Retorna H_role_to_rgb no espaço REAL de runtime. + + ref_size: + (ref_h, ref_w) da imagem RGB já decodificada. + + source_size: + (src_h, src_w) da banda RE/NIR REAL, sem resize prévio. + """ + role = str(role).lower() + entry = self._resolve_homography_geometry_entry_for_role(role) + + ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) + + if source_size is None: + src_w, src_h = entry["source_calibration_size"] + else: + src_h, src_w = int(source_size[0]), int(source_size[1]) + + H_scaled = self._scale_homography_to_runtime( + entry["H"], + calib_source_size=entry["source_calibration_size"], + calib_reference_size=entry["reference_calibration_size"], + runtime_source_size=(int(src_w), int(src_h)), + runtime_reference_size=(int(ref_w), int(ref_h)), ) if H_scaled is None or H_scaled.shape != (3, 3): raise RuntimeError( - f"Homografia inválida para role={role}, profile={profile_name}: " + f"Homografia inválida para role={role}, " + f"profile={entry['profile_name']}: " f"shape={None if H_scaled is None else H_scaled.shape}" ) return H_scaled.astype(np.float32) - def _direct_fusion_resize_spec_to_ref_if_needed_fast(self, img, ref_size): + def _direct_fusion_resize_spec_to_ref_if_needed_fast( + self, + img, + ref_size, + ): """ - Mantém compatibilidade com o fluxo atual: - se RE/NIR não estão no mesmo shape do RGB de referência, redimensiona para ref. + Compatibilidade legado. + + No pipeline de produto mixed-resolution, pré-resize da banda antes da + homografia é proibido. O caminho ativo não chama mais este método. + + Em MP legado, preserva o comportamento histórico. """ ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) if img.shape[:2] == (ref_h, ref_w): return img + if getattr(self, "strict_product_contract", False): + raise RuntimeError( + "Pré-resize de RE/NIR para o tamanho RGB é inválido no " + "contrato mixed-resolution de produto." + ) + return cv2.resize( img.astype(np.float32, copy=False), (ref_w, ref_h), interpolation=cv2.INTER_LINEAR, ) - def _direct_fusion_compute_valid_masks_fast(self, decoded, role_to_cam, ref_size, meta): + def _direct_fusion_compute_valid_masks_fast( + self, + decoded, + role_to_cam, + ref_size, + meta, + ): """ - Calcula máscaras válidas no espaço RGB/ref para crop comum. - Usa warpPerspective apenas em máscara uint8, que costuma ser barato. + Máscaras válidas no espaço RGB/ref para mixed-resolution. + + A máscara de cada banda nasce no tamanho REAL da própria source. """ ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) - valid_masks = [np.ones((ref_h, ref_w), dtype=np.uint8)] - - base = np.ones((ref_h, ref_w), dtype=np.uint8) * 255 + valid_masks = [ + np.ones((ref_h, ref_w), dtype=np.uint8) + ] for role in ("re", "nir"): - if role not in role_to_cam: + cam_id = role_to_cam.get(role) + if cam_id is None: continue - H = self._direct_fusion_get_role_homography_fast(role, meta, ref_size) + img = decoded[cam_id]["image"] + + H = self._direct_fusion_get_role_homography_fast( + role, + meta, + ref_size, + source_size=img.shape[:2], + ) + + source_mask = ( + np.ones( + img.shape[:2], + dtype=np.uint8, + ) + * 255 + ) + mask = cv2.warpPerspective( - base, + source_mask, H, (ref_w, ref_h), flags=cv2.INTER_NEAREST, borderMode=cv2.BORDER_CONSTANT, borderValue=0, ) - valid_masks.append(mask) + + valid_masks.append( + (mask > 0).astype(np.uint8) + ) return valid_masks @@ -4475,23 +6313,34 @@ class RawProcessorCore: tensor[1] = rgb_out[:, :, 1] tensor[2] = rgb_out[:, :, 2] - def _direct_fusion_write_spec_fast(self, tensor, channel_index, img, role, C_ref_to_target, ref_size, target_size, meta): + def _direct_fusion_write_spec_fast( + self, + tensor, + channel_index, + img, + role, + C_ref_to_target, + ref_size, + target_size, + meta, + ): """ - Escreve RE ou NIR direto no tensor final, compondo: - M = C_ref_to_target @ H_role_to_rgb + RE/NIR native -> target final em um único warp. - img é primeiro redimensionada para ref_size se necessário, para manter o mesmo - comportamento geométrico do fluxo atual. + M = C_ref_to_target @ H_runtime(source_real -> RGB/ref_real) """ target_w, target_h = int(target_size[0]), int(target_size[1]) - img_ref = self._direct_fusion_resize_spec_to_ref_if_needed_fast(img, ref_size) - - H_role_to_rgb = self._direct_fusion_get_role_homography_fast(role, meta, ref_size) + H_role_to_rgb = self._direct_fusion_get_role_homography_fast( + role, + meta, + ref_size, + source_size=img.shape[:2], + ) M_role_to_target = (C_ref_to_target @ H_role_to_rgb).astype(np.float32) out = cv2.warpPerspective( - img_ref.astype(np.float32, copy=False), + img.astype(np.float32, copy=False), M_role_to_target, (target_w, target_h), flags=cv2.INTER_LINEAR, @@ -4501,9 +6350,18 @@ class RawProcessorCore: tensor[int(channel_index)] = out - def _fuse_multispec_direct_to_target_fast(self, decoded, meta, channels_expected): + def _fuse_multispec_direct_to_target_fast( + self, + decoded, + meta, + channels_expected, + target_size_override=None, + ): """ Fusão direta otimizada com cache de geometria fixa. + + target_size_override permite ir diretamente ao tamanho final pedido + pelo treino/inferência, evitando um segundo resize posterior. """ channels_expected = self._validate_physical_channel_count(channels_expected) t0 = time.perf_counter() @@ -4518,7 +6376,10 @@ class RawProcessorCore: ref_h, ref_w = rgb.shape[:2] ref_size = (int(ref_h), int(ref_w)) - target_size = self._direct_fusion_get_target_size_fast(ref_size) + target_size = self._direct_fusion_get_target_size_fast( + ref_size, + target_size_override=target_size_override, + ) target_w, target_h = int(target_size[0]), int(target_size[1]) role_to_cam = { @@ -4747,39 +6608,49 @@ class RawProcessorCore: - def _direct_fusion_get_geometry_cache_key_fast(self, ref_size, target_size, role_to_cam): + def _direct_fusion_get_geometry_cache_key_fast( + self, + decoded, + ref_size, + target_size, + role_to_cam, + ): """ - Chave simples e estável para cache da geometria. - - Considera: - - tamanho do RGB/ref - - target final - - roles presentes - - crop/resize - - homografia efetivamente selecionada por perfil - - calibration_size efetivo por role + Mixed-resolution exige que o tamanho REAL de cada source faça parte + da chave, pois H_runtime depende de source_size e reference_size. """ ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) target_w, target_h = int(target_size[0]), int(target_size[1]) fusion = getattr(self, "fusion_config", {}) or {} - roles = tuple(sorted([str(r).lower() for r in role_to_cam.keys()])) + roles = tuple(sorted(str(r).lower() for r in role_to_cam.keys())) + + role_shapes = [] + for role in roles: + cam_id = role_to_cam.get(role) + if cam_id is None: + continue + img = decoded[cam_id].get("image") + if img is None: + continue + role_shapes.append((role, tuple(int(v) for v in img.shape[:2]))) def h_sig_for_role(role): role = str(role).lower() - if role not in role_to_cam: return None try: - H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) + e = self._resolve_homography_geometry_entry_for_role(role) except Exception: return None - arr = np.asarray(H, dtype=np.float32).reshape(-1) + arr = np.asarray(e["H"], dtype=np.float32).reshape(-1) return ( - str(profile_name), - tuple(calib_size or []), + str(e["profile_name"]), + tuple(e["source_calibration_size"] or []), + tuple(e["reference_calibration_size"] or []), + str(e.get("coordinate_space") or ""), tuple(np.round(arr, 8).tolist()), ) @@ -4789,6 +6660,7 @@ class RawProcessorCore: target_w, target_h, roles, + tuple(sorted(role_shapes)), bool(fusion.get("crop_valid_common", False)), bool(fusion.get("resize_after_crop", False)), h_sig_for_role("re"), @@ -4807,26 +6679,31 @@ class RawProcessorCore: self._direct_fusion_remap_cache_hits = 0 self._direct_fusion_remap_cache_misses = 0 - def _direct_fusion_get_geometry_cached_fast(self, decoded, role_to_cam, ref_size, target_size, meta): + def _direct_fusion_get_geometry_cached_fast( + self, + decoded, + role_to_cam, + ref_size, + target_size, + meta, + ): """ - Retorna geometria cacheada para a fusão direta. + Geometria fixa cacheada. - Saída: - geom = { - key, - crop_box, - crop_applied, - C_ref_to_target, - H_role_to_rgb: {re,nir}, - M_role_to_target: {re,nir}, - valid_masks, # opcional/debug - prepare_cache_hit, - } + H_role_to_rgb sempre mapeia: + banda no tamanho REAL atual -> RGB/ref atual. + + Nenhum resize de RE/NIR acontece antes da homografia. """ if not hasattr(self, "_direct_fusion_geometry_cache"): self.clear_direct_fusion_geometry_cache() - key = self._direct_fusion_get_geometry_cache_key_fast(ref_size, target_size, role_to_cam) + key = self._direct_fusion_get_geometry_cache_key_fast( + decoded, + ref_size, + target_size, + role_to_cam, + ) cache = self._direct_fusion_geometry_cache if key in cache: @@ -4841,34 +6718,46 @@ class RawProcessorCore: ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) - # ------------------------------------------------------------ - # Homografias escaladas para runtime. - # ------------------------------------------------------------ H_role_to_rgb = {} homography_profiles_used = {} - for role in ("re", "nir"): - if role in role_to_cam: - H_raw, calib_size, profile_name = self._resolve_homography_entry_for_role(role) - homography_profiles_used[role] = { - "profile": profile_name, - "calib_size": list(calib_size) if calib_size is not None else None, - } - H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast(role, meta, ref_size) - - # ------------------------------------------------------------ - # Máscaras válidas e crop comum. - # Essa era uma das partes caras e totalmente fixa. - # ------------------------------------------------------------ - valid_masks = [np.ones((ref_h, ref_w), dtype=np.uint8)] - base = np.ones((ref_h, ref_w), dtype=np.uint8) * 255 + source_shapes = {} for role in ("re", "nir"): - if role not in H_role_to_rgb: + if role not in role_to_cam: continue + cam_id = role_to_cam[role] + img = decoded[cam_id]["image"] + src_shape = tuple(int(v) for v in img.shape[:2]) + source_shapes[role] = src_shape + + entry = self._resolve_homography_geometry_entry_for_role(role) + + homography_profiles_used[role] = { + "profile": entry["profile_name"], + "source_calibration_size": list(entry["source_calibration_size"]), + "reference_calibration_size": list(entry["reference_calibration_size"]), + "coordinate_space": entry.get("coordinate_space"), + "runtime_source_shape": list(src_shape), + "runtime_reference_shape": [ref_h, ref_w], + } + + H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast( + role, + meta, + ref_size, + source_size=src_shape, + ) + + valid_masks = [np.ones((ref_h, ref_w), dtype=np.uint8)] + + for role, H in H_role_to_rgb.items(): + src_h, src_w = source_shapes[role] + source_mask = np.ones((src_h, src_w), dtype=np.uint8) * 255 + mask = cv2.warpPerspective( - base, - H_role_to_rgb[role], + source_mask, + H, (ref_w, ref_h), flags=cv2.INTER_NEAREST, borderMode=cv2.BORDER_CONSTANT, @@ -4876,19 +6765,20 @@ class RawProcessorCore: ) valid_masks.append(mask) - crop_box, crop_applied = self._direct_fusion_get_crop_box_fast(valid_masks, ref_size) + crop_box, crop_applied = self._direct_fusion_get_crop_box_fast( + valid_masks, + ref_size, + ) - # ------------------------------------------------------------ - # Matriz crop RGB/ref -> target final. - # ------------------------------------------------------------ - C_ref_to_target = self._direct_fusion_crop_to_target_matrix_fast(crop_box, target_size) + C_ref_to_target = self._direct_fusion_crop_to_target_matrix_fast( + crop_box, + target_size, + ) - # ------------------------------------------------------------ - # Matrizes compostas role original/ref -> target. - # ------------------------------------------------------------ - M_role_to_target = {} - for role, H in H_role_to_rgb.items(): - M_role_to_target[role] = (C_ref_to_target @ H).astype(np.float32) + M_role_to_target = { + role: (C_ref_to_target @ H).astype(np.float32) + for role, H in H_role_to_rgb.items() + } geom = { "key": key, @@ -4897,6 +6787,7 @@ class RawProcessorCore: "C_ref_to_target": C_ref_to_target.astype(np.float32), "H_role_to_rgb": H_role_to_rgb, "M_role_to_target": M_role_to_target, + "source_shapes": source_shapes, "valid_masks": valid_masks, "prepare_cache_hit": False, "cache_hits": int(self._direct_fusion_geometry_cache_hits), @@ -4904,28 +6795,33 @@ class RawProcessorCore: "homography_profiles_used": homography_profiles_used, } - # Cache pequeno: normalmente só uma geometria. Se mudar resolução/config, - # evita crescimento infinito. if len(cache) > 4: cache.clear() cache[key] = geom return geom - def _direct_fusion_write_spec_cached_fast(self, tensor, channel_index, img, role, geom, ref_size, target_size): + def _direct_fusion_write_spec_cached_fast( + self, + tensor, + channel_index, + img, + role, + geom, + ref_size, + target_size, + ): """ - Escreve RE/NIR usando matriz composta cacheada. + RE/NIR native -> target final em um único warp. """ target_w, target_h = int(target_size[0]), int(target_size[1]) - img_ref = self._direct_fusion_resize_spec_to_ref_if_needed_fast(img, ref_size) - M_role_to_target = geom["M_role_to_target"].get(str(role).lower()) if M_role_to_target is None: raise RuntimeError(f"Matriz composta ausente para role={role}") out = cv2.warpPerspective( - img_ref.astype(np.float32, copy=False), + img.astype(np.float32, copy=False), M_role_to_target, (target_w, target_h), flags=cv2.INTER_LINEAR, @@ -5046,7 +6942,10 @@ class RawProcessorCore: target_size: tuple, ): """ - Retorna mapas de remap cacheados para RGB, RE e NIR. + Retorna remaps cacheados. + + M_role_to_target já nasce no espaço da source REAL. + Não existe resize/compensação posterior. """ if not hasattr(self, "_direct_fusion_remap_cache"): self._direct_fusion_remap_cache = {} @@ -5083,25 +6982,16 @@ class RawProcessorCore: "cache_misses": int(self._direct_fusion_remap_cache_misses), } - # ------------------------------------------------------------ - # RGB: matriz C_ref_to_target leva RGB/ref -> target. - # ------------------------------------------------------------ rgb_cam_id = role_to_cam.get("rgb") if rgb_cam_id is not None: rgb_img = decoded[rgb_cam_id]["image"] - C_ref_to_target = geom["C_ref_to_target"] - remap["maps"]["rgb"] = self._direct_fusion_build_remap_from_src_to_dst_fast( - M_src_to_dst=C_ref_to_target, + M_src_to_dst=geom["C_ref_to_target"], src_shape=rgb_img.shape[:2], dst_size=(target_w, target_h), ref_shape_for_scaled_src=None, ) - # ------------------------------------------------------------ - # RE/NIR: matriz composta M_role_to_target leva role/ref -> target. - # Se a imagem fonte não tiver o mesmo tamanho do ref, escalamos o mapa. - # ------------------------------------------------------------ for role in ("re", "nir"): cam_id = role_to_cam.get(role) if cam_id is None: @@ -5117,7 +7007,7 @@ class RawProcessorCore: M_src_to_dst=M_role_to_target, src_shape=img.shape[:2], dst_size=(target_w, target_h), - ref_shape_for_scaled_src=ref_size, + ref_shape_for_scaled_src=None, ) if len(cache) > 4: @@ -5199,30 +7089,24 @@ class RawProcessorCore: return "default" - def _resolve_homography_entry_for_role(self, role: str): + def _resolve_homography_geometry_entry_for_role(self, role: str) -> dict: """ - Resolve a homografia no contrato novo ou antigo. + Resolve H preservando os espaços de calibração de origem e referência. Contrato novo: - fusion_config.homography_profiles..homographies._to_rgb - - Contrato antigo: - fusion_config.homographies._to_rgb - - Retorna: - H, calib_size, profile_name + fusion_config.homography_profiles. + .reference_size + .source_size_by_role[re|nir] + .homographies._to_rgb """ role = str(role).lower() + if role not in ("re", "nir"): + raise ValueError(f"Role espectral inválida: {role}") + fusion = getattr(self, "fusion_config", {}) or {} - key = f"{role}_to_rgb" - selected_profile = self._resolve_homography_profile_name_for_role(role) - # ------------------------------------------------------------ - # Futuro: auto por profundidade. - # Por enquanto, cai em media/default de forma explícita. - # ------------------------------------------------------------ if selected_profile == "auto": profiles = fusion.get("homography_profiles", {}) or {} if "media" in profiles: @@ -5232,15 +7116,12 @@ class RawProcessorCore: else: selected_profile = "" - # ------------------------------------------------------------ - # Contrato novo: homography_profiles - # ------------------------------------------------------------ profiles = fusion.get("homography_profiles", {}) or {} + if isinstance(profiles, dict) and selected_profile: profile = profiles.get(selected_profile) if profile is None: - # tolera nomes com caixa diferente for name, item in profiles.items(): if str(name).lower() == selected_profile: profile = item @@ -5248,45 +7129,109 @@ class RawProcessorCore: break if isinstance(profile, dict): - profile_homographies = profile.get("homographies", {}) or {} - H = profile_homographies.get(key) - + homographies = profile.get("homographies", {}) or {} + H = homographies.get(key) if H is None: - # fallback curto: "re" ou "nir" - H = profile_homographies.get(role) + H = homographies.get(role) if H is not None: - calib_size = ( - profile.get("homography_calibration_size") + reference_size = ( + profile.get("reference_size") + or profile.get("homography_calibration_size") or profile.get("calibration_size") or fusion.get("homography_calibration_size") or fusion.get("calibration_size") - or None ) - return H, calib_size, selected_profile - # ------------------------------------------------------------ - # Contrato antigo: homographies direto - # ------------------------------------------------------------ + source_sizes = profile.get("source_size_by_role", {}) or {} + source_size = ( + source_sizes.get(role) + or profile.get("source_size") + or reference_size + ) + + if reference_size is None or source_size is None: + raise RuntimeError( + f"Perfil {selected_profile!r} sem tamanhos geométricos " + f"suficientes para role={role}." + ) + + if len(reference_size) != 2 or len(source_size) != 2: + raise RuntimeError( + f"Tamanhos inválidos no perfil {selected_profile!r}: " + f"source={source_size}, reference={reference_size}" + ) + + coordinate_space = ( + profile.get("coordinate_space") + or fusion.get("coordinate_space") + or "native_stream_no_external_undistort" + ) + + H_arr = np.asarray(H, dtype=np.float32) + if H_arr.shape != (3, 3) or not np.all(np.isfinite(H_arr)): + raise RuntimeError( + f"Homografia inválida no perfil={selected_profile} role={role}" + ) + + return { + "H": H_arr, + "source_calibration_size": [ + int(source_size[0]), + int(source_size[1]), + ], + "reference_calibration_size": [ + int(reference_size[0]), + int(reference_size[1]), + ], + "profile_name": selected_profile, + "coordinate_space": str(coordinate_space), + } + + # Compatibilidade legado, onde as duas grades tinham o mesmo tamanho. homographies = fusion.get("homographies", {}) or {} H = homographies.get(key) - if H is None: H = homographies.get(role) if H is not None: - calib_size = ( + common_size = ( fusion.get("homography_calibration_size") or fusion.get("calibration_size") - or None + or [self.sensor_width, self.sensor_height] ) - return H, calib_size, "legacy" + + H_arr = np.asarray(H, dtype=np.float32) + if H_arr.shape != (3, 3): + raise RuntimeError(f"Homografia legacy inválida para {role}: {H_arr.shape}") + + return { + "H": H_arr, + "source_calibration_size": [int(common_size[0]), int(common_size[1])], + "reference_calibration_size": [int(common_size[0]), int(common_size[1])], + "profile_name": "legacy", + "coordinate_space": "legacy_common_pixel_space", + } raise RuntimeError( - f"Homografia ausente para role={role}. " - f"Procurei profile='{selected_profile}' em " - f"fusion_config.homography_profiles.*.homographies.{key} " - f"e fallback fusion_config.homographies.{key}" + f"Homografia ausente para role={role}; profile={selected_profile!r}." + ) + + def _resolve_homography_entry_for_role(self, role: str): + """ + Compatibilidade com callers antigos. + + Retorna: + H, reference_calibration_size, profile_name + + A geometria mixed-resolution usa internamente + _resolve_homography_geometry_entry_for_role(). + """ + entry = self._resolve_homography_geometry_entry_for_role(role) + return ( + entry["H"], + entry["reference_calibration_size"], + entry["profile_name"], ) diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py index a8d70227b..dbf3982dc 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_preview.py @@ -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. + + Parâmetros + ---------- + sensor_width: + Largura NATIVA do RGB de referência. + + sensor_height: + Altura NATIVA do RGB de referência. + + bayer_pattern: + Bayer real da CAM_A, vindo do contrato homologado. + + sensor_name: + Opcional, somente rastreabilidade. + + strict_shape: + Se True, RAW recebido deve possuir exatamente HxW nativo. + Recomendado e default no produto. + """ + + 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) + + 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}" + ) + + 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", + } + + # ============================================================ + # Validação + # ============================================================ + + def _validate_raw( + self, + raw16: np.ndarray, + bit_depth: int, + ) -> np.ndarray: + if raw16 is None: + raise ValueError( + "RAW de preview é None." + ) + + 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[int] = None, - white_level: Optional[int] = None, - gamma: float = 2.2, - bit_depth: int = 10 + 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: """ - Conversão para visualização: - - auto-level - - gamma + 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. """ - max_val = float((1 << bit_depth) - 1) + raw = self._validate_raw( + raw16, + bit_depth, + ) - raw = raw16.astype(np.float32) + max_val = float( + (1 << int(bit_depth)) + - 1 + ) - if black_level is None: - black_level = float(raw.min()) - if white_level is None: - white_level = float(raw.max()) + black, white = ( + self._validate_levels( + black_level, + white_level, + max_val, + ) + ) - if white_level <= black_level: - norm = raw / 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: - norm = (raw - black_level) / (white_level - black_level) + sample = raw_f.reshape( + -1 + ) - norm = np.clip(norm, 0.0, 1.0) + if black is None: + black = float( + np.percentile( + sample, + low_p, + ) + ) - if gamma is not None and gamma > 0: - norm = np.power(norm, 1.0 / gamma) + if white is None: + white = float( + np.percentile( + sample, + high_p, + ) + ) - return (norm * 255.0).clip(0, 255).astype(np.uint8) + 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_BayerRG2RGB_EA, - "BGGR": cv2.COLOR_BayerBG2RGB_EA, - "GRBG": cv2.COLOR_BayerGR2RGB_EA, - "GBRG": cv2.COLOR_BayerGB2RGB_EA, - } + "RGGB": ( + cv2.COLOR_BayerBG2BGR_EA + ), + "BGGR": ( + cv2.COLOR_BayerRG2BGR_EA + ), + "GRBG": ( + cv2.COLOR_BayerGB2BGR_EA + ), + "GBRG": ( + cv2.COLOR_BayerGR2BGR_EA + ), + } - if self.bayer_pattern not in mapping: - raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}") + return mapping[ + self.bayer_pattern + ] - return mapping[self.bayer_pattern] + # ============================================================ + # Ajustes VISUAIS + # ============================================================ - def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray: + @staticmethod + def apply_preview_white_balance( + bgr: np.ndarray, + strength: float = 1.0, + ) -> np.ndarray: """ - Gray-world simples para deixar o preview mais agradável. - Não usar no raw de treino. + Gray-world simples somente para deixar o preview legível. + + Não usar no RAW científico. """ - img = bgr.astype(np.float32) + img = np.asarray( + bgr + ) - mean_b = float(img[:, :, 0].mean()) - mean_g = float(img[:, :, 1].mean()) - mean_r = float(img[:, :, 2].mean()) + if ( + img.ndim != 3 + or img.shape[2] != 3 + ): + raise ValueError( + "Preview WB exige BGR HxWx3; " + f"shape={img.shape}" + ) - mean_gray = (mean_b + mean_g + mean_r) / 3.0 + 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() diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py index 4759e33a0..45c9ebaaa 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/segformer_service.py @@ -1,14 +1,60 @@ # camera_worker/oak_fcc3_core/segformer_service.py # -*- coding: utf-8 -*- +""" +MultiSpecSegformerService - Production +====================================== + +Último estágio do pipeline multiespectral antes do WeedDetector. + +Contrato oficial: + RawProcessorCore / CameraMultispectral + -> Raw5 físico canônico float32 CHW: + [R, G, B, RE, NIR] + -> MultiSpecSegformerService + - valida contrato do ONNX + - deriva NDVI/NDRE SOMENTE se o modelo pedir + - NÃO redimensiona a entrada + - NÃO normaliza novamente + - executa somente a cabeça operacional selecionada + - exige máscara full-resolution + -> máscara binária uint8 HxW + -> WeedDetector + +Autoridades: + - Shape BCHW: sessão ONNX + - Ordem nominal de canais: config + metadata/sidecar do ONNX + - Normalização: embutida no ONNX + - Raw5 físico: sempre [R,G,B,RE,NIR] + +Canais suportados pelo modelo: + R, G, B, RE, NIR, NDVI, NDRE + +Cabeças operacionais: + target + vegetation + cana + semantic -> convertida em binário usando semantic_target_class + +Fail-closed produto: + - input ONNX deve ser BCHW estático + - quantidade de canais deve bater + - metadata nominal deve bater quando exigida + - normalization_embedded deve ser true quando declarada + - saída da cabeça escolhida deve existir exatamente + - máscara deve sair no mesmo HxW do input + - máscara final do WeedDetector deve ser 0/1 + - mudanças estruturais exigem recriar a sessão +""" + from __future__ import annotations -import time -import threading -import unicodedata import json +import threading +import time +import unicodedata from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, List, Sequence, Tuple import cv2 import numpy as np @@ -19,154 +65,349 @@ except Exception: ort = None +SEGFORMER_SERVICE_VERSION = "production_v1_2026_08_24" + PHYSICAL_CHANNEL_ORDER = ["R", "G", "B", "RE", "NIR"] DERIVED_CHANNEL_ORDER = ["NDVI", "NDRE"] SUPPORTED_CHANNEL_ORDER = PHYSICAL_CHANNEL_ORDER + DERIVED_CHANNEL_ORDER DEFAULT_CHANNEL_ORDER = PHYSICAL_CHANNEL_ORDER HEAD_COLORS_RGB = { - 0: (30, 30, 30), # background - 1: (255, 70, 30), # classe positiva da cabeça binária selecionada - 255: (0, 0, 0), # ignore/fallback + 0: (30, 30, 30), + 1: (255, 70, 30), + 255: (0, 0, 0), } -def get_input_channel_names(config: dict) -> List[str]: - names = config.get("input_channels", DEFAULT_CHANNEL_ORDER) +# ============================================================ +# Contrato de canais +# ============================================================ + +def parse_channel_list( + value, + fallback: Sequence[str] | None = None, +) -> List[str]: + if value is None: + names = list( + fallback + or PHYSICAL_CHANNEL_ORDER + ) + + elif isinstance( + value, + str, + ): + names = [ + x.strip().upper() + for x in value.split(",") + if x.strip() + ] - if isinstance(names, str): - names = [c.strip().upper() for c in names.split(",") if c.strip()] else: - names = [str(c).upper() for c in names] + names = [ + str(x).strip().upper() + for x in value + if str(x).strip() + ] + + if not names: + raise RuntimeError( + "input_channels vazio." + ) + + invalid = [ + x + for x in names + if x not in SUPPORTED_CHANNEL_ORDER + ] - invalid = [c for c in names if c not in SUPPORTED_CHANNEL_ORDER] if invalid: raise RuntimeError( - f"Canais inválidos em input_channels: {invalid}. " + f"Canais inválidos: {invalid}. " f"Suportados={SUPPORTED_CHANNEL_ORDER}" ) - if not names: - raise RuntimeError("input_channels vazio.") - - duplicates = sorted({c for c in names if names.count(c) > 1}) - if duplicates: - raise RuntimeError(f"Canais duplicados em input_channels: {duplicates}") - - configured_count = config.get("channels") - if configured_count is not None and int(configured_count) != len(names): + if len( + set(names) + ) != len(names): raise RuntimeError( - f"Config inconsistente: channels={configured_count}, mas " - f"input_channels possui {len(names)} itens: {names}" + f"Canais duplicados: {names}" ) return names -def get_input_channel_indices(config: dict) -> List[int | None]: - names = get_input_channel_names(config) +def get_input_channel_names( + config: dict, +) -> List[str]: + names = parse_channel_list( + config.get( + "input_channels" + ), + PHYSICAL_CHANNEL_ORDER, + ) + + configured_count = config.get( + "channels" + ) + + if ( + configured_count is not None + and int( + configured_count + ) != len(names) + ): + raise RuntimeError( + "Config inconsistente: " + f"channels={configured_count}, " + f"input_channels={names} ({len(names)})" + ) + + return names + + +def get_input_channel_indices( + config: dict, +) -> List[int | None]: + names = get_input_channel_names( + config + ) + return [ - PHYSICAL_CHANNEL_ORDER.index(c) if c in PHYSICAL_CHANNEL_ORDER else None + ( + PHYSICAL_CHANNEL_ORDER.index( + c + ) + if c + in PHYSICAL_CHANNEL_ORDER + else None + ) for c in names ] -def build_model_input_tensor( - raw5_chw: np.ndarray, - input_channel_names: List[str], - derived_config: dict | None = None, -) -> np.ndarray: - """Monta os canais nominais do modelo a partir do Raw5 físico canônico.""" - raw5 = np.asarray(raw5_chw) - if raw5.ndim != 3 or raw5.shape[0] != len(PHYSICAL_CHANNEL_ORDER): +def validate_derived_config( + config: dict | None, +) -> dict: + cfg = dict( + config + or {} + ) + + eps = float( + cfg.get( + "epsilon", + 1e-6, + ) + ) + + clip_min = float( + cfg.get( + "clip_min", + -1.0, + ) + ) + + clip_max = float( + cfg.get( + "clip_max", + 1.0, + ) + ) + + if ( + not np.isfinite( + eps + ) + or eps <= 0.0 + ): raise RuntimeError( - "Entrada física inválida: esperado Raw5 CHW " - f"{PHYSICAL_CHANNEL_ORDER}, veio shape={raw5.shape}" + f"derived_channels.epsilon inválido: {eps}" ) - names = [str(name).strip().upper() for name in input_channel_names] - if not names: - raise RuntimeError("Lista de canais nominais vazia.") - invalid = [name for name in names if name not in SUPPORTED_CHANNEL_ORDER] - if invalid: - raise RuntimeError(f"Canais nominais não suportados: {invalid}") - if len(set(names)) != len(names): - raise RuntimeError(f"Canais nominais duplicados: {names}") + if ( + not np.isfinite( + clip_min + ) + or not np.isfinite( + clip_max + ) + or clip_min >= clip_max + ): + raise RuntimeError( + "Faixa inválida em derived_channels: " + f"clip_min={clip_min}, " + f"clip_max={clip_max}" + ) - raw5 = np.ascontiguousarray(raw5, dtype=np.float32) + return { + "epsilon": eps, + "clip_min": clip_min, + "clip_max": clip_max, + } + + +def build_model_input_tensor( + raw5_chw: np.ndarray, + requested_channels: Sequence[str], + derived_config: dict | None = None, +) -> np.ndarray: + """ + ÚNICO lugar onde canais derivados são criados. + + Entrada física obrigatória: + [R,G,B,RE,NIR] + + Exemplos de saída: + 5ch -> [R,G,B,RE,NIR] + 7ch -> [R,G,B,RE,NIR,NDVI,NDRE] + + NDVI: + (NIR - R) / (NIR + R) + + NDRE: + (NIR - RE) / (NIR + RE) + """ + raw5 = np.asarray( + raw5_chw + ) + + if ( + raw5.ndim != 3 + or raw5.shape[0] + != len( + PHYSICAL_CHANNEL_ORDER + ) + ): + raise RuntimeError( + "Raw5 físico inválido: esperado " + f"{PHYSICAL_CHANNEL_ORDER}, " + f"shape={raw5.shape}" + ) + + names = parse_channel_list( + requested_channels + ) + + raw5 = np.ascontiguousarray( + raw5, + dtype=np.float32, + ) + + # Fast path mais comum. if names == PHYSICAL_CHANNEL_ORDER: return raw5 - cfg = derived_config or {} - eps = float(cfg.get("epsilon", 1e-6)) - clip_min = float(cfg.get("clip_min", -1.0)) - clip_max = float(cfg.get("clip_max", 1.0)) - if not np.isfinite(eps) or eps <= 0.0: - raise RuntimeError(f"derived_channels.epsilon inválido: {eps}") - if not np.isfinite(clip_min) or not np.isfinite(clip_max) or clip_min >= clip_max: - raise RuntimeError( - "Faixa inválida em derived_channels: " - f"clip_min={clip_min}, clip_max={clip_max}" - ) + cfg = validate_derived_config( + derived_config + ) + + eps = float( + cfg["epsilon"] + ) + clip_min = float( + cfg["clip_min"] + ) + clip_max = float( + cfg["clip_max"] + ) physical = { name: raw5[index] - for index, name in enumerate(PHYSICAL_CHANNEL_ORDER) + for index, name + in enumerate( + PHYSICAL_CHANNEL_ORDER + ) } + derived = {} - def normalized_difference(a: np.ndarray, b: np.ndarray) -> np.ndarray: + def normalized_difference( + a: np.ndarray, + b: np.ndarray, + ) -> np.ndarray: denominator = a + b - result = np.zeros_like(denominator, dtype=np.float32) - np.divide(a - b, denominator, out=result, where=np.abs(denominator) > eps) - np.nan_to_num(result, copy=False, nan=0.0, posinf=clip_max, neginf=clip_min) - np.clip(result, clip_min, clip_max, out=result) + + result = np.zeros_like( + denominator, + dtype=np.float32, + ) + + np.divide( + a - b, + denominator, + out=result, + where=np.abs( + denominator + ) > eps, + ) + + np.nan_to_num( + result, + copy=False, + nan=0.0, + posinf=clip_max, + neginf=clip_min, + ) + + np.clip( + result, + clip_min, + clip_max, + out=result, + ) + return result if "NDVI" in names: - derived["NDVI"] = normalized_difference(physical["NIR"], physical["R"]) + derived["NDVI"] = ( + normalized_difference( + physical["NIR"], + physical["R"], + ) + ) + if "NDRE" in names: - derived["NDRE"] = normalized_difference(physical["NIR"], physical["RE"]) + derived["NDRE"] = ( + normalized_difference( + physical["NIR"], + physical["RE"], + ) + ) - output = np.empty((len(names), raw5.shape[1], raw5.shape[2]), dtype=np.float32) - for index, name in enumerate(names): - output[index] = physical[name] if name in physical else derived[name] - return np.ascontiguousarray(output) + output = np.empty( + ( + len(names), + raw5.shape[1], + raw5.shape[2], + ), + dtype=np.float32, + ) + for index, name in enumerate( + names + ): + if name in physical: + output[index] = ( + physical[name] + ) + else: + output[index] = ( + derived[name] + ) + + return np.ascontiguousarray( + output + ) + + +# ============================================================ +# Runtime +# ============================================================ class MultiSpecSegformerService: """ - Runtime oficial Weed Worker multi-head. - - Contrato: - input: - tensor CHW float32 0..1 - normalmente [R,G,B,RE,NIR] - - backend: - ONNX Runtime + TensorRT - - modelo ONNX: - já contém: - - normalização - - SegFormer multi-head - - resize fullres - - argmax - e expõe as saídas multi-head. Este serviço executa somente uma das - cabeças operacionais por inferência: - - target_mask - - vegetation_mask - - cana_mask - - semantic_mask (convertida para binário pela classe escolhida) - - output: - np.ndarray uint8 HxW - máscara binária uint8 HxW da cabeça selecionada - 0 = classe negativa - 1 = classe positiva - - semantic_mask nunca é entregue diretamente ao WeedDetector. Quando - selecionada, ela é convertida para 0/1 usando semantic_target_class. + Runtime multi-head oficial do Weed Worker. """ MODE_ALIASES = { @@ -208,53 +449,193 @@ class MultiSpecSegformerService: "erva": 2, } - def __init__(self, model_config: dict, mostrar_log=print): - self.config = model_config or {} - self.mostrar_log = mostrar_log - self._runtime_lock = threading.RLock() + STRUCTURAL_CONFIG_KEYS = ( + "onnx_model_path", + "ia_model_path", + "model_path", + "onnx_path", + "input_channels", + "channels", + "derived_channels", + "runtime_backend", + "onnx_provider", + "onnx_output_kind", + "onnx_preprocess_norm", + "trust_input", + "require_onnx_metadata", + "strict_output_shape", + "allow_provider_fallback", + ) + + def __init__( + self, + model_config: dict, + mostrar_log=print, + ): + self.config = dict( + model_config + or {} + ) + + self.mostrar_log = ( + mostrar_log + ) + + self._runtime_lock = ( + threading.RLock() + ) + self._runtime_generation = 0 - self.input_channel_names = get_input_channel_names(self.config) - self.input_channel_indices = get_input_channel_indices(self.config) - self.channels = len(self.input_channel_names) - self.derived_channels_config = dict( - self.config.get("derived_channels", {}) or {} + self.input_channel_names = ( + get_input_channel_names( + self.config + ) ) - self.runtime_backend = str(self.config.get("runtime_backend", "onnx")).lower() - self.onnx_provider = str(self.config.get("onnx_provider", "tensorrt")).lower() - # runtime_mode é a fonte única da verdade. onnx_output_mode permanece - # apenas como fallback para configs antigas que não possuem runtime_mode. - configured_mode = self.config.get( - "runtime_mode", - self.config.get("onnx_output_mode", "target"), + self.input_channel_indices = ( + get_input_channel_indices( + self.config + ) ) - self.runtime_mode = str(configured_mode).strip().lower() - self.selected_head = self._normalizar_head(self.runtime_mode) - self.selected_output_name = self.OUTPUT_BY_HEAD[self.selected_head] - self.semantic_target_class = self._normalizar_semantic_target_class( - self.config.get("semantic_target_class", "erva") + + self.channels = len( + self.input_channel_names ) - self.semantic_target_id = self.SEMANTIC_CLASS_IDS[self.semantic_target_class] - # Mantido para compatibilidade com logs/código externo antigo. - self.onnx_output_mode = self.runtime_mode - self.onnx_output_kind = str(self.config.get("onnx_output_kind", "mask")).lower() + self.derived_channels_config = ( + validate_derived_config( + self.config.get( + "derived_channels", + {}, + ) + ) + ) - # No contrato v1, a normalização está dentro do ONNX. - # Manter False evita normalização dupla. - self.onnx_preprocess_norm = bool(self.config.get("onnx_preprocess_norm", False)) + self.runtime_backend = str( + self.config.get( + "runtime_backend", + "onnx", + ) + ).lower() - self.trust_input = bool(self.config.get("trust_input", True)) - self.sync_for_timing = bool(self.config.get("sync_for_timing", False)) + self.onnx_provider = str( + self.config.get( + "onnx_provider", + "tensorrt", + ) + ).lower() + + configured_mode = ( + self.config.get( + "runtime_mode", + self.config.get( + "onnx_output_mode", + "target", + ), + ) + ) + + self.runtime_mode = str( + configured_mode + ).strip().lower() + + self.selected_head = ( + self._normalizar_head( + self.runtime_mode + ) + ) + + self.selected_output_name = ( + self.OUTPUT_BY_HEAD[ + self.selected_head + ] + ) + + self.semantic_target_class = ( + self._normalizar_semantic_target_class( + self.config.get( + "semantic_target_class", + "erva", + ) + ) + ) + + self.semantic_target_id = ( + self.SEMANTIC_CLASS_IDS[ + self.semantic_target_class + ] + ) + + # Compatibilidade nominal. + self.onnx_output_mode = ( + self.runtime_mode + ) + + self.onnx_output_kind = str( + self.config.get( + "onnx_output_kind", + "mask", + ) + ).lower() + + self.onnx_preprocess_norm = bool( + self.config.get( + "onnx_preprocess_norm", + False, + ) + ) + + self.trust_input = bool( + self.config.get( + "trust_input", + True, + ) + ) + + self.sync_for_timing = bool( + self.config.get( + "sync_for_timing", + False, + ) + ) + + # Produto: metadata nominal é parte do contrato. + self.require_onnx_metadata = bool( + self.config.get( + "require_onnx_metadata", + True, + ) + ) + + # O export oficial já devolve mask fullres. + self.strict_output_shape = bool( + self.config.get( + "strict_output_shape", + True, + ) + ) + + # Fallback de provider pode esconder perda brutal de FPS. + self.allow_provider_fallback = bool( + self.config.get( + "allow_provider_fallback", + False, + ) + ) self.onnx_session = None self.onnx_input_name = None + self.onnx_input_shape = [] self.onnx_output_names = [] self.onnx_run_output_names = [] + self.onnx_path = None self.onnx_contract_metadata = {} - self.onnx_input_shape = [] + self.onnx_contract_source = None + + self.expected_input_size = None + self.active_providers = [] self._ultimo_tensor = None self._ultimo_predictions = None @@ -264,481 +645,1633 @@ class MultiSpecSegformerService: self._validar_contrato_runtime() self.mostrar_log( - f"[WEED_ONNX][INPUT] selected_channels={self.input_channel_names} " + "[WEED_ONNX][INPUT] " + f"selected_channels={self.input_channel_names} " f"idx={self.input_channel_indices}" ) self._init_onnx_runtime() self.mostrar_log( - f"[WEED_ONNX] backend=onnx " + "[WEED_ONNX] " + f"version={SEGFORMER_SERVICE_VERSION} " f"provider={self.onnx_provider} " f"runtime_mode={self.runtime_mode} " f"selected_head={self.selected_head} " f"selected_output={self.selected_output_name} " - f"output_kind={self.onnx_output_kind} " - f"preprocess_norm={self.onnx_preprocess_norm}" + f"input_shape={self.onnx_input_shape}" ) + # ============================================================ + # Contrato público + # ============================================================ + + def get_expected_input_size( + self, + ) -> Tuple[int, int]: + """ + Retorna (W,H) oficial do ONNX. + """ + if self.expected_input_size is None: + raise RuntimeError( + "Contrato ONNX ainda não foi resolvido." + ) + + return ( + int( + self.expected_input_size[0] + ), + int( + self.expected_input_size[1] + ), + ) + + def get_expected_input_shape( + self, + ) -> List[int]: + return list( + self.onnx_input_shape + ) + + def get_model_contract( + self, + ) -> dict: + with self._runtime_lock: + return { + "service_version": ( + SEGFORMER_SERVICE_VERSION + ), + "onnx_model_path": ( + str( + self.onnx_path + ) + if self.onnx_path + is not None + else None + ), + "input_name": ( + self.onnx_input_name + ), + "input_shape": list( + self.onnx_input_shape + ), + "input_size": ( + list( + self.expected_input_size + ) + if self.expected_input_size + is not None + else None + ), + "input_channel_names": list( + self.input_channel_names + ), + "physical_input_contract": list( + PHYSICAL_CHANNEL_ORDER + ), + "derived_channel_config": dict( + self.derived_channels_config + ), + "normalization_embedded": True, + "onnx_output_kind": ( + self.onnx_output_kind + ), + "onnx_outputs": list( + self.onnx_output_names + ), + "selected_head": ( + self.selected_head + ), + "selected_output": ( + self.selected_output_name + ), + "runtime_generation": int( + self._runtime_generation + ), + "runtime_backend": ( + self.runtime_backend + ), + "requested_provider": ( + self.onnx_provider + ), + "active_providers": list( + self.active_providers + ), + "metadata_source": ( + self.onnx_contract_source + ), + "require_onnx_metadata": bool( + self.require_onnx_metadata + ), + "strict_output_shape": bool( + self.strict_output_shape + ), + } + # ============================================================ # Inicialização / contrato # ============================================================ @classmethod - def _normalizar_head(cls, mode: str) -> str: - normalized = str(mode).strip().lower() - head = cls.MODE_ALIASES.get(normalized) + def _normalizar_head( + cls, + mode: str, + ) -> str: + normalized = str( + mode + ).strip().lower() + + head = cls.MODE_ALIASES.get( + normalized + ) if head is None: raise RuntimeError( - f"runtime_mode inválido para Weed Worker: {mode!r}. " - "Use 'target', 'vegetation', 'cana' ou 'semantic'." + f"runtime_mode inválido: {mode!r}. " + "Use target, vegetation, cana ou semantic." ) return head @staticmethod - def _normalizar_texto(value) -> str: - text = unicodedata.normalize("NFKD", str(value).strip().lower()) - return "".join(ch for ch in text if not unicodedata.combining(ch)) + def _normalizar_texto( + value, + ) -> str: + text = unicodedata.normalize( + "NFKD", + str( + value + ).strip().lower(), + ) + + return "".join( + ch + for ch in text + if not unicodedata.combining( + ch + ) + ) @classmethod - def _normalizar_semantic_target_class(cls, value) -> str: - normalized = cls._normalizar_texto(value) + def _normalizar_semantic_target_class( + cls, + value, + ) -> str: + normalized = ( + cls._normalizar_texto( + value + ) + ) + aliases = { - "0": "chao", "chao": "chao", "solo": "chao", "background": "chao", "bg": "chao", - "1": "cana", "cana": "cana", "sugarcane": "cana", - "2": "erva", "erva": "erva", "weed": "erva", + "0": "chao", + "chao": "chao", + "solo": "chao", + "background": "chao", + "bg": "chao", + + "1": "cana", + "cana": "cana", + "sugarcane": "cana", + + "2": "erva", + "erva": "erva", + "weed": "erva", } - result = aliases.get(normalized) + + result = aliases.get( + normalized + ) + if result is None: raise RuntimeError( - f"semantic_target_class inválida: {value!r}. " - "Use 'chao', 'cana' ou 'erva'." + "semantic_target_class inválida: " + f"{value!r}. Use chao, cana ou erva." ) + return result - def _validar_contrato_runtime(self): - if self.runtime_backend not in ("onnx", "tensorrt", "trt"): + def _validar_contrato_runtime( + self, + ): + if self.runtime_backend not in ( + "onnx", + "tensorrt", + "trt", + ): raise RuntimeError( - f"runtime_backend inválido para Weed Worker v1: {self.runtime_backend}. " + "runtime_backend inválido: " + f"{self.runtime_backend}. " "Use runtime_backend='onnx'." ) - # A normalização no __init__ já valida o modo e define a cabeça. - if self.selected_head not in self.OUTPUT_BY_HEAD: - raise RuntimeError(f"Cabeça operacional inválida: {self.selected_head}") + if ( + self.selected_head + not in self.OUTPUT_BY_HEAD + ): + raise RuntimeError( + "Cabeça operacional inválida: " + f"{self.selected_head}" + ) if self.onnx_output_kind != "mask": raise RuntimeError( - f"onnx_output_kind inválido para Weed Worker v1: {self.onnx_output_kind}. " - "O ONNX oficial deve devolver target_mask pronto." + "onnx_output_kind inválido: " + f"{self.onnx_output_kind}. " + "O export oficial deve devolver máscaras." ) if self.onnx_preprocess_norm: raise RuntimeError( - "onnx_preprocess_norm=True não é permitido na v1. " - "O ONNX oficial já inclui normalização interna." + "onnx_preprocess_norm=True é proibido. " + "A normalização pertence ao ONNX exportado." ) - def _resolve_onnx_path(self) -> Path: + def _resolve_onnx_path( + self, + ) -> Path: candidates = [] - for key in ("onnx_model_path", "ia_model_path", "model_path", "onnx_path"): - value = self.config.get(key) - if value: - candidates.append(Path(value)) - - for p in candidates: - p = p.resolve() if not p.is_absolute() else p - if p.is_file(): - return p - - raise FileNotFoundError( - "Modelo ONNX não encontrado. Procurei:\n" + - "\n".join(str(p) for p in candidates) - ) - - def _init_onnx_runtime(self): - if ort is None: - raise RuntimeError( - "onnxruntime não está instalado. Instale onnxruntime-gpu " - "para usar TensorRT/CUDA." + for key in ( + "onnx_model_path", + "ia_model_path", + "model_path", + "onnx_path", + ): + value = self.config.get( + key ) - onnx_path = self._resolve_onnx_path() - - sess_options = ort.SessionOptions() - sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL - - #sess_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL - #sess_options.intra_op_num_threads = 1 - #sess_options.inter_op_num_threads = 1 - #sess_options.add_session_config_entry( - # "session.intra_op.allow_spinning", - # "0", - #) - #sess_options.add_session_config_entry( - # "session.inter_op.allow_spinning", - # "0", - #) - - trt_cache_dir = str(self.config.get("trt_cache_dir", "trt_engine_cache")) - trt_fp16 = bool(self.config.get("trt_fp16", True)) - - available = ort.get_available_providers() - providers = [] - - self.mostrar_log(f"[WEED_ONNX] providers disponíveis: {available}") - - if self.onnx_provider in ("tensorrt", "trt"): - if "TensorrtExecutionProvider" in available: - providers.append(( - "TensorrtExecutionProvider", - { - "trt_engine_cache_enable": True, - "trt_engine_cache_path": trt_cache_dir, - "trt_fp16_enable": trt_fp16, - } - )) - else: - self.mostrar_log( - "[WEED_ONNX][WARN] TensorRT provider não disponível. " - "Caindo para CUDA/CPU." + if value: + candidates.append( + Path( + str(value) + ) ) - if self.onnx_provider in ("tensorrt", "trt", "cuda"): - if "CUDAExecutionProvider" in available: - providers.append("CUDAExecutionProvider") + resolved_tried = [] - providers.append("CPUExecutionProvider") + for p in candidates: + try: + p = ( + p + if p.is_absolute() + else p.resolve() + ) - self.onnx_session = ort.InferenceSession( - str(onnx_path), - sess_options=sess_options, - providers=providers, + resolved_tried.append( + p + ) + + if p.is_file(): + return p + + except Exception: + resolved_tried.append( + p + ) + + raise FileNotFoundError( + "Modelo ONNX não encontrado. Procurei:\n" + + "\n".join( + str(p) + for p + in resolved_tried + ) ) - self.onnx_input_name = self.onnx_session.get_inputs()[0].name - self.onnx_output_names = [o.name for o in self.onnx_session.get_outputs()] - self.onnx_path = onnx_path - self.onnx_contract_metadata = self._load_onnx_contract_metadata(onnx_path) - self._validate_onnx_input_contract() - self.onnx_run_output_names = self._selecionar_outputs_runtime(self.onnx_output_names) + def _build_provider_list( + self, + available: List[str], + ): + requested = ( + self.onnx_provider + ) - self.mostrar_log(f"[WEED_ONNX] modelo={onnx_path}") - self.mostrar_log(f"[WEED_ONNX] input={self.onnx_input_name}") - self.mostrar_log(f"[WEED_ONNX] outputs={self.onnx_output_names}") - self.mostrar_log(f"[WEED_ONNX] outputs executados={self.onnx_run_output_names}") - self.mostrar_log(f"[WEED_ONNX] providers ativos={self.onnx_session.get_providers()}") + providers = [] + + trt_cache_enable = bool( + self.config.get( + "trt_engine_cache_enable", + True, + ) + ) + + trt_cache_path = str( + self.config.get( + "trt_engine_cache_path", + self.config.get( + "trt_cache_dir", + "./trt_cache_weed_worker", + ), + ) + ) + + trt_fp16 = bool( + self.config.get( + "trt_fp16", + True, + ) + ) + + trt_workspace = ( + self.config.get( + "trt_max_workspace_size" + ) + ) + + if requested in ( + "tensorrt", + "trt", + ): + if ( + "TensorrtExecutionProvider" + not in available + ): + if not self.allow_provider_fallback: + raise RuntimeError( + "TensorRT foi solicitado, mas " + "TensorrtExecutionProvider não está disponível. " + f"Providers={available}" + ) + + self.mostrar_log( + "[WEED_ONNX][WARN] TensorRT indisponível; " + "fallback explicitamente permitido." + ) + + else: + trt_options = { + "trt_engine_cache_enable": ( + trt_cache_enable + ), + "trt_engine_cache_path": ( + trt_cache_path + ), + "trt_fp16_enable": ( + trt_fp16 + ), + } + + if trt_workspace is not None: + trt_options[ + "trt_max_workspace_size" + ] = int( + trt_workspace + ) + + providers.append( + ( + "TensorrtExecutionProvider", + trt_options, + ) + ) + + if requested in ( + "tensorrt", + "trt", + "cuda", + ): + if ( + "CUDAExecutionProvider" + in available + ): + providers.append( + "CUDAExecutionProvider" + ) + + elif ( + requested == "cuda" + and not self.allow_provider_fallback + ): + raise RuntimeError( + "CUDA foi solicitado, mas " + "CUDAExecutionProvider não está disponível. " + f"Providers={available}" + ) + + # CPU permanece fallback interno do ORT quando explicitamente + # permitido ou como último provider após TRT/CUDA. + if ( + self.allow_provider_fallback + or providers + or requested == "cpu" + ): + if ( + "CPUExecutionProvider" + in available + ): + providers.append( + "CPUExecutionProvider" + ) + + if not providers: + raise RuntimeError( + "Nenhum provider ONNX válido pôde ser selecionado. " + f"requested={requested} available={available}" + ) + + return providers + + def _validate_active_provider( + self, + ): + active = list( + self.onnx_session.get_providers() + ) + + self.active_providers = ( + active + ) + + if self.onnx_provider in ( + "tensorrt", + "trt", + ): + expected = ( + "TensorrtExecutionProvider" + ) + + elif self.onnx_provider == "cuda": + expected = ( + "CUDAExecutionProvider" + ) + + else: + expected = ( + "CPUExecutionProvider" + ) + + if ( + expected not in active + and not self.allow_provider_fallback + ): + raise RuntimeError( + "Provider solicitado não ficou ativo: " + f"solicitado={expected}, ativos={active}" + ) + + def _init_onnx_runtime( + self, + ): + if ort is None: + raise RuntimeError( + "onnxruntime não está instalado. " + "Use onnxruntime-gpu no produto." + ) + + onnx_path = ( + self._resolve_onnx_path() + ) + + sess_options = ( + ort.SessionOptions() + ) + + sess_options.graph_optimization_level = ( + ort.GraphOptimizationLevel + .ORT_ENABLE_ALL + ) + + available = list( + ort.get_available_providers() + ) + + self.mostrar_log( + "[WEED_ONNX] providers disponíveis: " + f"{available}" + ) + + providers = ( + self._build_provider_list( + available + ) + ) + + self.onnx_session = ( + ort.InferenceSession( + str( + onnx_path + ), + sess_options=sess_options, + providers=providers, + ) + ) + + self._validate_active_provider() + + inputs = list( + self.onnx_session.get_inputs() + ) + + if len(inputs) != 1: + raise RuntimeError( + "O ONNX produto deve possuir exatamente uma entrada. " + f"Encontradas={len(inputs)}" + ) + + self.onnx_input_name = ( + inputs[0].name + ) + + self.onnx_output_names = [ + o.name + for o in ( + self.onnx_session + .get_outputs() + ) + ] + + self.onnx_path = ( + onnx_path + ) + + self.onnx_contract_metadata = ( + self._load_onnx_contract_metadata( + onnx_path + ) + ) + + self._validate_onnx_input_contract() + + self.onnx_run_output_names = ( + self._selecionar_outputs_runtime( + self.onnx_output_names + ) + ) + + self.mostrar_log( + f"[WEED_ONNX] modelo={onnx_path}" + ) + + self.mostrar_log( + f"[WEED_ONNX] input={self.onnx_input_name} " + f"shape={self.onnx_input_shape}" + ) + + self.mostrar_log( + f"[WEED_ONNX] outputs={self.onnx_output_names}" + ) + + self.mostrar_log( + "[WEED_ONNX] output executado=" + f"{self.onnx_run_output_names}" + ) + + self.mostrar_log( + "[WEED_ONNX] providers ativos=" + f"{self.active_providers}" + ) + + # ============================================================ + # Metadata do modelo + # ============================================================ @staticmethod - def _parse_metadata_list(value): + def _parse_metadata_list( + value, + ): if value is None: return None - if isinstance(value, (list, tuple)): - return [str(item).strip().upper() for item in value] - text = str(value).strip() + + if isinstance( + value, + ( + list, + tuple, + ), + ): + return [ + str(item) + .strip() + .upper() + for item + in value + ] + + text = str( + value + ).strip() + if not text: return None + try: - parsed = json.loads(text) - if isinstance(parsed, list): - return [str(item).strip().upper() for item in parsed] + parsed = json.loads( + text + ) + + if isinstance( + parsed, + list, + ): + return [ + str(item) + .strip() + .upper() + for item + in parsed + ] + except Exception: pass - return [item.strip().upper() for item in text.split(",") if item.strip()] + + return [ + item.strip().upper() + for item + in text.split(",") + if item.strip() + ] @staticmethod - def _parse_metadata_bool(value): - if isinstance(value, bool): + def _parse_metadata_bool( + value, + ): + if isinstance( + value, + bool, + ): return value + if value is None: return None - text = str(value).strip().lower() - if text in ("1", "true", "yes", "sim"): + + text = str( + value + ).strip().lower() + + if text in ( + "1", + "true", + "yes", + "sim", + ): return True - if text in ("0", "false", "no", "nao", "não"): + + if text in ( + "0", + "false", + "no", + "nao", + "não", + ): return False + return None - def _load_onnx_contract_metadata(self, onnx_path: Path) -> dict: - """Lê o sidecar e completa o contrato com a metadata interna do ONNX.""" + @staticmethod + def _parse_metadata_shape( + value, + ): + if value is None: + return None + + if isinstance( + value, + ( + list, + tuple, + ), + ): + return list( + value + ) + + text = str( + value + ).strip() + + try: + parsed = json.loads( + text + ) + + if isinstance( + parsed, + list, + ): + return parsed + + except Exception: + pass + + return None + + def _load_onnx_contract_metadata( + self, + onnx_path: Path, + ) -> dict: contract = {} + sidecar_candidates = [ - Path(str(onnx_path) + ".export_meta.json"), - onnx_path.with_suffix(".export_meta.json"), + Path( + str( + onnx_path + ) + + ".export_meta.json" + ), + onnx_path.with_suffix( + ".export_meta.json" + ), ] - for sidecar in sidecar_candidates: + + for sidecar in ( + sidecar_candidates + ): if not sidecar.is_file(): continue + try: - with sidecar.open("r", encoding="utf-8") as handle: - data = json.load(handle) - if isinstance(data, dict): - contract.update(data) - contract["_sidecar_path"] = str(sidecar) + with sidecar.open( + "r", + encoding="utf-8", + ) as handle: + data = json.load( + handle + ) + + if isinstance( + data, + dict, + ): + contract.update( + data + ) + + contract[ + "_sidecar_path" + ] = str( + sidecar + ) + + self.onnx_contract_source = str( + sidecar + ) + break + except Exception as exc: - raise RuntimeError(f"Falha ao ler contrato ONNX {sidecar}: {exc}") from exc + raise RuntimeError( + "Falha ao ler contrato ONNX " + f"{sidecar}: {exc}" + ) from exc try: internal = dict( - getattr(self.onnx_session.get_modelmeta(), "custom_metadata_map", {}) or {} + getattr( + self.onnx_session + .get_modelmeta(), + "custom_metadata_map", + {}, + ) + or {} ) + except Exception: internal = {} - contract["_internal"] = internal + + contract[ + "_internal" + ] = internal + + if ( + self.onnx_contract_source + is None + and internal + ): + self.onnx_contract_source = ( + "onnx_custom_metadata" + ) + return contract - def _validate_onnx_input_contract(self): - model_input = self.onnx_session.get_inputs()[0] - shape = list(model_input.shape or []) - self.onnx_input_shape = shape + def _validate_onnx_input_contract( + self, + ): + model_input = ( + self.onnx_session + .get_inputs()[0] + ) + + shape = list( + model_input.shape + or [] + ) + + self.onnx_input_shape = ( + shape + ) + if len(shape) != 4: raise RuntimeError( - f"Input ONNX deve ser BCHW 4D; {model_input.name!r} possui shape={shape}" + "Input ONNX deve ser BCHW 4D; " + f"{model_input.name!r} shape={shape}" ) + batch = shape[0] model_c = shape[1] - if isinstance(model_c, (int, np.integer)) and int(model_c) != self.channels: + model_h = shape[2] + model_w = shape[3] + + # Produto usa batch 1. + if ( + isinstance( + batch, + (int, np.integer), + ) + and int(batch) != 1 + ): raise RuntimeError( - f"Contrato ONNX incompatível: modelo espera C={model_c}, mas o config " - f"define C={self.channels} ({self.input_channel_names})." + f"ONNX produto exige batch=1; shape={shape}" ) - contract = self.onnx_contract_metadata or {} - internal = contract.get("_internal", {}) or {} - metadata_names = self._parse_metadata_list( - contract.get("input_channel_names", internal.get("oak.input_channel_names")) + if not isinstance( + model_c, + (int, np.integer), + ): + raise RuntimeError( + "Produto exige C estático no ONNX; " + f"shape={shape}" + ) + + if not isinstance( + model_h, + (int, np.integer), + ) or not isinstance( + model_w, + (int, np.integer), + ): + raise RuntimeError( + "Produto exige H/W estáticos no ONNX; " + f"shape={shape}" + ) + + if int( + model_c + ) != self.channels: + raise RuntimeError( + "Contrato ONNX incompatível: " + f"modelo espera C={model_c}, " + f"config define C={self.channels} " + f"{self.input_channel_names}" + ) + + if ( + int(model_h) <= 0 + or int(model_w) <= 0 + ): + raise RuntimeError( + f"H/W inválidos no ONNX: {shape}" + ) + + self.expected_input_size = ( + int( + model_w + ), + int( + model_h + ), ) - if metadata_names is not None and metadata_names != self.input_channel_names: - raise RuntimeError( - "Ordem de canais incompatível entre config e ONNX: " - f"config={self.input_channel_names}, modelo={metadata_names}." - ) - input_contract = contract.get("input_contract", {}) or {} - embedded_norm = self._parse_metadata_bool( - input_contract.get( - "normalization_embedded", - internal.get("oak.normalization_embedded"), + contract = ( + self.onnx_contract_metadata + or {} + ) + + internal = ( + contract.get( + "_internal", + {}, + ) + or {} + ) + + metadata_names = ( + self._parse_metadata_list( + contract.get( + "input_channel_names", + internal.get( + "oak.input_channel_names" + ), + ) ) ) - if embedded_norm is False: - raise RuntimeError( - "O ONNX declara normalization_embedded=false, mas este runtime exige " - "normalização incorporada. Reexporte com --include-norm." - ) - - postprocess = contract.get("postprocess", internal.get("oak.postprocess")) - if postprocess is not None and "argmax" not in str(postprocess).strip().lower(): - raise RuntimeError( - f"ONNX incompatível: postprocess={postprocess!r}. " - "O runtime operacional exige máscaras argmax prontas." - ) if metadata_names is None: + if self.require_onnx_metadata: + raise RuntimeError( + "ONNX produto sem metadata nominal " + "input_channel_names. Reexporte o modelo com o " + "exportador oficial." + ) + self.mostrar_log( - "[WEED_ONNX][WARN] ONNX sem metadata de input_channel_names; " - "validando apenas a quantidade de canais." - ) - else: - source = contract.get("_sidecar_path", "metadata interna") - self.mostrar_log( - f"[WEED_ONNX][CONTRACT] canais={metadata_names} fonte={source}" + "[WEED_ONNX][WARN] ONNX sem metadata nominal de canais." ) - def _selecionar_outputs_runtime(self, output_names: List[str]) -> List[str]: - output_lookup = {str(name).lower(): str(name) for name in output_names} - expected = self.selected_output_name.lower() + elif ( + metadata_names + != self.input_channel_names + ): + raise RuntimeError( + "Ordem de canais incompatível entre config e ONNX: " + f"config={self.input_channel_names}, " + f"modelo={metadata_names}" + ) - if expected in output_lookup: - return [output_lookup[expected]] - - raise RuntimeError( - f"A saída necessária {self.selected_output_name!r} não existe no ONNX. " - f"Cabeça selecionada={self.selected_head!r}; " - f"outputs disponíveis={output_names}." + input_contract = ( + contract.get( + "input_contract", + {}, + ) + or {} ) - def atualizar_runtime_mode(self, runtime_mode, semantic_target_class=None) -> bool: - """Troca a cabeça operacional sem recriar a sessão ONNX. + embedded_norm = ( + self._parse_metadata_bool( + input_contract.get( + "normalization_embedded", + internal.get( + "oak.normalization_embedded" + ), + ) + ) + ) - Retorna True quando a configuração efetivamente mudou. Uma inferência - que já estava em andamento é descartada e o novo modo vale no frame - seguinte. - """ - new_mode = str(runtime_mode).strip().lower() - new_head = self._normalizar_head(new_mode) - new_output = self.OUTPUT_BY_HEAD[new_head] + if embedded_norm is False: + raise RuntimeError( + "ONNX declara normalization_embedded=false. " + "O runtime produto exige normalização incorporada." + ) - if semantic_target_class is None: - semantic_target_class = self.semantic_target_class - new_semantic_class = self._normalizar_semantic_target_class(semantic_target_class) - new_semantic_id = self.SEMANTIC_CLASS_IDS[new_semantic_class] + if ( + embedded_norm is None + and self.require_onnx_metadata + ): + raise RuntimeError( + "ONNX produto não declara normalization_embedded. " + "Reexporte com metadata de contrato." + ) + + postprocess = contract.get( + "postprocess", + internal.get( + "oak.postprocess" + ), + ) + + if postprocess is not None: + if ( + "argmax" + not in str( + postprocess + ).strip().lower() + ): + raise RuntimeError( + "ONNX incompatível: " + f"postprocess={postprocess!r}. " + "Esperado argmax/mask embutido." + ) + + elif self.require_onnx_metadata: + raise RuntimeError( + "ONNX produto não declara postprocess. " + "O runtime exige export full-runtime com máscaras prontas." + ) + + metadata_shape = ( + self._parse_metadata_shape( + contract.get( + "input_shape", + internal.get( + "oak.input_shape" + ), + ) + ) + ) + + if ( + metadata_shape is not None + and len( + metadata_shape + ) == 4 + ): + comparable = [] + + for actual, declared in zip( + shape, + metadata_shape, + ): + if ( + isinstance( + actual, + (int, np.integer), + ) + and isinstance( + declared, + (int, np.integer), + ) + ): + comparable.append( + int(actual) + == int(declared) + ) + + if ( + comparable + and not all( + comparable + ) + ): + raise RuntimeError( + "Metadata input_shape diverge da sessão ONNX: " + f"metadata={metadata_shape}, session={shape}" + ) + + if metadata_names is not None: + self.mostrar_log( + "[WEED_ONNX][CONTRACT] " + f"canais={metadata_names} " + f"size={self.expected_input_size} " + f"metadata={self.onnx_contract_source}" + ) + + # ============================================================ + # Heads / troca dinâmica + # ============================================================ + + def _selecionar_outputs_runtime( + self, + output_names: List[str], + ) -> List[str]: + output_lookup = { + str(name).lower(): str( + name + ) + for name + in output_names + } + + expected = ( + self.selected_output_name + .lower() + ) + + if expected in output_lookup: + actual = ( + output_lookup[ + expected + ] + ) + + self.selected_output_name = ( + actual + ) + + return [ + actual + ] + + raise RuntimeError( + "Saída necessária não existe no ONNX: " + f"head={self.selected_head!r} " + f"expected={self.selected_output_name!r} " + f"outputs={output_names}" + ) + + def atualizar_runtime_mode( + self, + runtime_mode, + semantic_target_class=None, + ) -> bool: + new_head = self._normalizar_head( + runtime_mode + ) + + new_output = ( + self.OUTPUT_BY_HEAD[ + new_head + ] + ) + + if ( + semantic_target_class + is None + ): + semantic_target_class = ( + self.semantic_target_class + ) + + new_semantic_class = ( + self._normalizar_semantic_target_class( + semantic_target_class + ) + ) + + new_semantic_id = ( + self.SEMANTIC_CLASS_IDS[ + new_semantic_class + ] + ) + + output_lookup = { + str(n).lower(): str(n) + for n + in self.onnx_output_names + } + + actual_output = ( + output_lookup.get( + new_output.lower() + ) + ) - output_lookup = {str(n).lower(): str(n) for n in self.onnx_output_names} - actual_output = output_lookup.get(new_output.lower()) if actual_output is None: raise RuntimeError( - f"A saída necessária {new_output!r} não existe no ONNX. " - f"Outputs disponíveis={self.onnx_output_names}." + "A saída necessária " + f"{new_output!r} não existe no ONNX. " + f"Outputs={self.onnx_output_names}" ) with self._runtime_lock: changed = ( - new_head != self.selected_head - or new_semantic_id != self.semantic_target_id + new_head + != self.selected_head + or ( + new_head + == "semantic" + and new_semantic_id + != self.semantic_target_id + ) ) + if not changed: return False - old_desc = self._runtime_description_unlocked() - self.runtime_mode = new_head - self.onnx_output_mode = new_head - self.selected_head = new_head - self.selected_output_name = actual_output - self.onnx_run_output_names = [actual_output] - self.semantic_target_class = new_semantic_class - self.semantic_target_id = new_semantic_id - self._runtime_generation += 1 - self._limpar_cache_unlocked() - new_desc = self._runtime_description_unlocked() + old_desc = ( + self._runtime_description_unlocked() + ) + + self.runtime_mode = ( + new_head + ) + + self.onnx_output_mode = ( + new_head + ) + + self.selected_head = ( + new_head + ) + + self.selected_output_name = ( + actual_output + ) + + self.onnx_run_output_names = [ + actual_output + ] + + self.semantic_target_class = ( + new_semantic_class + ) + + self.semantic_target_id = ( + new_semantic_id + ) + + self._runtime_generation += 1 + + self._limpar_cache_unlocked() + + new_desc = ( + self._runtime_description_unlocked() + ) + + self.mostrar_log( + "[WEED_ONNX][MODE] " + f"{old_desc} -> {new_desc}" + ) - self.mostrar_log(f"[WEED_ONNX][MODE] {old_desc} -> {new_desc}") return True - def atualizar_config(self, config: dict) -> bool: - config = config or {} + def atualizar_config( + self, + config: dict, + ) -> bool: + """ + Somente seleção de cabeça/classe é mutável. - # Estes campos alteram a arquitetura/contrato do input e exigem uma - # nova sessão. Só runtime_mode e semantic_target_class são dinâmicos. - if "input_channels" in config or "channels" in config: - merged = dict(self.config) - merged.update(config) - requested_names = get_input_channel_names(merged) - if requested_names != self.input_channel_names: + Modelo, provider, shape, canais, derived config e preprocess são + estruturais e exigem recriar MultiSpecSegformerService. + """ + config = dict( + config + or {} + ) + + # Canais. + if ( + "input_channels" + in config + or "channels" + in config + ): + merged = dict( + self.config + ) + + merged.update( + config + ) + + requested_names = ( + get_input_channel_names( + merged + ) + ) + + if ( + requested_names + != self.input_channel_names + ): raise RuntimeError( - "input_channels não pode ser alterado com a sessão ONNX ativa: " - f"atual={self.input_channel_names}, solicitado={requested_names}. " - "Recrie MultiSpecSegformerService com o novo modelo/config." + "input_channels não pode mudar na sessão ONNX: " + f"atual={self.input_channel_names}, " + f"solicitado={requested_names}" ) + # Derived. if "derived_channels" in config: - requested_derived = dict(config.get("derived_channels", {}) or {}) - if requested_derived != self.derived_channels_config: + requested_derived = ( + validate_derived_config( + config.get( + "derived_channels" + ) + ) + ) + + if ( + requested_derived + != self.derived_channels_config + ): raise RuntimeError( - "derived_channels não pode ser alterado com a sessão ONNX ativa. " - "Recrie MultiSpecSegformerService para preservar o contrato." + "derived_channels não pode mudar na sessão ONNX." ) - mode = config.get("runtime_mode", config.get("onnx_output_mode", self.runtime_mode)) - semantic_class = config.get("semantic_target_class", self.semantic_target_class) - changed = self.atualizar_runtime_mode(mode, semantic_class) - self.config.update(config) + # Caminho do modelo. + for key in ( + "onnx_model_path", + "ia_model_path", + "model_path", + "onnx_path", + ): + value = config.get( + key + ) + + if not value: + continue + + try: + requested = str( + Path( + str(value) + ).resolve() + ) + + current = str( + self.onnx_path.resolve() + ) + + except Exception: + requested = str( + value + ) + + current = str( + self.onnx_path + ) + + if requested != current: + raise RuntimeError( + "Modelo ONNX não pode mudar com a sessão ativa." + ) + + immutable_simple = ( + "runtime_backend", + "onnx_provider", + "onnx_output_kind", + "onnx_preprocess_norm", + "trust_input", + "require_onnx_metadata", + "strict_output_shape", + "allow_provider_fallback", + ) + + for key in ( + immutable_simple + ): + if key not in config: + continue + + old = self.config.get( + key + ) + + new = config.get( + key + ) + + # Config antiga pode omitir o default. Só acusa quando ambos + # representam mudança efetiva. + if ( + old is not None + and new != old + ): + raise RuntimeError( + f"{key} não pode mudar com a sessão ONNX ativa." + ) + + mode = config.get( + "runtime_mode", + config.get( + "onnx_output_mode", + self.runtime_mode, + ), + ) + + semantic_class = ( + config.get( + "semantic_target_class", + self.semantic_target_class, + ) + ) + + changed = ( + self.atualizar_runtime_mode( + mode, + semantic_class, + ) + ) + + # Salva somente opções dinâmicas e valores iguais aos estruturais. + self.config.update( + config + ) + return changed - def _runtime_description_unlocked(self) -> str: - if self.selected_head == "semantic": - return f"semantic[{self.semantic_target_class}={self.semantic_target_id}]" + def _runtime_description_unlocked( + self, + ) -> str: + if ( + self.selected_head + == "semantic" + ): + return ( + "semantic[" + f"{self.semantic_target_class}=" + f"{self.semantic_target_id}]" + ) + return self.selected_head - def _limpar_cache_unlocked(self): + def _limpar_cache_unlocked( + self, + ): self._ultimo_predictions = None self._ultimo_probs = None self._ultimo_predictions_full = {} - def limpar_cache_runtime(self): + def limpar_cache_runtime( + self, + ): with self._runtime_lock: self._limpar_cache_unlocked() - def get_runtime_generation(self) -> int: + def get_runtime_generation( + self, + ) -> int: with self._runtime_lock: - return int(self._runtime_generation) + return int( + self._runtime_generation + ) # ============================================================ # Inferência # ============================================================ - def infer_tensor_fast(self, tensor5_chw: np.ndarray, keep_probs: bool = False): - # keep_probs mantido só para compatibilidade de chamada. + def infer_tensor_fast( + self, + tensor5_chw: np.ndarray, + keep_probs: bool = False, + ): + # keep_probs existe por compatibilidade. return self.infer_tensor_onnx( tensor5_chw, - return_full=bool(self.config.get("return_full_fast", False)), + return_full=bool( + self.config.get( + "return_full_fast", + False, + ) + ), ) - def infer_tensor_onnx(self, tensor5_chw: np.ndarray, return_full: bool = False): + def infer_tensor_onnx( + self, + tensor5_chw: np.ndarray, + return_full: bool = False, + ): if tensor5_chw is None: return None if self.onnx_session is None: - raise RuntimeError("ONNX Runtime não inicializado.") + raise RuntimeError( + "ONNX Runtime não inicializado." + ) t_total0 = time.perf_counter() t_prepare0 = time.perf_counter() - raw5_chw, x, out_hw = self._prepare_input_numpy_onnx(tensor5_chw) - prepare_ms = (time.perf_counter() - t_prepare0) * 1000.0 - if x is None: - return None - - t_forward0 = time.perf_counter() - - with self._runtime_lock: - run_output_names = list(self.onnx_run_output_names) - selected_head = self.selected_head - selected_output_name = self.selected_output_name - semantic_target_class = self.semantic_target_class - semantic_target_id = self.semantic_target_id - runtime_generation = self._runtime_generation - - outputs = self.onnx_session.run( - run_output_names, - {self.onnx_input_name: x}, + raw5_chw, x, out_hw = ( + self._prepare_input_numpy_onnx( + tensor5_chw + ) ) - forward_ms = (time.perf_counter() - t_forward0) * 1000.0 + prepare_ms = ( + time.perf_counter() + - t_prepare0 + ) * 1000.0 - t_post0 = time.perf_counter() + t_forward0 = ( + time.perf_counter() + ) - if len(outputs) < 1: - raise RuntimeError("ONNX não retornou nenhuma saída.") - - raw_mask = self._onnx_output_to_mask(outputs[0], out_hw) - - if selected_head == "semantic": - selected_mask = (raw_mask == semantic_target_id).astype(np.uint8) - else: - selected_mask = raw_mask - - # Defesa do contrato do WeedDetector: a saída final deste serviço é - # sempre binária. As cabeças binárias já devem conter 0/1; semantic - # foi explicitamente convertida acima. - invalid_values = np.logical_and(selected_mask != 0, selected_mask != 1) - if np.any(invalid_values): - values = np.unique(selected_mask[invalid_values])[:10].tolist() - raise RuntimeError( - f"A saída {selected_output_name!r} não é binária. " - f"Valores inválidos encontrados: {values}" + with self._runtime_lock: + run_output_names = list( + self.onnx_run_output_names ) - post_ms = (time.perf_counter() - t_post0) * 1000.0 - total_ms = (time.perf_counter() - t_total0) * 1000.0 + selected_head = ( + self.selected_head + ) + + selected_output_name = ( + self.selected_output_name + ) + + semantic_target_class = ( + self.semantic_target_class + ) + + semantic_target_id = ( + self.semantic_target_id + ) + + runtime_generation = ( + self._runtime_generation + ) + + outputs = ( + self.onnx_session.run( + run_output_names, + { + self.onnx_input_name: x + }, + ) + ) + + forward_ms = ( + time.perf_counter() + - t_forward0 + ) * 1000.0 + + t_post0 = ( + time.perf_counter() + ) + + if len(outputs) != 1: + raise RuntimeError( + "Inferência operacional deve executar exatamente " + f"uma saída; retornadas={len(outputs)}" + ) + + raw_mask = ( + self._onnx_output_to_mask( + outputs[0], + out_hw, + ) + ) + + if selected_head == "semantic": + selected_mask = ( + raw_mask + == semantic_target_id + ).astype( + np.uint8 + ) + + else: + selected_mask = ( + raw_mask + ) + + # Contrato WeedDetector sempre binário. + invalid_values = np.logical_and( + selected_mask != 0, + selected_mask != 1, + ) + + if np.any( + invalid_values + ): + values = np.unique( + selected_mask[ + invalid_values + ] + )[:10].tolist() + + raise RuntimeError( + "Saída operacional não é binária: " + f"output={selected_output_name!r}, " + f"values={values}" + ) + + selected_mask = np.ascontiguousarray( + selected_mask, + dtype=np.uint8, + ) + + post_ms = ( + time.perf_counter() + - t_post0 + ) * 1000.0 + + total_ms = ( + time.perf_counter() + - t_total0 + ) * 1000.0 predictions_full = { "selected": selected_mask, - "selected_head": selected_head, - "selected_output": selected_output_name, - "semantic_target_class": semantic_target_class if selected_head == "semantic" else None, - "semantic_target_id": semantic_target_id if selected_head == "semantic" else None, - "target": selected_mask if selected_head == "target" else None, - "target_head": selected_mask if selected_head == "target" else None, - "target_op": selected_mask if selected_head == "target" else None, - "semantic": raw_mask if selected_head == "semantic" else None, - "vegetation": selected_mask if selected_head == "vegetation" else None, - "cana": selected_mask if selected_head == "cana" else None, + "selected_head": ( + selected_head + ), + "selected_output": ( + selected_output_name + ), + "semantic_target_class": ( + semantic_target_class + if selected_head + == "semantic" + else None + ), + "semantic_target_id": ( + semantic_target_id + if selected_head + == "semantic" + else None + ), + + "target": ( + selected_mask + if selected_head + == "target" + else None + ), + "target_head": ( + selected_mask + if selected_head + == "target" + else None + ), + "target_op": ( + selected_mask + if selected_head + == "target" + else None + ), + "semantic": ( + raw_mask + if selected_head + == "semantic" + else None + ), + "vegetation": ( + selected_mask + if selected_head + == "vegetation" + else None + ), + "cana": ( + selected_mask + if selected_head + == "cana" + else None + ), "probs": None, "infer_ms": total_ms, @@ -747,198 +2280,489 @@ class MultiSpecSegformerService: "post_ms": post_ms, "runtime_mode": selected_head, - "runtime_generation": runtime_generation, + "runtime_generation": int( + runtime_generation + ), "backend": "onnx", - "providers": self.onnx_session.get_providers(), - "outputs": self.onnx_output_names, - "outputs_executados": run_output_names, + "providers": list( + self.active_providers + ), + "outputs": list( + self.onnx_output_names + ), + "outputs_executados": ( + run_output_names + ), + "input_channel_names": list( + self.input_channel_names + ), + "input_shape": list( + x.shape + ), } - # A configuração pode mudar enquanto o provider executa. A verificação - # e a publicação do cache ficam no mesmo lock para impedir que um frame - # antigo reapareça depois de atualizar_runtime_mode() limpar o estado. + # Não publica resultado de uma geração antiga depois de trocar head. with self._runtime_lock: - if runtime_generation != self._runtime_generation: + if ( + runtime_generation + != self._runtime_generation + ): return None - # Preview/cache permanecem no domínio físico [R,G,B,RE,NIR]. - self._ultimo_tensor = raw5_chw - self._ultimo_predictions = selected_mask + + # Cache de preview permanece no domínio físico Raw5. + self._ultimo_tensor = ( + raw5_chw + ) + + self._ultimo_predictions = ( + selected_mask + ) + self._ultimo_probs = None - self._ultimo_predictions_full = predictions_full + + self._ultimo_predictions_full = ( + predictions_full + ) if return_full: - return dict(predictions_full) + return dict( + predictions_full + ) return selected_mask - def _prepare_input_numpy_onnx(self, tensor5_chw: np.ndarray): + def _prepare_input_numpy_onnx( + self, + tensor5_chw: np.ndarray, + ): if tensor5_chw is None: return None, None, None if self.trust_input: raw5 = tensor5_chw - if not isinstance(raw5, np.ndarray): - raw5 = np.asarray(raw5, dtype=np.float32) + if not isinstance( + raw5, + np.ndarray, + ): + raw5 = np.asarray( + raw5, + dtype=np.float32, + ) - if raw5.dtype != np.float32 or not raw5.flags.c_contiguous: - raw5 = np.ascontiguousarray(raw5, dtype=np.float32) + if ( + raw5.dtype + != np.float32 + or not raw5.flags.c_contiguous + ): + raw5 = np.ascontiguousarray( + raw5, + dtype=np.float32, + ) else: - raw5 = np.asarray(tensor5_chw, dtype=np.float32) - raw5 = np.nan_to_num(raw5, nan=0.0, posinf=1.0, neginf=0.0) - raw5 = np.clip(raw5, 0.0, 1.0).astype(np.float32, copy=False) - raw5 = np.ascontiguousarray(raw5) - - if raw5.ndim != 3 or raw5.shape[0] != len(PHYSICAL_CHANNEL_ORDER): - raise RuntimeError( - "Tensor físico inválido no runtime: esperado " - f"Raw5 {PHYSICAL_CHANNEL_ORDER}, veio shape={raw5.shape}" + raw5 = np.asarray( + tensor5_chw, + dtype=np.float32, ) - model_chw = build_model_input_tensor( - raw5, - self.input_channel_names, - self.derived_channels_config, + np.nan_to_num( + raw5, + copy=False, + nan=0.0, + posinf=1.0, + neginf=0.0, + ) + + np.clip( + raw5, + 0.0, + 1.0, + out=raw5, + ) + + raw5 = np.ascontiguousarray( + raw5, + dtype=np.float32, + ) + + if ( + raw5.ndim != 3 + or raw5.shape[0] + != len( + PHYSICAL_CHANNEL_ORDER + ) + ): + raise RuntimeError( + "Tensor físico inválido: esperado Raw5 " + f"{PHYSICAL_CHANNEL_ORDER}, " + f"shape={raw5.shape}" + ) + + expected_w, expected_h = ( + self.get_expected_input_size() ) - if model_chw.shape[0] != self.channels: + + if ( + int( + raw5.shape[2] + ) != expected_w + or int( + raw5.shape[1] + ) != expected_h + ): raise RuntimeError( - f"Tensor nominal inválido: esperado C={self.channels}, " - f"veio shape={model_chw.shape}" + "Raw5 H/W diverge do ONNX. " + f"raw5={raw5.shape}, " + f"esperado=(5,{expected_h},{expected_w}). " + "O SegformerService não faz resize." ) - if len(self.onnx_input_shape) == 4: - expected_h, expected_w = self.onnx_input_shape[2], self.onnx_input_shape[3] - if isinstance(expected_h, (int, np.integer)) and int(expected_h) != model_chw.shape[1]: - raise RuntimeError( - f"Altura incompatível com ONNX: modelo={expected_h}, tensor={model_chw.shape[1]}" - ) - if isinstance(expected_w, (int, np.integer)) and int(expected_w) != model_chw.shape[2]: - raise RuntimeError( - f"Largura incompatível com ONNX: modelo={expected_w}, tensor={model_chw.shape[2]}" - ) + model_chw = ( + build_model_input_tensor( + raw5, + self.input_channel_names, + self.derived_channels_config, + ) + ) - h, w = int(raw5.shape[1]), int(raw5.shape[2]) + if ( + model_chw.shape[0] + != self.channels + ): + raise RuntimeError( + "Tensor nominal inválido: " + f"esperado C={self.channels}, " + f"shape={model_chw.shape}" + ) - # No contrato v1 não normaliza aqui. - # O ONNX oficial já possui normalização interna. - x = model_chw[None, :, :, :].astype(np.float32, copy=False) - x = np.ascontiguousarray(x, dtype=np.float32) + if ( + model_chw.shape[1] + != expected_h + or model_chw.shape[2] + != expected_w + ): + raise RuntimeError( + "Tensor nominal mudou H/W inesperadamente: " + f"shape={model_chw.shape}" + ) - return raw5, x, (h, w) + # NÃO normaliza e NÃO redimensiona. + x = np.ascontiguousarray( + model_chw[ + None, + :, + :, + :, + ], + dtype=np.float32, + ) - def _select_input_channels(self, chw: np.ndarray) -> np.ndarray: - # Compatibilidade com chamadas externas antigas: a entrada continua Raw5. + return ( + raw5, + x, + ( + expected_h, + expected_w, + ), + ) + + def _select_input_channels( + self, + chw: np.ndarray, + ) -> np.ndarray: + # Compatibilidade externa. return build_model_input_tensor( chw, self.input_channel_names, self.derived_channels_config, ) - @staticmethod - def _onnx_output_to_mask(arr: np.ndarray, out_hw) -> np.ndarray: - h, w = int(out_hw[0]), int(out_hw[1]) + def _onnx_output_to_mask( + self, + arr: np.ndarray, + out_hw, + ) -> np.ndarray: + h = int( + out_hw[0] + ) - arr = np.asarray(arr) + w = int( + out_hw[1] + ) - # Formatos esperados para ONNX full-runtime: - # HxW - # 1xHxW - # BxHxW + arr = np.asarray( + arr + ) + + # Export full-runtime esperado: + # HxW + # 1xHxW if arr.ndim == 2: - mask = arr.astype(np.uint8, copy=False) + mask = arr - elif arr.ndim == 3: - mask = arr[0].astype(np.uint8, copy=False) + elif ( + arr.ndim == 3 + and arr.shape[0] == 1 + ): + mask = arr[0] + + elif ( + arr.ndim == 4 + and arr.shape[0] == 1 + and arr.shape[1] == 1 + ): + # Tolerância para alguns exporters que preservam canal singleton. + mask = arr[ + 0, + 0, + ] else: raise RuntimeError( - f"Saída ONNX inválida para contrato mask. " - f"Esperado HxW ou 1xHxW, veio shape={arr.shape}" + "Saída ONNX inválida para máscara operacional: " + f"shape={arr.shape}. Esperado HxW, 1xHxW " + "ou 1x1xHxW." ) - if mask.shape[0] != h or mask.shape[1] != w: - mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST) + if ( + mask.shape[0] != h + or mask.shape[1] != w + ): + if self.strict_output_shape: + raise RuntimeError( + "ONNX não devolveu máscara full-resolution: " + f"output={mask.shape}, esperado={(h, w)}. " + "Reexporte o modelo. O runtime produto não redimensiona " + "a máscara silenciosamente." + ) - return mask.astype(np.uint8, copy=False) + mask = cv2.resize( + mask, + ( + w, + h, + ), + interpolation=cv2.INTER_NEAREST, + ) + + return np.ascontiguousarray( + mask.astype( + np.uint8, + copy=False, + ) + ) # ============================================================ # Preview / stream # ============================================================ - def preview_infer_cached(self, tensor5_chw=None, predictions=None, alpha=0.5): + def preview_infer_cached( + self, + tensor5_chw=None, + predictions=None, + alpha=0.5, + ): """ - Gera frames BGR para TCP/C#/debug. + Somente visualização. - Não roda inferência aqui. - Usa: - - tensor multiespectral cacheado - - máscara binária cacheada da cabeça selecionada + Não roda inferência e não altera o tensor científico. """ - tensor = tensor5_chw if tensor5_chw is not None else self._ultimo_tensor - pred = predictions if predictions is not None else self._ultimo_predictions + tensor = ( + tensor5_chw + if tensor5_chw + is not None + else self._ultimo_tensor + ) + + pred = ( + predictions + if predictions + is not None + else self._ultimo_predictions + ) if tensor is None: - return None, None, None, None, None + return ( + None, + None, + None, + None, + None, + ) - rgb = self.tensor_to_preview_rgb(tensor) - rgb_bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) + rgb = ( + self.tensor_to_preview_rgb( + tensor + ) + ) + + rgb_bgr = cv2.cvtColor( + rgb, + cv2.COLOR_RGB2BGR, + ) if pred is None: - return rgb_bgr, None, rgb_bgr, None, None + return ( + rgb_bgr, + None, + rgb_bgr, + None, + None, + ) - seg_rgb = self.ids_to_rgb(pred, HEAD_COLORS_RGB) - overlay_rgb = cv2.addWeighted(rgb, 1.0 - alpha, seg_rgb, alpha, 0.0) + seg_rgb = self.ids_to_rgb( + pred, + HEAD_COLORS_RGB, + ) - seg_bgr = cv2.cvtColor(seg_rgb, cv2.COLOR_RGB2BGR) - overlay_bgr = cv2.cvtColor(overlay_rgb, cv2.COLOR_RGB2BGR) + overlay_rgb = cv2.addWeighted( + rgb, + 1.0 - alpha, + seg_rgb, + alpha, + 0.0, + ) - return rgb_bgr, seg_bgr, overlay_bgr, None, None + seg_bgr = cv2.cvtColor( + seg_rgb, + cv2.COLOR_RGB2BGR, + ) + + overlay_bgr = cv2.cvtColor( + overlay_rgb, + cv2.COLOR_RGB2BGR, + ) + + return ( + rgb_bgr, + seg_bgr, + overlay_bgr, + None, + None, + ) @staticmethod - def tensor_to_preview_rgb(chw: np.ndarray, gamma: float = 0.85) -> np.ndarray: + def tensor_to_preview_rgb( + chw: np.ndarray, + gamma: float = 0.85, + ) -> np.ndarray: if chw is None: return None - chw = np.asarray(chw) + chw = np.asarray( + chw + ) if chw.ndim != 3: - raise RuntimeError(f"Tensor inválido para preview: esperado CHW, veio shape={chw.shape}") + raise RuntimeError( + "Tensor inválido para preview: " + f"shape={chw.shape}" + ) - c, h, w = chw.shape + if chw.shape[0] < 3: + raise RuntimeError( + "Preview RGB exige ao menos R/G/B." + ) - if c >= 3: - rgb = np.transpose(chw[:3], (1, 2, 0)).copy() - else: - one = chw[0] - rgb = np.stack([one, one, one], axis=-1) + # O cache do service é sempre Raw5 físico, + # então [:3] é R,G,B por contrato. + rgb = np.transpose( + chw[:3], + ( + 1, + 2, + 0, + ), + ).copy() - rgb = np.nan_to_num(rgb, nan=0.0, posinf=1.0, neginf=0.0) + np.nan_to_num( + rgb, + copy=False, + nan=0.0, + posinf=1.0, + neginf=0.0, + ) - lo = np.percentile(rgb, 1.0) - hi = np.percentile(rgb, 99.0) + # Preview only. + lo = float( + np.percentile( + rgb, + 1.0, + ) + ) + + hi = float( + np.percentile( + rgb, + 99.0, + ) + ) if hi > lo: - rgb = (rgb - lo) / (hi - lo) + rgb = ( + rgb - lo + ) / ( + hi - lo + ) - rgb = np.clip(rgb, 0.0, 1.0) + np.clip( + rgb, + 0.0, + 1.0, + out=rgb, + ) if gamma and gamma > 0: - rgb = np.power(rgb, gamma) + np.power( + rgb, + gamma, + out=rgb, + ) - return (rgb * 255.0).astype(np.uint8) + return ( + rgb * 255.0 + ).astype( + np.uint8 + ) @staticmethod - def ids_to_rgb(mask: np.ndarray, colormap_rgb: Dict[int, Tuple[int, int, int]]) -> np.ndarray: - mask = np.asarray(mask) + def ids_to_rgb( + mask: np.ndarray, + colormap_rgb: Dict[ + int, + Tuple[int, int, int], + ], + ) -> np.ndarray: + mask = np.asarray( + mask + ) if mask.ndim != 2: - raise RuntimeError(f"Máscara inválida para preview: esperado HxW, veio shape={mask.shape}") + raise RuntimeError( + "Máscara inválida para preview: " + f"shape={mask.shape}" + ) - h, w = mask.shape[:2] - out = np.zeros((h, w, 3), dtype=np.uint8) + h, w = ( + mask.shape[:2] + ) - for cid, color in colormap_rgb.items(): - out[mask == int(cid)] = color + out = np.zeros( + ( + h, + w, + 3, + ), + dtype=np.uint8, + ) + + for cid, color in ( + colormap_rgb.items() + ): + out[ + mask + == int(cid) + ] = color return out diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/segformer_runner.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/segformer_runner.py index 77465b841..b31e898f0 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/segformer_runner.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/segformer_runner.py @@ -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") ), } diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py index 7e9124fb1..115de31e8 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py @@ -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"), diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py index 18ffe14e1..968cf5430 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py @@ -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 { diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py index 68cd8d1b5..af9f8f6aa 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py @@ -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({ diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/regras_taticas.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/regras_taticas.py index e87d932bf..e9e092a53 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/regras_taticas.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/regras_taticas.py @@ -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. diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py index 8410ccad8..52355f47b 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py @@ -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, diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py index 33a170d3b..b94d63500 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py @@ -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 diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py index d6bf1b92f..1f7339c1c 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/config.py @@ -1,337 +1,1044 @@ -import time -import threading +# weed_worker/config.py +# -*- coding: utf-8 -*- + +""" +Configuração do Weed Worker - Production +======================================== + +Este arquivo contém SOMENTE: + 1. políticas operacionais realmente ajustáveis; + 2. parâmetros do WeedDetector usados pela estratégia oficial; + 3. execução ONNX/TensorRT; + 4. overrides vindos do Redis/equipamento. + +NÃO pertencem mais a este arquivo: + - resolução nativa das câmeras; + - sensor RGB/RE/NIR; + - Bayer; + - Homography; + - Flat-Field; + - Radiometry; + - camera_settings; + - tamanho físico do sensor; + - ia_resolution como segunda fonte de verdade. + +Autoridades oficiais +-------------------- +Hardware/calibração: + module_params.json homologado + +Resolução final do tensor: + ONNX input H/W + == + module_params.fusion_config.target_size + +Ordem nominal dos canais: + input_channels deste config + == + metadata do ONNX + +O CameraManager valida esses contratos antes de abrir a OAK. +""" + +from __future__ import annotations + +import threading +import time +from copy import deepcopy +from pathlib import Path -from weed_worker.camera_manager import CameraManager from shared.contexto_global_redis import ContextoGlobalRedis + +WEED_CONFIG_VERSION = "production_v1_2026_08_24" + module_id = "weed" topico_tx = f"operador/{module_id}/tx" topico_rx = f"operador/{module_id}/rx" + debug = True ultima_atualizacao = time.time() + def mostrar_log(mensagem): if debug: - print(f"{time.time()} - [{module_id}] {mensagem}") + print( + f"{time.time()} - " + f"[{module_id}] {mensagem}" + ) + + +# Import tardio preservado para evitar ciclo de import. +from weed_worker.camera_manager import CameraManager + +manager = CameraManager( + mostrar_log +) -manager = CameraManager(mostrar_log) def get_camera_manager(): return manager + def iniciar_camera_manager(mx_id): - global manager, ultima_atualizacao + global manager + global ultima_atualizacao + if mx_id is None: return + agora = time.time() - if (not manager.iniciando and (agora - ultima_atualizacao) > 5 and (manager.camera is None or manager.mx_id != mx_id)): + + if ( + not manager.iniciando + and ( + agora + - ultima_atualizacao + ) > 5 + and ( + manager.camera is None + or manager.mx_id + != mx_id + ) + ): ultima_atualizacao = agora - mostrar_log("Iniciando Camera Manager...") - manager.inicializar(mx_id=mx_id) - if manager.camera is not None and manager.operante: - mostrar_log(f"✅ Camera manager iniciado, com MX_ID: {mx_id}") + + mostrar_log( + "Iniciando Camera Manager..." + ) + + manager.inicializar( + mx_id=mx_id + ) + + if ( + manager.camera + is not None + and manager.operante + ): + mostrar_log( + "✅ Camera manager iniciado, " + f"com MX_ID: {mx_id}" + ) -_CONFIG_CACHE = None -_CONFIG_LOCK = threading.Lock() +# ============================================================ +# Invariantes de produto +# ============================================================ + +# Não são knobs de campo. +# Se algum deles precisar mudar, trata-se de mudança de contrato/software. +PRODUCT_INVARIANTS = { + # O produto usa o pipeline científico RAW completo. + "require_product_contract": True, + + # Runtime oficial. + "runtime_backend": "onnx", + "onnx_output_kind": "mask", + + # O export oficial já contém normalização e pós-processamento. + "onnx_preprocess_norm": False, + "require_onnx_metadata": True, + "strict_output_shape": True, + + # Falha de provider não pode virar CPU silenciosamente. + "allow_provider_fallback": False, + + # Raw5 já chega float32 CHW 0..1 validado pelo pipeline. + "trust_input": True, + + # Caminho quente retorna somente a máscara selecionada. + "return_full_fast": False, + + # Sincronização CUDA explícita só atrapalha o caminho quente. + "sync_for_timing": False, + + # Contrato do WeedDetector. + "prediction_contract": "target_binary", + + # Estratégia oficial de atuação. + "usar_memoria_espacial_bicos": True, +} + + +# ============================================================ +# Configuração realmente ajustável +# ============================================================ WEED_DEFAULT_CONFIG = { - # ============================================================ - # 1) Debug e telemetria - # ============================================================ - # Mostra janela OpenCV/debug visual. Não usar em runtime de campo. + # ======================================================== + # 1) Observabilidade / salvamento + # ======================================================== "debug_visual": False, - "posproc_intervalo_min_s": 5.0, - - # Publica logs de performance no console. "debug_perf": False, - - # Inclui tempos internos do WeedDetector no payload de análise. - # Barato e útil nesta fase; pode desligar na versão final final. "detector_debug_perf": True, + # RAW científico para pós-processamento. + "posproc_intervalo_min_s": 5.0, - # ============================================================ - # 2) Frequências dos loops - # ============================================================ - # Pipeline oficial validado em ~25 FPS. + + # ======================================================== + # 2) Frequências + # ======================================================== + # FPS solicitado à OAK. + "camera_fps": 40, + + # Loops do Weed Worker. "tensor_fps": 25.0, "inferencia_fps": 25.0, "deteccao_fps": 25.0, - # Supervisor/performance. Não precisa ser igual ao pipeline. + # Supervisor/telemetria. "analise_fps": 10.0, - - # Publicação Redis. Mantém baixo para não virar ruído. "publicacao_fps": 5.0, - # ============================================================ - # 3) Câmera multiespectral - # ============================================================ - "camera_width": 1280, - "camera_height": 800, + # ======================================================== + # 3) Watchdogs + # ======================================================== + "watchdog_tensor_timeout_s": 2.0, + "watchdog_inferencia_timeout_s": 2.0, + "watchdog_deteccao_timeout_s": 2.0, - # FPS solicitado na câmera/OAK. Pode ser maior que o pipeline. - "camera_fps": 40, - # Tamanho final do tensor entregue ao modelo: [W, H]. - "ia_resolution": [640, 400], + # ======================================================== + # 4) Modelo / TensorRT + # ======================================================== + # Cabeça operacional. + # Pode ser atualizada dinamicamente pelo Redis: + # target | vegetation | cana | semantic + "runtime_mode": "target", - # Ordem oficial do tensor multiespectral. - # Deve bater com o modelo ONNX exportado. - "channels": 5, + # Só é usada em runtime_mode=semantic. + "semantic_target_class": "erva", + + # Ordem nominal do tensor que o ONNX espera. + # + # Modelo 5ch: + # ["R", "G", "B", "RE", "NIR"] + # + # Modelo 7ch: + # ["R", "G", "B", "RE", "NIR", "NDVI", "NDRE"] + # + # O SegformerService cruza isso com metadata do ONNX. "input_channels": [ "R", "G", "B", "RE", "NIR", - #"NDVI", - #"NDRE", ], + # Só tem efeito se input_channels pedir NDVI/NDRE. "derived_channels": { "epsilon": 1e-6, "clip_min": -1.0, "clip_max": 1.0, }, - - # ============================================================ - # 4) Modelo ONNX/TensorRT - # ============================================================ - # Runtime oficial da primeira versão. - "runtime_backend": "onnx", + # Provider oficial. "onnx_provider": "tensorrt", - # Cabeça utilizada: - # target | vegetation | cana | semantic - "runtime_mode": "target", - - # Usado somente quando runtime_mode = semantic: - # chao | cana | erva - "semantic_target_class": "erva", - - # Contrato do ONNX novo: - # entrada 0..1 crua -> normalização interna -> resize -> argmax -> target_mask. - "onnx_output_kind": "mask", - "onnx_preprocess_norm": False, - - # TensorRT FP16 validado. + "trt_engine_cache_enable": True, + "trt_engine_cache_path": "./trt_cache_weed_worker", + "trt_max_workspace_size": None, "trt_fp16": True, - # Assume que o tensor já vem float32 CHW 0..1, contíguo e limpo. - "trust_input": True, - # Não retornar dict completo no caminho quente. - "return_full_fast": False, - - # Sincronização CUDA só para debug fino. Deixar False no runtime. - "sync_for_timing": False, - - - # ============================================================ - # 5) Contrato da máscara para o WeedDetector - # ============================================================ - # target_binary: - # 0 = fundo / não pulverizar - # 1 = alvo pulverizável - "prediction_contract": "target_binary", - - - # ============================================================ - # 6) Radar global de alvo - # ============================================================ - # Gate global: só libera bicos quando existe alvo suficiente no frame. - "usar_radar_global_gate": True, - "inverter_ordem_bicos": False, - "inverter_sentido_vertical_bicos": True, - - # Histerese global da fração de alvo no frame completo. - "min_frac_erva_global_on": 0.0020, - "min_frac_erva_global_off": 0.0015, - - # Suavização temporal da fração global. - "erva_frac_ema": 0.30, - - # Ajuste do limiar global pela velocidade. - # 0.0 desativa; valores maiores deixam o gate mais sensível com velocidade. - "erva_thresh_vel_gain": 0.40, - - - # ============================================================ - # 7) Controle por bico - # ============================================================ + # ======================================================== + # 5) Geometria física dos bicos + # ======================================================== + # qtd_bicos é sobrescrito pelo equipamento/Redis. "qtd_bicos": 7, - # Suavização temporal por bico. - "ema_frac_bico": 0.35, + # Associação coluna da imagem -> bico físico. + "inverter_ordem_bicos": False, - # Debounce temporal por bico. - "on_frames_required": 3, - "off_frames_required": 2, + # True no rover atual: + # a aproximação física do alvo ocorre do pé para o topo da imagem. + "inverter_sentido_vertical_bicos": True, - # Ajuste local do limiar por velocidade. - "erva_thresh_vel_gain_local": 0.60, - - # Desloca a ROI vertical para compensar latência em movimento. - "k_roi_shift_px_per_vnorm": 24.0, - - - # ============================================================ - # 7.1) Memória espacial por bico / predição até atuação - # ============================================================ - # Estratégia nova: - # Cada bico mantém uma "esteira" normalizada da imagem. - # cell 0 = topo da imagem - # cell N-1 = pé da imagem - # - # A câmera alimenta a esteira. - # A velocidade do rover desloca a memória para baixo na imagem. - # O bico atua quando a evidência chega na zona de atuação. - "usar_memoria_espacial_bicos": True, - - # ------------------------------------------------------------ - # Campo / operação - # ------------------------------------------------------------ - # Zona de atuação em percentual vertical da imagem, de cima para baixo. - # - # Exemplo: - # faixa_atuacao_bicos = 0.70 - # area_atuacao_bicos = 0.10 - # - # Resultado: - # zona de atuação = 70% até 80% do frame - # predição antes = 0% até 70% - # sobra depois = 80% até 100% + # Zona física de atuação no frame. + # Ambos podem ser sobrescritos pela configuração da operação. "faixa_atuacao_bicos": 0.70, "area_atuacao_bicos": 0.10, - # ------------------------------------------------------------ - # Técnico / baseline - # ------------------------------------------------------------ - # Quantidade de células verticais da esteira. - # 100 células = cada célula representa 1% da altura da imagem. - "memoria_num_cells": 100, - - # Fração vertical da imagem percorrida por segundo a 1 m/s. - # Exemplo: 0.90 significa que, a 1 m/s, o chão percorre ~90% - # da imagem por segundo. - "k_shift_frame_por_mps": None, + # Footprint longitudinal usado para converter velocidade em deslocamento + # da memória espacial. "comprimento_visao_chao_cm": 110.0, - # Latência total estimada até o produto realmente atingir o alvo. - # Inclui worker + Redis/C# + CAN + solenoide + hidráulica. - "latencia_total_atuacao_ms": 120.0, - - # Atualização da confiança espacial. - "min_frac_erva_predicao": 0.004, - "percent_ref_evidencia": 0.020, - "score_up_gain": 0.25, - "score_down_decay": 0.06, - - # Histerese da memória no momento da atuação. - "score_ligar_bico": 0.55, - "score_desligar_bico": 0.25, - - # Agregação das células dentro da zona de atuação. - # Opções: "max", "mean", "max_mean". - "atuacao_score_mode": "max_mean", - - # Segurança contra saltos grandes de tempo entre frames. - "max_dt_shift_s": 0.50, - - # Com a memória espacial, o radar global não deve matar bico pendente. - # False evita perder erva já vista que ainda está chegando na atuação. - "radar_global_bloqueia_bicos": False, - - # Mantidos só para debug/telemetria aproximada, não como regulagem de campo. - "comprimento_visao_chao_cm": 110.0, + # Usado para diagnóstico/representação física por bico. "largura_visao_chao_cm": 170.0, - # ============================================================ - # 8) Filtros opcionais da máscara - # ============================================================ - # No contrato target_binary, a própria IA já entrega o alvo final. - # Deixar desligado no baseline oficial. + # ======================================================== + # 6) Radar global de alvo + # ======================================================== + "usar_radar_global_gate": True, + + "min_frac_erva_global_on": 0.0020, + "min_frac_erva_global_off": 0.0015, + + "erva_frac_ema": 0.30, + + # Sensibiliza o radar global conforme velocidade. + # 0.0 desativa. + "erva_thresh_vel_gain": 0.40, + + + # ======================================================== + # 7) Memória espacial por bico + # ======================================================== + # 100 células = 1% da altura da imagem por célula. + "memoria_num_cells": 100, + + # Abaixo desta velocidade a memória considera o rover parado. + "vel_min_movimento_memoria_mps": 0.03, + + # Segurança contra gaps grandes entre frames. + "max_dt_shift_s": 0.50, + + # Evidência mínima de alvo em uma célula para atualizar score. + "min_frac_erva_predicao": 0.004, + + # Fração de alvo considerada evidência cheia (= 1.0). + "percent_ref_evidencia": 0.020, + + # Dinâmica do score em movimento. + "score_up_gain": 0.25, + "score_down_decay": 0.06, + + # Quando parado, repetição do mesmo terreno vira EMA, + # não evidência espacial acumulada infinita. + "score_alpha_parado": 0.70, + + # Histerese da confiança do bico. + "score_ligar_bico": 0.55, + "score_desligar_bico": 0.25, + + # Debounce por frame. + "on_frames_required": 3, + "off_frames_required": 2, + + # max | mean | max_mean + "atuacao_score_mode": "max_mean", + + # Por padrão o radar do frame atual não apaga alvo já vivo na memória. + "radar_global_bloqueia_bicos": False, + + + # ======================================================== + # 8) Compensação temporal até atuação + # ======================================================== + # Pipeline + Redis/C# + CAN + solenoide + hidráulica. + "latencia_total_atuacao_ms": 120.0, + + # Até esta velocidade a zona física permanece fixa. + # Acima dela, a avaliação é antecipada para compensar latência. + "velocidade_referencia_atuacao_mps": 0.65, + + + # ======================================================== + # 9) Filtros opcionais da máscara + # ======================================================== + # Baseline oficial: desligados. "usar_morfologia": False, "kernel_morf": 3, - # Remove componentes pequenos. 0 desativa. + # 0 desativa connected-components filter. "min_area_erva_px": 0, } -def aplicar_overrides_redis(cfg: dict) -> dict: - dados_atu = ContextoGlobalRedis.get_operacao().get("Atu", {}) - contexto = ContextoGlobalRedis.get_contexto() - equipamento = ContextoGlobalRedis.get_equipamento() - cfg["runtime_mode"] = str(dados_atu.get("modelo_cabeca") or "target").strip().lower() - cfg["semantic_target_class"] = str(dados_atu.get("modelo_classe_alvo") or "erva").strip().lower() +_CONFIG_LOCK = threading.Lock() - cfg["qtd_bicos"] = int(equipamento.get("qtd_bicos") or cfg.get("qtd_bicos", 7) or 7) - # Modelo ONNX full-runtime validado para o Weed Worker. - cfg["ia_model_path"] = equipamento.get("path_ia_model_ervas") - cfg["ia_module_params_path"] = equipamento.get("path_ia_module_params_ervas") +# ============================================================ +# Helpers +# ============================================================ - cfg["velocidade_robo"] = float( - contexto.get("Gerais", {}).get("velocidade_ms", 0.0) or 0.0 +def _bool(value): + if isinstance( + value, + bool, + ): + return value + + if value is None: + return False + + if isinstance( + value, + (int, float), + ): + return value != 0 + + text = str( + value + ).strip().lower() + + if text in ( + "1", + "true", + "sim", + "yes", + "on", + ): + return True + + if text in ( + "0", + "false", + "nao", + "não", + "no", + "off", + "", + ): + return False + + return bool( + value ) - cfg["faixa_atuacao_bicos"] = float( - dados_atu.get("percent_vertical_deteccao", 0.7) - ) - cfg["area_atuacao_bicos"] = float( - dados_atu.get("height_area_deteccao", 0.1) - ) +def _float( + value, + default=0.0, +): + try: + if value is None: + return float( + default + ) - cfg["min_frac_erva_por_bico_on"] = float( - dados_atu.get("pct_erva_bico_on", 0.02) - ) + return float( + value + ) - cfg["min_frac_erva_por_bico_off"] = float( - dados_atu.get("pct_erva_bico_off", 0.01) - ) + except Exception: + return float( + default + ) - return cfg -def normalizar_config_runtime(cfg: dict) -> dict: - cfg["onnx_model_path"] = cfg.get("ia_model_path") - cfg["module_calibration_json"] = cfg.get("ia_module_params_path") +def _int( + value, + default=0, +): + try: + if value is None: + return int( + default + ) - cfg["camera_fps"] = int(cfg.get("camera_fps", 40)) + return int( + value + ) - input_channels = cfg.get("input_channels", ["R", "G", "B", "RE", "NIR"]) - if isinstance(input_channels, str): - input_channels = [c.strip().upper() for c in input_channels.split(",") if c.strip()] + except Exception: + return int( + default + ) + + +def _normalizar_input_channels( + value, +): + if isinstance( + value, + str, + ): + channels = [ + item.strip().upper() + for item + in value.split(",") + if item.strip() + ] else: - input_channels = [str(c).upper() for c in input_channels] + channels = [ + str(item).strip().upper() + for item + in ( + value + or [] + ) + if str(item).strip() + ] - cfg["input_channels"] = input_channels + allowed = { + "R", + "G", + "B", + "RE", + "NIR", + "NDVI", + "NDRE", + } - if not cfg.get("onnx_model_path"): - mostrar_log("[WARN] path do modelo ONNX de ervas não definido no Redis/equipamento.") + if not channels: + raise RuntimeError( + "input_channels não pode ser vazio." + ) - if not cfg.get("module_calibration_json"): - mostrar_log("[WARN] path de calibração/module_params do WeedWorker não definido.") + invalid = [ + ch + for ch in channels + if ch not in allowed + ] - if cfg["runtime_backend"] == "onnx" and cfg["onnx_preprocess_norm"]: - mostrar_log("[WARN] onnx_preprocess_norm=True não é permitido no modelo full-runtime.") + if invalid: + raise RuntimeError( + f"input_channels inválidos: {invalid}" + ) + + if len( + channels + ) != len( + set(channels) + ): + raise RuntimeError( + f"input_channels duplicados: {channels}" + ) + + return channels + + +def _path_required( + value, + field_name, +): + if not value: + raise RuntimeError( + f"{field_name} não definido no equipamento/Redis." + ) + + path = Path( + str(value) + ) + + if not path.is_file(): + raise FileNotFoundError( + f"{field_name} não encontrado: {path}" + ) + + return str( + path + ) + + +def _range( + cfg, + key, + lo, + hi, +): + value = float( + cfg[key] + ) + + if not ( + lo <= value <= hi + ): + raise RuntimeError( + f"{key} fora da faixa [{lo},{hi}]: {value}" + ) + + cfg[key] = value + + +def _positive( + cfg, + key, + allow_zero=False, +): + value = float( + cfg[key] + ) + + valid = ( + value >= 0.0 + if allow_zero + else value > 0.0 + ) + + if not valid: + op = ">=" if allow_zero else ">" + raise RuntimeError( + f"{key} deve ser {op} 0: {value}" + ) + + cfg[key] = value + + +# ============================================================ +# Overrides dinâmicos +# ============================================================ + +def aplicar_overrides_redis( + cfg: dict, +) -> dict: + operacao = ( + ContextoGlobalRedis + .get_operacao() + or {} + ) + + dados_atu = ( + operacao.get( + "Atu", + {}, + ) + or {} + ) + + contexto = ( + ContextoGlobalRedis + .get_contexto() + or {} + ) + + equipamento = ( + ContextoGlobalRedis + .get_equipamento() + or {} + ) + + # -------------------------------------------------------- + # Estrutural: paths vêm do cadastro do equipamento. + # Não criamos aliases ia_model_path / ia_module_params_path. + # -------------------------------------------------------- + cfg[ + "onnx_model_path" + ] = equipamento.get( + "path_ia_model_ervas" + ) + + cfg[ + "module_calibration_json" + ] = equipamento.get( + "path_ia_module_params_ervas" + ) + + # -------------------------------------------------------- + # Cabeça operacional pode mudar dinamicamente. + # -------------------------------------------------------- + cfg[ + "runtime_mode" + ] = str( + dados_atu.get( + "modelo_cabeca" + ) + or cfg[ + "runtime_mode" + ] + ).strip().lower() + + cfg[ + "semantic_target_class" + ] = str( + dados_atu.get( + "modelo_classe_alvo" + ) + or cfg[ + "semantic_target_class" + ] + ).strip().lower() + + # -------------------------------------------------------- + # Geometria/estado do equipamento. + # -------------------------------------------------------- + cfg[ + "qtd_bicos" + ] = _int( + equipamento.get( + "qtd_bicos" + ), + cfg[ + "qtd_bicos" + ], + ) + + cfg[ + "velocidade_robo" + ] = _float( + ( + contexto.get( + "Gerais", + {}, + ) + or {} + ).get( + "velocidade_ms", + 0.0, + ), + 0.0, + ) + + # -------------------------------------------------------- + # Parâmetros de campo expostos no C#. + # -------------------------------------------------------- + if ( + dados_atu.get( + "percent_vertical_deteccao" + ) + is not None + ): + cfg[ + "faixa_atuacao_bicos" + ] = _float( + dados_atu.get( + "percent_vertical_deteccao" + ), + cfg[ + "faixa_atuacao_bicos" + ], + ) + + if ( + dados_atu.get( + "height_area_deteccao" + ) + is not None + ): + cfg[ + "area_atuacao_bicos" + ] = _float( + dados_atu.get( + "height_area_deteccao" + ), + cfg[ + "area_atuacao_bicos" + ], + ) + + # IMPORTANTE: + # pct_erva_bico_on/off pertenciam à lógica ROI legada. + # A estratégia oficial usa: + # min_frac_erva_predicao + # percent_ref_evidencia + # score_ligar_bico + # score_desligar_bico + # + # Portanto NÃO fingimos que pct_erva_bico_on/off ainda controlam + # a memória espacial. return cfg + +# ============================================================ +# Normalização / validação +# ============================================================ + +def normalizar_config_runtime( + cfg: dict, +) -> dict: + # Invariantes vencem qualquer default/override acidental. + cfg.update( + deepcopy( + PRODUCT_INVARIANTS + ) + ) + + cfg[ + "input_channels" + ] = _normalizar_input_channels( + cfg.get( + "input_channels" + ) + ) + + # Paths estruturais obrigatórios no produto. + cfg[ + "onnx_model_path" + ] = _path_required( + cfg.get( + "onnx_model_path" + ), + "onnx_model_path", + ) + + cfg[ + "module_calibration_json" + ] = _path_required( + cfg.get( + "module_calibration_json" + ), + "module_calibration_json", + ) + + # Tipos básicos. + cfg[ + "camera_fps" + ] = _int( + cfg.get( + "camera_fps" + ), + 40, + ) + + cfg[ + "qtd_bicos" + ] = _int( + cfg.get( + "qtd_bicos" + ), + 7, + ) + + if cfg[ + "camera_fps" + ] <= 0: + raise RuntimeError( + "camera_fps deve ser > 0." + ) + + if cfg[ + "qtd_bicos" + ] <= 0: + raise RuntimeError( + "qtd_bicos deve ser > 0." + ) + + # Frequências. + for key in ( + "tensor_fps", + "inferencia_fps", + "deteccao_fps", + "analise_fps", + "publicacao_fps", + ): + _positive( + cfg, + key, + ) + + # Watchdogs. + for key in ( + "watchdog_tensor_timeout_s", + "watchdog_inferencia_timeout_s", + "watchdog_deteccao_timeout_s", + ): + _positive( + cfg, + key, + ) + + # Frações 0..1. + for key in ( + "faixa_atuacao_bicos", + "area_atuacao_bicos", + "min_frac_erva_global_on", + "min_frac_erva_global_off", + "erva_frac_ema", + "min_frac_erva_predicao", + "percent_ref_evidencia", + "score_up_gain", + "score_down_decay", + "score_alpha_parado", + "score_ligar_bico", + "score_desligar_bico", + ): + _range( + cfg, + key, + 0.0, + 1.0, + ) + + if ( + cfg[ + "min_frac_erva_global_off" + ] + > cfg[ + "min_frac_erva_global_on" + ] + ): + raise RuntimeError( + "min_frac_erva_global_off deve ser <= min_frac_erva_global_on." + ) + + if ( + cfg[ + "score_desligar_bico" + ] + >= cfg[ + "score_ligar_bico" + ] + ): + raise RuntimeError( + "score_desligar_bico deve ser < score_ligar_bico." + ) + + if ( + cfg[ + "faixa_atuacao_bicos" + ] + + cfg[ + "area_atuacao_bicos" + ] + > 1.000001 + ): + raise RuntimeError( + "faixa_atuacao_bicos + area_atuacao_bicos " + "não pode ultrapassar 1.0." + ) + + # Parâmetros físicos/temporais. + for key in ( + "comprimento_visao_chao_cm", + "largura_visao_chao_cm", + "memoria_num_cells", + "max_dt_shift_s", + "latencia_total_atuacao_ms", + ): + _positive( + cfg, + key, + ) + + _positive( + cfg, + "vel_min_movimento_memoria_mps", + allow_zero=True, + ) + + _positive( + cfg, + "velocidade_referencia_atuacao_mps", + allow_zero=True, + ) + + cfg[ + "memoria_num_cells" + ] = int( + cfg[ + "memoria_num_cells" + ] + ) + + cfg[ + "on_frames_required" + ] = max( + 1, + _int( + cfg.get( + "on_frames_required" + ), + 1, + ), + ) + + cfg[ + "off_frames_required" + ] = max( + 1, + _int( + cfg.get( + "off_frames_required" + ), + 1, + ), + ) + + mode = str( + cfg.get( + "atuacao_score_mode", + "max_mean", + ) + ).strip().lower() + + if mode not in ( + "max", + "mean", + "max_mean", + ): + raise RuntimeError( + f"atuacao_score_mode inválido: {mode}" + ) + + cfg[ + "atuacao_score_mode" + ] = mode + + # Runtime mode. + runtime_mode = str( + cfg.get( + "runtime_mode", + "target", + ) + ).strip().lower() + + if runtime_mode not in ( + "target", + "vegetation", + "cana", + "semantic", + ): + raise RuntimeError( + f"runtime_mode inválido: {runtime_mode}" + ) + + cfg[ + "runtime_mode" + ] = runtime_mode + + semantic_class = str( + cfg.get( + "semantic_target_class", + "erva", + ) + ).strip().lower() + + if semantic_class not in ( + "chao", + "cana", + "erva", + ): + raise RuntimeError( + "semantic_target_class inválida: " + f"{semantic_class}" + ) + + cfg[ + "semantic_target_class" + ] = semantic_class + + return cfg + + +# ============================================================ +# API +# ============================================================ + def load_seg_config(): - global _CONFIG_CACHE + """ + Retorna snapshot NOVO a cada chamada. + Não existe cache de valores dinâmicos: + - velocidade; + - cabeça; + - classe semântica; + - qtd_bicos; + - zona de atuação. + + O lock só impede leituras concorrentes inconsistentes durante a montagem. + """ with _CONFIG_LOCK: - cfg = dict(WEED_DEFAULT_CONFIG) + cfg = deepcopy( + WEED_DEFAULT_CONFIG + ) - cfg = aplicar_overrides_redis(cfg) - cfg = normalizar_config_runtime(cfg) + cfg = aplicar_overrides_redis( + cfg + ) - _CONFIG_CACHE = cfg - return _CONFIG_CACHE + cfg = normalizar_config_runtime( + cfg + ) + + cfg[ + "config_version" + ] = WEED_CONFIG_VERSION + + return cfg