Pure pursuit agora é filtrado em saindo rua, MPC simula os próximos candidatos de acordo com a posição real do motor, libera a pressão da linha após concluir operação, controle do agitador através da base, sincronização dos parametros da operação na IHM através da base

This commit is contained in:
Diego Freitas 2026-09-20 15:11:44 -03:00
parent 5fb8caa88c
commit bd1526cdaf
18 changed files with 34018 additions and 420 deletions

View File

@ -23,6 +23,9 @@ namespace AgroBase.Forms.IHM.Operacao
private readonly ucParametrosMapa _ucMapa;
private OperacaoModel operacaoParametrizando;
private OperacaoModel _operacaoFonteCarregada;
private OperacaoParametrosModel _parametrosFonteCarregados;
private OperacaoParametrosControleModel _controleFonteCarregado;
public ucOperacaoParametros()
{
@ -135,6 +138,11 @@ namespace AgroBase.Forms.IHM.Operacao
private void DefinirOperacaoParametrizando(OperacaoModel operacao, bool recarregarMapa = true)
{
operacaoParametrizando = operacao;
_operacaoFonteCarregada = operacao;
_parametrosFonteCarregados = operacao?.Parametros;
_controleFonteCarregado = operacao?.Parametros?.Controle;
CarregarDadosGerais();
_ucMovimento.DefinirOperacao(operacaoParametrizando);
_ucDirecional.DefinirOperacao(operacaoParametrizando);
@ -298,12 +306,46 @@ namespace AgroBase.Forms.IHM.Operacao
}
public void AtualizarParametrosTela(OperacaoModel op)
public void AtualizarParametrosTela(
OperacaoModel op,
bool recarregarMapa = true)
{
DefinirOperacaoParametrizando(op, true);
DefinirOperacaoParametrizando(op, recarregarMapa);
}
/// <summary>
/// Recarrega os controles somente quando a operação ou alguma das
/// instâncias autoritativas de parâmetros foi substituída. Isso cobre
/// a parametrização parcial recebida da base sem apagar uma edição
/// local que o operador esteja fazendo sobre a mesma instância.
/// </summary>
public void SincronizarComOperacaoAtualSeNecessario()
{
OperacaoModel atual = Variaveis.OperacaoEmAndamento;
if (atual == null)
return;
bool fonteAlterada =
!ReferenceEquals(_operacaoFonteCarregada, atual) ||
!ReferenceEquals(_parametrosFonteCarregados, atual.Parametros) ||
!ReferenceEquals(
_controleFonteCarregado,
atual.Parametros?.Controle
);
if (!fonteAlterada)
return;
// Em atualização parcial a operação e o mapa continuam os mesmos.
// Recarregar o WebView aqui é desnecessário e pode perder estado
// visual; os demais controles devem refletir a nova autoridade.
DefinirOperacaoParametrizando(atual, recarregarMapa: false);
}
public void AtualizarDadosTela()
{
SincronizarComOperacaoAtualSeNecessario();
operacaoParametrizando?.Mapa?.AtualizarInteratividadeMapaAsync(parametrizacao: true);
}

View File

@ -73,9 +73,40 @@ namespace AgroBase.Forms.IHM
}
}
public void AtualizarParametrosTela(OperacaoModel op)
public void AtualizarParametrosTela(
OperacaoModel op,
bool recarregarMapa = true)
{
_ucParametros.AtualizarParametrosTela(op);
if (IsDisposed || Disposing)
return;
// Se o formulário ainda não criou o handle, não force sua criação
// na thread MQTT. Ao abrir a aba, a sincronização por referência
// abaixo carregará a operação atual.
if (!IsHandleCreated)
return;
if (InvokeRequired)
{
try
{
BeginInvoke(new Action<OperacaoModel, bool>(
AtualizarParametrosTela
), op, recarregarMapa);
}
catch (InvalidOperationException)
{
// O formulário pode estar encerrando entre a verificação
// e o BeginInvoke. A próxima abertura sincroniza a fonte.
}
catch (ObjectDisposedException)
{
}
return;
}
_ucParametros.AtualizarParametrosTela(op, recarregarMapa);
}
@ -124,6 +155,7 @@ namespace AgroBase.Forms.IHM
break;
case AbaOperacaoIHM.Parametros:
lblContextoAba.Text = "Parâmetros da operação atual e configuração local";
_ucParametros.SincronizarComOperacaoAtualSeNecessario();
ExibirConteudo(_ucParametros);
break;
default:

View File

@ -89,6 +89,12 @@ namespace AgroBase.Forms
private double velocidadeCarroMs;
private double _ultimoAnguloControleAplicado;
private double _ultimoAnguloControleAlvoAtrasado;
// Modelo simples do atuador direcional: a fila existente representa o
// atraso de transporte; este limite representa o tempo físico de giro.
// Mantido no código para não exigir mudança no Designer nesta versão.
private double VelocidadeAngularDirecionalGrausSegundo => VariaveisEquipamento.VelocidadeAngularDirecionalGrausSegundo;
private GPSModel posicaoAtual = new GPSModel();
// Mantida para compatibilidade e para os dados apresentados na tela.
@ -398,11 +404,26 @@ namespace AgroBase.Forms
if (atualizarAnguloControle)
{
anguloControle = CalcularAnguloControleAtrasado(
_ultimoAnguloControleAlvoAtrasado = CalcularAnguloControleAtrasado(
Volatile.Read(ref _delayComandoPassos)
);
}
// Diferente da versão anterior, o fim da fila vira um alvo e
// não um salto instantâneo. A pose usa somente o ângulo físico.
anguloControle = AtualizarAnguloControleFisico(
_ultimoAnguloControleAlvoAtrasado,
tempoPassadoSegundos
);
if (operacao.DispMvd?.Dados != null)
{
operacao.DispMvd.Dados.AnguloDirecionalSimuladoCanonico =
anguloControle;
operacao.DispMvd.Dados.MomentoAnguloDirecionalSimulado =
DateTime.UtcNow;
}
GPSModel gps = GPSService.GetSnapshot();
double anguloAtual =
@ -1614,7 +1635,10 @@ namespace AgroBase.Forms
);
lock (_estadoSimulacaoLock)
{
_ultimoAnguloControleAlvoAtrasado = angulo;
_ultimoAnguloControleAplicado = angulo;
}
txtAnguloControle.Text = angulo.ToString("0.00");
}
@ -1638,9 +1662,26 @@ namespace AgroBase.Forms
historicoAngulosControle.Dequeue();
}
_ultimoAnguloControleAplicado =
historicoAngulosControle.Peek();
return historicoAngulosControle.Peek();
}
}
private double AtualizarAnguloControleFisico(
double anguloAlvo,
double dtSegundos)
{
if (!ValorFinito(anguloAlvo))
anguloAlvo = 0.0;
double passoMaximo =
VelocidadeAngularDirecionalGrausSegundo *
Math.Max(0.0, dtSegundos);
lock (_estadoSimulacaoLock)
{
double erro = anguloAlvo - _ultimoAnguloControleAplicado;
double passo = Math.Max(-passoMaximo, Math.Min(passoMaximo, erro));
_ultimoAnguloControleAplicado += passo;
return _ultimoAnguloControleAplicado;
}
}

View File

@ -32,9 +32,11 @@ namespace AgroBase.Models.Modules
private DateTime _ultimaDescobertaEnviada = DateTime.MinValue;
private readonly object _estadoLock = new object();
private readonly SemaphoreSlim _calibragemGate = new SemaphoreSlim(1, 1);
private readonly SemaphoreSlim _alivioPressaoGate = new SemaphoreSlim(1, 1);
private readonly object _calibragemCtsLock = new object();
private CancellationTokenSource _calibragemCts;
private bool _sincronizacaoSeguraPendente;
private volatile bool _alivioPressaoLinhaEmAndamento;
[JsonIgnore]
internal object EstadoSyncRoot => _estadoLock;
@ -1817,6 +1819,15 @@ namespace AgroBase.Models.Modules
if (op?.DispAtu?.Dados?.Conectado != true)
return;
/*
* Durante o alívio de pressão, a sequência explícita abaixo é a
* única dona das saídas do atuador. Sem esta barreira, o ciclo de
* segurança da finalização fecharia os bicos antes de completar
* a janela de despressurização.
*/
if (_alivioPressaoLinhaEmAndamento)
return;
bool operacaoConcluida = op.Sensoriamento?.Operacao?.StatusOperacaoAtual == StatusOperacao.Concluido;
bool pulverizadorAutomatico = op.Parametros?.Controle?.PulverizadorAutomatico ?? false;
@ -1898,6 +1909,10 @@ namespace AgroBase.Models.Modules
foreach (var bomba in BombasPressurizadoras.Where(x => x != null && x.Inicializado))
{
bool ehBombaPrincipal = bomba.ID == BombaPressurizadora?.ID;
bool ehAgitador = bomba.ID == AgitadorDeCalda?.ID;
bool agitadorManual =
ehAgitador &&
op.Parametros?.Controle?.AtuAgitadorModo == ModoAgitadorCalda.Manual;
bool comandoSolicitado = bomba.Comandar && bomba.ComandoAtuar;
@ -1914,6 +1929,21 @@ namespace AgroBase.Models.Modules
{
statusDesejado = comandoSolicitado && atuacaoPermitida;
}
/*
* O agitador em Manual pode ser ligado pela base antes do
* início da operação para preparar a calda. Continuam valendo
* os bloqueios absolutos de emergência, finalização e operação
* concluída; somente a exigência OperacaoIniciada/Pausa deixa
* de se aplicar neste modo explícito do operador.
*/
else if (agitadorManual)
{
statusDesejado =
comandoSolicitado &&
!op.Sensoriamento.Operacao.Finalizando &&
!op.Sensoriamento.Operacao.Emergencia &&
!operacaoConcluida;
}
/*
* Fora da calibração, a bomba principal automática
* deve permanecer ligada enquanto a operação estiver
@ -2070,7 +2100,10 @@ namespace AgroBase.Models.Modules
return false;
}
private bool PodeEnviarComandoFisico(bool statusLigado, string componente)
private bool PodeEnviarComandoFisico(
bool statusLigado,
string componente,
bool permitirDuranteFinalizacao = false)
{
var dados = Variaveis.OperacaoEmAndamento?.DispAtu?.Dados;
@ -2084,6 +2117,24 @@ namespace AgroBase.Models.Modules
if (!BloqueioAbsolutoAtuacao(out string motivo))
return true;
/*
* Exceção estreita, usada exclusivamente pela rotina de alívio:
* permite abrir bicos com a bomba já desligada enquanto a operação
* está em Finalizando. Emergência, aplicação encerrando e operação
* concluída continuam bloqueando o comando.
*/
var operacao = Variaveis.OperacaoEmAndamento?.Sensoriamento?.Operacao;
if (permitirDuranteFinalizacao &&
_alivioPressaoLinhaEmAndamento &&
operacao?.Finalizando == true &&
operacao.Emergencia != true &&
operacao.StatusOperacaoAtual != StatusOperacao.Concluido &&
!Variaveis.Fechando)
{
return true;
}
Variaveis.MostrarLog($"[ATU/SEGURANCA] Comando ON bloqueado para {componente}: {motivo}");
return false;
}
@ -2108,12 +2159,13 @@ namespace AgroBase.Models.Modules
CanMessagePosicaoDados dado,
bool setarControle,
bool status,
double? angulo = null
double? angulo = null,
bool permitirDuranteFinalizacao = false
)
{
var dispAtu = Variaveis.OperacaoEmAndamento?.DispAtu;
if (bico == null || !PodeEnviarComandoFisico(status, bico.ID))
if (bico == null || !PodeEnviarComandoFisico(status, bico.ID, permitirDuranteFinalizacao))
return;
bool comandoControle = bico.ComandoAtuar;
@ -2228,6 +2280,130 @@ namespace AgroBase.Models.Modules
}
}
/// <summary>
/// Desliga as bombas, abre todos os bicos comandáveis por uma janela
/// curta para aliviar a pressão residual da linha e volta tudo ao
/// estado seguro. Deve ser chamada depois de marcar a operação como
/// Finalizando, para impedir que qualquer automação religue a bomba.
/// </summary>
public async Task AliviarPressaoLinhaAsync(int tempoAlivioMs = 1000)
{
tempoAlivioMs = Math.Max(0, tempoAlivioMs);
await _alivioPressaoGate.WaitAsync().ConfigureAwait(false);
try
{
var op = Variaveis.OperacaoEmAndamento;
if (op?.Sensoriamento?.Operacao?.Finalizando != true)
{
Variaveis.MostrarLog("[ATU/ALIVIO] Ignorado: operação não está finalizando.");
return;
}
if (!Conectado)
{
Variaveis.MostrarLog("[ATU/ALIVIO] Ignorado: atuador desconectado.");
return;
}
if (op.Sensoriamento.Operacao.Emergencia || Variaveis.Fechando)
{
Variaveis.MostrarLog("[ATU/ALIVIO] Ignorado por bloqueio de segurança.");
return;
}
var bicos = (BicosPulverizadores ?? new List<AtuadorBicoModel>())
.Where(x => x != null && x.Inicializado && x.Comandar)
.ToList();
_alivioPressaoLinhaEmAndamento = true;
Variaveis.MostrarLog(
$"[ATU/ALIVIO] Iniciando: bombas OFF, {bicos.Count} bico(s) aberto(s) por {tempoAlivioMs} ms."
);
/* A bomba precisa estar desligada antes de qualquer bico abrir. */
foreach (var bomba in BombasPressurizadoras ?? new List<AtuadorBombaModel>())
{
if (bomba?.Inicializado != true)
continue;
double pressaoOff = bomba.Sensor_ID_Num > -1 ? 0 : -1;
EnviarComandoFisicoBomba(
bomba,
CanMessagePosicaoDados.Command1,
setarControle: true,
status: false,
potencia: 0,
pressao: pressaoOff
);
}
foreach (var bico in bicos)
{
EnviarComandoFisicoBico(
bico,
CanMessagePosicaoDados.Command1,
setarControle: true,
status: true,
angulo: bico.AnguloAbertura,
permitirDuranteFinalizacao: true
);
}
if (tempoAlivioMs > 0)
await Task.Delay(tempoAlivioMs).ConfigureAwait(false);
}
catch (Exception ex)
{
Variaveis.MostrarLog("[ATU/ALIVIO] Falha durante o alívio: " + ex.Message);
}
finally
{
/*
* O fechamento é obrigatório inclusive se ocorrer exceção ou
* cancelamento no meio da janela.
*/
foreach (var bico in BicosPulverizadores ?? new List<AtuadorBicoModel>())
{
if (bico?.Inicializado == true)
{
EnviarComandoFisicoBico(
bico,
CanMessagePosicaoDados.Command1,
setarControle: true,
status: false,
angulo: bico.AnguloNeutro
);
}
}
foreach (var bomba in BombasPressurizadoras ?? new List<AtuadorBombaModel>())
{
if (bomba?.Inicializado == true)
{
double pressaoOff = bomba.Sensor_ID_Num > -1 ? 0 : -1;
EnviarComandoFisicoBomba(
bomba,
CanMessagePosicaoDados.Command1,
setarControle: true,
status: false,
potencia: 0,
pressao: pressaoOff
);
}
}
_alivioPressaoLinhaEmAndamento = false;
_alivioPressaoGate.Release();
Variaveis.MostrarLog("[ATU/ALIVIO] Concluído: bicos e bombas em OFF.");
}
}
private void AtualizaComponentesIniciados()

View File

@ -313,6 +313,38 @@ namespace AgroBase.Models.Modules
public Dictionary<TipoMovimentoDirecional, ConfiguracaoSentidoMotor> ConfigSentidos { get; set; }
public List<FuncoesPinout> Funcoes { get; set; }
/// <summary>
/// Converte o sinal local do módulo (que depende do lado e da montagem
/// do motor) para a convenção única usada pelo controle:
/// positivo = curva/yaw para a direita; negativo = para a esquerda.
///
/// A conversão usa a própria configuração que gera os comandos, evitando
/// duplicar uma tabela de sinais que poderia divergir da atuação real.
/// </summary>
public double AnguloCanonicoControle(
TipoMovimentoDirecional tipoMovimento,
double anguloLocal)
{
if (double.IsNaN(anguloLocal) || double.IsInfinity(anguloLocal))
return 0.0;
ConfiguracaoSentidoMotor config;
if (ConfigSentidos == null ||
!ConfigSentidos.TryGetValue(tipoMovimento, out config))
{
return anguloLocal;
}
// O sentido configurado para Direita define qual sinal local
// corresponde ao comando canônico positivo.
double fator =
config.Direita == Sentido.Horario ? 1.0 :
config.Direita == Sentido.Antihorario ? -1.0 :
0.0;
return fator == 0.0 ? 0.0 : anguloLocal * fator;
}
public static DirecionalModel CarregarParametrosIniciais(string Mod_ID, byte _EnderecoTx, byte _EnderecoRx)
{

View File

@ -51,8 +51,173 @@ namespace AgroBase.Models.Modules
private readonly SemaphoreSlim _gateMovimentoCombinado = new SemaphoreSlim(1, 1);
private CancellationTokenSource _ctsMovimentoCombinado;
// Estado físico separado do comando no simulador. No rover real o
// estado é obtido diretamente de AnguloLeitura dos módulos.
public double AnguloDirecionalSimuladoCanonico { get; set; } = 0.0;
public DateTime MomentoAnguloDirecionalSimulado { get; set; } = DateTime.MinValue;
public class EstadoDirecionalCanonico
{
public TipoMovimentoDirecional TipoMovimento { get; set; }
public double AnguloComando { get; set; }
public double AnguloSP { get; set; }
public double AnguloFisico { get; set; }
public double Dispersao { get; set; }
public int RodasValidas { get; set; }
public bool Valido { get; set; }
public bool EmTransicao { get; set; }
public long TimestampUnixMs { get; set; }
}
public List<ModuloMvdModel> Modulos { get; set; } = new List<ModuloMvdModel>();
/// <summary>
/// Consolida as quatro direções em um estado canônico para o MPC.
/// Somente os eixos ativos no modo atual participam. A mediana torna o
/// valor resistente a uma leitura isolada e a dispersão expõe desacordo.
/// </summary>
public EstadoDirecionalCanonico ObterEstadoDirecionalCanonico(
TipoMovimentoDirecional tipoMovimento,
double anguloComando,
bool simulando)
{
var agora = DateTime.UtcNow;
var leituras = new List<double>();
var setpoints = new List<double>();
DateTime momentoLeituraMaisRecente = DateTime.MinValue;
foreach (var modulo in Modulos ?? new List<ModuloMvdModel>())
{
if (modulo?.DirMotor == null ||
!ModuloAtivoNoTipo(modulo.Modulo_ID, tipoMovimento))
continue;
var dir = modulo.DirMotor;
if (!simulando && !dir.Inicializado)
continue;
if (!simulando)
{
DateTime momentoLeitura =
dir.Leitura?.UltimoComandoRecebido ?? DateTime.MinValue;
if (momentoLeitura == DateTime.MinValue ||
DateTime.Now - momentoLeitura > TimeSpan.FromSeconds(2))
continue;
if (momentoLeitura > momentoLeituraMaisRecente)
momentoLeituraMaisRecente = momentoLeitura;
}
double leitura = dir.AnguloCanonicoControle(
tipoMovimento,
dir.AnguloLeitura
);
double sp = dir.AnguloCanonicoControle(
tipoMovimento,
dir.AnguloSPAtual
);
if (!double.IsNaN(leitura) && !double.IsInfinity(leitura))
leituras.Add(leitura);
if (!double.IsNaN(sp) && !double.IsInfinity(sp))
setpoints.Add(sp);
}
double anguloFisico;
if (simulando && MomentoAnguloDirecionalSimulado != DateTime.MinValue)
{
anguloFisico = AnguloDirecionalSimuladoCanonico;
}
else
{
anguloFisico = Mediana(leituras);
}
double anguloSP = setpoints.Count > 0
? Mediana(setpoints)
: anguloComando;
double dispersao = leituras.Count > 0
? leituras.Max(x => Math.Abs(x - Mediana(leituras)))
: 0.0;
int rodasValidas = simulando ? QuantidadeRodasAtivas(tipoMovimento) : leituras.Count;
bool valido = simulando
? MomentoAnguloDirecionalSimulado != DateTime.MinValue
: rodasValidas >= QuantidadeMinimaRodasValidas(tipoMovimento) && dispersao <= 8.0;
return new EstadoDirecionalCanonico
{
TipoMovimento = tipoMovimento,
AnguloComando = anguloComando,
AnguloSP = anguloSP,
AnguloFisico = anguloFisico,
Dispersao = dispersao,
RodasValidas = rodasValidas,
Valido = valido,
EmTransicao = Math.Abs(anguloSP - anguloFisico) > 1.0,
TimestampUnixMs = simulando
? new DateTimeOffset(
MomentoAnguloDirecionalSimulado == DateTime.MinValue
? agora
: MomentoAnguloDirecionalSimulado
).ToUnixTimeMilliseconds()
: (momentoLeituraMaisRecente == DateTime.MinValue
? 0
: new DateTimeOffset(momentoLeituraMaisRecente)
.ToUnixTimeMilliseconds())
};
}
private static bool ModuloAtivoNoTipo(
string moduloId,
TipoMovimentoDirecional tipo)
{
bool dianteiro = (moduloId ?? "").EndsWith("F", StringComparison.OrdinalIgnoreCase);
bool traseiro = (moduloId ?? "").EndsWith("T", StringComparison.OrdinalIgnoreCase);
switch (tipo)
{
case TipoMovimentoDirecional.RodasDianteiras:
return dianteiro;
case TipoMovimentoDirecional.RodasTraseiras:
return traseiro;
case TipoMovimentoDirecional.MovimentoArco:
case TipoMovimentoDirecional.MovimentoDiagonal:
case TipoMovimentoDirecional.MovimentoLateral:
case TipoMovimentoDirecional.RotacionarNoEixo:
return dianteiro || traseiro;
default:
return dianteiro;
}
}
private static int QuantidadeRodasAtivas(TipoMovimentoDirecional tipo)
{
return tipo == TipoMovimentoDirecional.RodasDianteiras ||
tipo == TipoMovimentoDirecional.RodasTraseiras ? 2 : 4;
}
private static int QuantidadeMinimaRodasValidas(TipoMovimentoDirecional tipo)
{
return QuantidadeRodasAtivas(tipo) == 2 ? 1 : 2;
}
private static double Mediana(IEnumerable<double> valores)
{
var ordenados = (valores ?? Enumerable.Empty<double>())
.Where(x => !double.IsNaN(x) && !double.IsInfinity(x))
.OrderBy(x => x)
.ToList();
if (ordenados.Count == 0)
return 0.0;
int meio = ordenados.Count / 2;
return ordenados.Count % 2 == 1
? ordenados[meio]
: (ordenados[meio - 1] + ordenados[meio]) / 2.0;
}
public (bool, List<string>) StatusModulosMOV(Direcao Lado)
{

View File

@ -1451,6 +1451,14 @@ namespace AgroBase.Models
op.Sensoriamento.Operacao.Finalizando = true;
/*
* Com a operação já bloqueada para novas atuações automáticas,
* desliga as bombas e abre os bicos por 1 s para aliviar a pressão
* residual da linha. A própria rotina garante o fechamento final.
*/
await (op.DispAtu?.Dados?.AliviarPressaoLinhaAsync(1000)
?? Task.CompletedTask).ConfigureAwait(false);
op.Trajetoria?.LoopAtualizaDados();
await Task.Delay(2000);
@ -2664,6 +2672,16 @@ namespace AgroBase.Models
op?.DispAtu?.Dados?.PrepararAtuadorParaOperacao();
HealthWorkerService.AtualizarDadosOperacao(true);
/*
* A atualização parcial substitui principalmente a instância de
* Parametros.Controle. Os controles da IHM mantinham a referência
* visual anterior e podiam regravar valores antigos ao alterar um
* único campo. Recarrega a tela na thread da própria IHM, sem
* reconstruir o mapa da operação em andamento.
*/
frmInstancial.frmIHM?._frmOperacao?
.AtualizarParametrosTela(op, recarregarMapa: false);
}
private void ExecutarLiberacaoHumanaAutonomia(OperacaoComandoBaseModel controleBase, OperacaoModel op)
@ -2943,6 +2961,12 @@ namespace AgroBase.Models
case S_Code.sBMB:
var bomba = dadosAtu.BombasPressurizadoras?.FirstOrDefault(x => x.ID == controleBase._comp_id);
if (bomba == null)
{
Variaveis.MostrarLog("[ComandoBase] Bomba ATU não encontrada: " + controleBase._comp_id);
return;
}
switch (controleBase.Controle.Comando)
{
case CanMessagePosicaoDados.Command2:
@ -2960,8 +2984,31 @@ namespace AgroBase.Models
bool ligarBomba = controleBase.Controle.Estado == true;
if (bomba.ID == dadosAtu?.AgitadorDeCalda?.ID)
op.Parametros.Controle.AtuAgitadorModo = ligarBomba ? ModoAgitadorCalda.Manual : ModoAgitadorCalda.SemAgitacao;
bool ehAgitador = bomba.ID == dadosAtu?.AgitadorDeCalda?.ID;
if (ehAgitador && op.Parametros?.Controle != null)
{
/*
* O comando direto da base assume a fonte de verdade do
* agitador. Manual impede o controle automático de
* sobrescrever o ON; SemAgitacao mantém o OFF até uma
* nova parametrização da operação.
*/
op.Parametros.Controle.AtuAgitadorModo = ligarBomba
? ModoAgitadorCalda.Manual
: ModoAgitadorCalda.SemAgitacao;
bomba.ComandoAtuar = ligarBomba;
bomba.PotenciaSP = ligarBomba
? FuncoesMatematicas.Clamp(controleBase.Controle.Potencia ?? bomba.PotenciaSP, 0, 100)
: 0;
bomba.PressaoSP = -1;
Variaveis.MostrarLog(
$"[ComandoBase][Agitador] Estado={(ligarBomba ? "ON" : "OFF")}, " +
$"Modo={op.Parametros.Controle.AtuAgitadorModo}, Potencia={bomba.PotenciaSP:0.##}%"
);
}
dadosAtu?.EnviarComandoFisicoBomba(
bomba,
@ -2970,7 +3017,9 @@ namespace AgroBase.Models
status: ligarBomba,
potencia: controleBase.Controle.Potencia,
pressao: controleBase.Controle.Pressao,
modo: op.Parametros.Controle.AtuModoControle
modo: ehAgitador
? bomba.Controle.Modo
: op.Parametros?.Controle?.AtuModoControle
);
return;
}

View File

@ -1021,6 +1021,7 @@ namespace AgroBase.Models
public static double DistanciaEntreEixosCm { get; } = 92.0;
public static double BitolaRodasCm { get; } = 75.0;
public static double KuDirecional { get; } = 2.65;
public static double VelocidadeAngularDirecionalGrausSegundo { get; } = 50.0;
public static double LeverArmFrontalCm { get; set; } = 37.0; // cm
public static double LeverArmLateralCm { get; set; } = 0.0; // cm
public static double LarguraEquipamentoMm

View File

@ -193,6 +193,13 @@ namespace AgroBase.Services.Operadores
bool carroEmMovimento = comandoMovimentoAtivo && velocidadeFisicaAtiva;
var estadoDirecional = op.DispMvd?.Dados?
.ObterEstadoDirecionalCanonico(
op.Controle.TipoMovimento,
op.Controle.Angulo,
op.Simulando
);
var bombaLinha = op?.DispAtu?.Dados?.BombaPressurizadora;
double pressaoAlvo = bombaLinha?._PressaoSPControle > 0 ? bombaLinha._PressaoSPControle : op.Parametros?.Controle?.AtuPressaoLinha ?? 0;
double pressaoLinha = bombaLinha?._PressaoAtual ?? _Sensoriamento?.Atuador?.PressaoLinha ?? 0;
@ -252,6 +259,19 @@ namespace AgroBase.Services.Operadores
percentual_bateria = _Sensoriamento.Bateria?.PercentualBateria ?? 0,
}
),
("Direcional", estadoDirecional != null ? new
{
tipo_movimento = (int)estadoDirecional.TipoMovimento,
angulo_comando = estadoDirecional.AnguloComando,
angulo_sp = estadoDirecional.AnguloSP,
angulo_fisico = estadoDirecional.AnguloFisico,
dispersao = estadoDirecional.Dispersao,
rodas_validas = estadoDirecional.RodasValidas,
valido = estadoDirecional.Valido,
em_transicao = estadoDirecional.EmTransicao,
timestamp_unix_ms = estadoDirecional.TimestampUnixMs,
} : null
),
("Atuador", new
{
pressao_linha_regular = pressaoLinhaRegular,
@ -427,6 +447,7 @@ namespace AgroBase.Services.Operadores
tracking_row_hybrid_diagonal_enabled = true,
entry_capture_enabled = false,
tracking_axis_debug = false,
atuador_dir_taxa_graus_s = VariaveisEquipamento.VelocidadeAngularDirecionalGrausSegundo
}
}
),

View File

@ -143,7 +143,7 @@ def _comando_mapa_gps_mpc(contexto: dict):
parada_necessaria=True,
)
comando_anterior = _ler_comando_anterior_para_mpc()
comando_anterior = _ler_comando_anterior_para_mpc(contexto)
inicio = time.perf_counter()
comando = mpc.compute_receding(contexto, comando_anterior)
@ -461,6 +461,7 @@ def _comando_mapeamento_visual(contexto: dict):
def _montar_contexto_direcional(*, operacao, controle, contexto_global, equipamento):
trajetoria = _dict(contexto_global.get("Trajetoria", {}))
gerais = _dict(contexto_global.get("Gerais", {}))
direcional_fisico = _dict(contexto_global.get("Direcional", {}))
gps_info = _montar_contexto_gps(operacao)
imu_contexto = _montar_contexto_imu_direcional(_dict(ContextoGlobalRedis.get_modulo(T_Code.Imu)))
@ -513,6 +514,38 @@ def _montar_contexto_direcional(*, operacao, controle, contexto_global, equipame
1,
),
},
"Direcional": {
"TipoMovimento": _int(
direcional_fisico.get(
"tipo_movimento",
controle.get(
"tipo_movimento_direcional",
TipoMovimentoDirecional.RodasDianteiras.value,
),
),
TipoMovimentoDirecional.RodasDianteiras.value,
),
"AnguloComando": _float(
direcional_fisico.get("angulo_comando", controle.get("angulo_sp", 0.0)),
0.0,
),
"AnguloSP": _float(
direcional_fisico.get("angulo_sp", controle.get("angulo_sp", 0.0)),
0.0,
),
"AnguloFisico": _float(
direcional_fisico.get("angulo_fisico", controle.get("angulo_sp_ant", 0.0)),
0.0,
),
"Dispersao": _float(direcional_fisico.get("dispersao", 0.0), 0.0),
"RodasValidas": _int(direcional_fisico.get("rodas_validas", 0), 0),
"Valido": _bool(direcional_fisico.get("valido", False)),
"EmTransicao": _bool(direcional_fisico.get("em_transicao", False)),
"TimestampUnixMs": _float(
direcional_fisico.get("timestamp_unix_ms", 0.0),
0.0,
),
},
"VisualWorker": visual_worker,
"IMU": imu_contexto,
"Equipamento": {
@ -1125,10 +1158,16 @@ def _montar_comando_retorno(comando, latencia=-1.0):
return comando
def _ler_comando_anterior_para_mpc():
def _ler_comando_anterior_para_mpc(contexto=None):
controle = _dict(ContextoGlobalRedis.get_controle())
direcional = _dict(_dict(contexto).get("Direcional", {}))
return {
telemetria_valida = _bool(direcional.get("Valido", False))
timestamp_ms = _float(direcional.get("TimestampUnixMs", 0.0), 0.0)
idade_ms = max(0.0, time.time() * 1000.0 - timestamp_ms) if timestamp_ms > 0 else 1e9
telemetria_valida = telemetria_valida and idade_ms <= 1500.0
comando = {
"angulo": _float(
controle.get("angulo_sp_ant", controle.get("angulo_sp", 0.0)),
0.0,
@ -1148,6 +1187,25 @@ def _ler_comando_anterior_para_mpc():
),
}
comando["angulo_aplicado"] = (
_float(direcional.get("AnguloFisico", comando["angulo"]), comando["angulo"])
if telemetria_valida else comando["angulo"]
)
comando["angulo_sp_aplicado"] = (
_float(direcional.get("AnguloSP", comando["angulo"]), comando["angulo"])
if telemetria_valida else comando["angulo"]
)
comando["tipo_aplicado"] = (
_normalizar_tipo_movimento(direcional.get("TipoMovimento", comando["tipo"]))
if telemetria_valida else comando["tipo"]
)
comando["telemetria_direcional_valida"] = telemetria_valida
comando["telemetria_direcional_idade_ms"] = idade_ms
comando["telemetria_direcional_dispersao"] = _float(
direcional.get("Dispersao", 0.0), 0.0
)
return comando
# ============================================================
# Helpers de matriz

View File

@ -450,6 +450,27 @@ class ControladorMPC:
1.0, min_value=0.25, max_value=2.0,
)
# V22 - amortecimento exclusivo do handoff antecipado em SaindoRua.
#
# Nesse estado o rover ja opera a 15% e ainda percorre o trecho reto
# antes da Dubins. O Pure Pursuit bruto pode alternar pequenos comandos
# de Arco quando a projecao/target cruza de um lado para o outro da
# centerline. Aplicamos filtro, rate-limit, deadband e passagem por zero
# SOMENTE nessa fase. Ao entrar em Manobrando, o PP continua bruto e
# preserva integralmente a autoridade angular validada em campo.
self._dubins_saida_filter_tau_s = _safe_float(
parametros_mpc.get("dubins_saida_filter_tau_s", 0.30),
0.30, min_value=0.0, max_value=2.0,
)
self._dubins_saida_rate_graus_s = _safe_float(
parametros_mpc.get("dubins_saida_rate_graus_s", 18.0),
18.0, min_value=2.0, max_value=120.0,
)
self._dubins_saida_deadband_graus = _safe_float(
parametros_mpc.get("dubins_saida_deadband_graus", 1.0),
1.0, min_value=0.0, max_value=5.0,
)
# V18 - handoff antecipado para o Pure Pursuit.
#
# SaindoRua ja nasce alguns metros antes da BordaSaida para reduzir a
@ -773,6 +794,12 @@ class ControladorMPC:
parametros_mpc.get("cinematica_entre_eixos_m", 0.94),
0.94, min_value=0.20, max_value=4.0,
)
# Dinâmica física da direção. O valor comandado é o alvo; o estado
# usado na cinemática avança gradualmente a partir da telemetria real.
self._atuador_dir_taxa_graus_s = _safe_float(
parametros_mpc.get("atuador_dir_taxa_graus_s", 50.0),
50.0, min_value=3.0, max_value=180.0,
)
# ------------------------------------------------------------------
# V14 - CURVE TRACK: yaw contínuo quando a própria rua está curvando
@ -1571,14 +1598,93 @@ class ControladorMPC:
return False
def _estabilizar_pp_saindo_rua(
self, angulo_desejado_rad, comando_anterior, dt_controle
):
"""V22: amortece o PP apenas no trecho reto de SaindoRua.
Regras de seguranca:
- limita a taxa angular por segundo;
- filtra o comando por primeira ordem;
- elimina ruido dentro da deadband;
- numa inversao real, passa deliberadamente por zero;
- nunca devolve comando no hemisferio oposto ao desejado.
Manobrando nao chama esta funcao e continua recebendo o PP bruto.
"""
limite = math.radians(float(self.angulo_max_graus))
desejado = float(np.clip(angulo_desejado_rad, -limite, limite))
anterior = math.radians(_safe_float(
_as_dict(comando_anterior).get("angulo", 0.0),
0.0,
min_value=-self.angulo_max_graus,
max_value=self.angulo_max_graus,
))
dt = _safe_float(dt_controle, 0.5, min_value=0.05, max_value=1.5)
dead = math.radians(float(self._dubins_saida_deadband_graus))
# Perto de zero, pequenas mudancas de lado da projecao nao devem
# movimentar as quatro rodas em Arco enquanto o rover ainda esta reto.
if abs(desejado) <= dead:
desejado = 0.0
inversao_significativa = bool(
abs(desejado) > dead
and abs(anterior) > dead
and (desejado * anterior) < 0.0
)
if inversao_significativa:
filtrado = 0.0
else:
tau = float(self._dubins_saida_filter_tau_s)
alpha = 1.0 if tau <= 1e-6 else dt / (tau + dt)
filtrado = anterior + alpha * (desejado - anterior)
max_delta = math.radians(
float(self._dubins_saida_rate_graus_s)
) * dt
filtrado = anterior + float(np.clip(
filtrado - anterior,
-max_delta,
max_delta,
))
if desejado == 0.0 and abs(filtrado) <= 2.0 * dead:
filtrado = 0.0
# Barreira final contra a manutencao de esterco no sentido antigo.
if (
abs(desejado) > dead
and abs(filtrado) > dead
and (desejado * filtrado) < 0.0
):
filtrado = 0.0
return float(np.clip(filtrado, -limite, limite))
def _comando_seguidor_dubins(
self, contexto, comando_anterior, x, y, theta, velocidade, status_carro,
idx_alvo_correcao, pos_lat, pos_lon, pos_theta, pos_latencia, ponto_alvo_real
):
angulo_rad, dbg = self._referencia_seguidor_dubins(
angulo_bruto_rad, dbg = self._referencia_seguidor_dubins(
x, y, theta, idx_alvo_correcao, velocidade, contexto
)
pp_antecipado = _status_in(
status_carro, [StatusCarroMapa.SaindoRua]
)
angulo_rad = (
self._estabilizar_pp_saindo_rua(
angulo_bruto_rad,
comando_anterior,
self.tempo_execucao_local,
)
if pp_antecipado
else float(angulo_bruto_rad)
)
preview_xy = self._simular_preview_seguidor_dubins(
x, y, theta, idx_alvo_correcao, velocidade, contexto,
self.tempo_execucao_local,
@ -1620,7 +1726,7 @@ class ControladorMPC:
"debug_seguidor_dubins": {
"modo": (
"pure_pursuit_4ws_antecipado"
if _status_in(status_carro, [StatusCarroMapa.SaindoRua])
if pp_antecipado
else "pure_pursuit_4ws"
),
"lookahead_m": float(dbg["lookahead_m"]),
@ -1628,7 +1734,9 @@ class ControladorMPC:
"alpha_graus": float(math.degrees(dbg["alpha"])),
"kappa_pp": float(dbg["kappa_pp"]),
"kappa_cmd": float(dbg["kappa_cmd"]),
"comando_graus": float(math.degrees(dbg["delta_final"])),
"comando_bruto_graus": float(math.degrees(angulo_bruto_rad)),
"comando_graus": float(math.degrees(angulo_rad)),
"amortecimento_saida_ativo": bool(pp_antecipado),
},
"candidatos_testados": 0,
"motivos": [],
@ -2272,6 +2380,13 @@ class ControladorMPC:
)["omega"]
)
def _avancar_atuador_direcional(self, angulo_atual, angulo_alvo, dt):
"""Aplica limite de velocidade ao estado físico da direção (radianos)."""
atual = float(angulo_atual)
alvo = float(angulo_alvo)
passo_max = np.radians(self._atuador_dir_taxa_graus_s) * max(0.0, float(dt))
return float(atual + np.clip(alvo - atual, -passo_max, passo_max))
@staticmethod
def _posicoes_eixos_tracking(x, y, theta, entre_eixos):
"""Retorna centros dos eixos dianteiro/traseiro a partir do centro.
@ -3921,6 +4036,9 @@ class ControladorMPC:
self.dt = old_dt
# --- integra por tempo, quebrando cada trecho em subpassos ~dt ---
angulo_estado = float(
u_hold.get('angulo_inicial', u_hold.get('angulo', 0.0))
)
t_rem = latency_s
for dur_s, u in cmd_seq:
if t_rem <= 0.0:
@ -3935,7 +4053,18 @@ class ControladorMPC:
for _ in range(n):
if time_left_ms() <= 0.0:
return x, y, theta, visitados
x, y, theta = aplicar_passo_dt(x, y, theta, u, dt_eff)
angulo_estado = self._avancar_atuador_direcional(
angulo_estado,
u.get('angulo', angulo_estado),
dt_eff,
)
u_aplicado = dict(u)
u_aplicado['angulo'] = angulo_estado
# omega precisa acompanhar o ângulo físico, não o alvo antigo.
u_aplicado.pop('omega', None)
x, y, theta = aplicar_passo_dt(
x, y, theta, u_aplicado, dt_eff
)
idx_alvo = self._corrigir_pontos_visitados(
x,
y,
@ -4190,19 +4319,31 @@ class ControladorMPC:
# -------------------- Correção por latência (vida real) --------------------
# Mantém correção com 'self.dt' e 'velocidade' REAIS
u_hold = {
"tipo": comando_anterior.get("tipo"),
"angulo": np.radians(comando_anterior.get("angulo", 0.0)),
"tipo": comando_anterior.get(
"tipo_aplicado", comando_anterior.get("tipo")
),
"angulo": np.radians(
comando_anterior.get(
"angulo_sp_aplicado",
comando_anterior.get("angulo", 0.0),
)
),
"angulo_inicial": np.radians(
comando_anterior.get(
"angulo_aplicado",
comando_anterior.get("angulo", 0.0),
)
),
"v": velocidade,
"omega": self._calcular_omega_4ws(
velocidade,
np.radians(comando_anterior.get("angulo", 0.0)),
comando_anterior.get("tipo")
)
}
cmd_seq = None
latencia_compensada_s = min(
pos_latencia + self.tempo_execucao_local,
LAT_MAX,
)
x, y, theta, self.visitados_execucao = self.corrigir_pose_por_latencia(
x, y, theta, self.visitados_execucao,
latency_s=min(pos_latencia + self.tempo_execucao_local, LAT_MAX),
latency_s=latencia_compensada_s,
dt=self.dt,
nova_posicao_fn=self._nova_posicao,
u_hold=u_hold,
@ -4210,6 +4351,19 @@ class ControladorMPC:
time_left_ms=lambda: (deadline - now()) * 1000.0,
calc_omega_fn=self._calcular_omega_4ws
)
# A pose acima já foi trazida até o presente. O estado inicial do
# beam search precisa avançar pelo mesmo intervalo para não voltar
# ao ângulo físico antigo recebido junto com a posição GNSS.
angulo_aplicado_estimado = self._avancar_atuador_direcional(
u_hold["angulo_inicial"],
u_hold["angulo"],
latencia_compensada_s,
)
comando_anterior = dict(comando_anterior)
comando_anterior["angulo_aplicado"] = float(
np.degrees(angulo_aplicado_estimado)
)
idx_alvo_correcao = self._corrigir_pontos_visitados(
x,
y,
@ -4407,6 +4561,12 @@ class ControladorMPC:
"x": x, "y": y, "theta": theta,
"custo": 0.0,
"comandos": [(tipo_anterior, angulo_anterior)],
"angulo_atuador": np.radians(
comando_anterior.get(
"angulo_aplicado",
comando_anterior.get("angulo", 0.0),
)
),
"trajetoria": [],
"visitados": self.visitados_execucao,
"inicial": True
@ -4433,6 +4593,10 @@ class ControladorMPC:
cmd_anterior_local = {
"tipo": candidato["comandos"][-1][0],
"angulo": candidato["comandos"][-1][1],
"angulo_aplicado": candidato.get(
"angulo_atuador",
candidato["comandos"][-1][1],
),
}
#look_ahead = passo > 2 and status_carro not in [StatusCarroMapa.Manobrando.value, StatusCarroMapa.Direcionando.value]
@ -4525,7 +4689,7 @@ class ControladorMPC:
t_c_ini = now()
try:
# >>> CHANGED: passa dt_pred e v_sim para a simulação de um PASSO
custo, sim, valido, visitados_sim, _debug_custo = self._simular_passo(
custo, sim, valido, visitados_sim, _debug_custo, angulo_atuador_final = self._simular_passo(
x_atual, y_atual, theta_atual,
tipo_k, ang_k,
custos_candidatos,
@ -4543,6 +4707,7 @@ class ControladorMPC:
"x": x_f, "y": y_f, "theta": theta_f,
"custo": candidato["custo"] + float(custo),
"comandos": candidato["comandos"] + [(tipo_k, ang_k)],
"angulo_atuador": angulo_atuador_final,
"trajetoria": candidato["trajetoria"] + sim,
"visitados": visitados_sim,
"inicial": False
@ -5789,6 +5954,9 @@ class ControladorMPC:
simulacoes = []
prev_ang = float(comando_anterior.get("angulo", 0.0))
prev_tipo = comando_anterior.get("tipo", TipoMovimentoDirecional.RodasDianteiras.value)
angulo_atuador = float(
comando_anterior.get("angulo_aplicado", prev_ang)
)
pontos_visitados = visitados.copy()
# chave para custo heurístico vindo do VisualWorker (por ângulo/tipo)
@ -5822,8 +5990,6 @@ class ControladorMPC:
custo_visual_worker = 0.0
# omega e sub-stepping
omega_const = self._calcular_omega_4ws(v_planejado, angulo_testado, tipo)
#passos_local = max(1, int(getattr(self, "passos_horizonte_local", 1)))
#dt_total = float(dt_pred) * passos_local
#SUB_MAX = float(getattr(self, "_dt_sub_max", 0.15)) # ~150 ms por subpasso (ajuste teu comentário)
@ -5846,11 +6012,21 @@ class ControladorMPC:
side_mem_local = 0
for n_sub in range(n_subs):
angulo_atuador = self._avancar_atuador_direcional(
angulo_atuador,
angulo_testado,
sub_dt,
)
omega_aplicado = self._calcular_omega_4ws(
v_planejado,
angulo_atuador,
tipo,
)
# IMPORTANTE: _nova_posicao precisa aceitar dt opcional
x_sim, y_sim, theta_sim = self._nova_posicao(
x_sim, y_sim, theta_sim,
omega_const, v_planejado, # (omega, v, tipo, angulo)
tipo, angulo_testado,
omega_aplicado, v_planejado,
tipo, angulo_atuador,
dt=sub_dt
)
@ -6004,7 +6180,7 @@ class ControladorMPC:
#print(
# f"ang:{np.degrees(angulo_testado):.2f} "
# f"tipo:{tipo.name} omg:{omega_const:.3f} "
# f"tipo:{tipo.name} omg:{omega_aplicado:.3f} "
# f"xsim:{finais[0]:.5f} ysim:{finais[1]:.5f} tsim:{finais[2]:.5f} "
# f"xalv:{finais[3]:.5f} yalv:{finais[4]:.5f} "
# f"epos:{somas[5]:.4f} eposn:{somas[6]:.4f} "
@ -6021,7 +6197,7 @@ class ControladorMPC:
debug_custo = {
"angulo": np.degrees(angulo_testado),
"tipo": tipo.value,
"omega": omega_const,
"omega": omega_aplicado,
"x_sim": finais[0],
"y_sim": finais[1],
"theta_sim": finais[2],
@ -6057,11 +6233,21 @@ class ControladorMPC:
}
#print(custo_visual_worker)
return float(custo_total), simulacoes, True, pontos_visitados, debug_custo
debug_custo["atuador_direcional"] = {
"angulo_inicial_deg": float(np.degrees(
comando_anterior.get("angulo_aplicado", prev_ang)
)),
"angulo_alvo_deg": float(np.degrees(angulo_testado)),
"angulo_final_deg": float(np.degrees(angulo_atuador)),
"taxa_graus_s": float(self._atuador_dir_taxa_graus_s),
}
return float(custo_total), simulacoes, True, pontos_visitados, debug_custo, angulo_atuador
except Exception as e:
mostrar_log(f"Erro ao simular passo para {tipo.name} | angulo: {np.degrees(angulo_testado):.2f}: {e}")
return float('inf'), [(0.0, 0.0, 0.0)], False, visitados
return float('inf'), [(0.0, 0.0, 0.0)], False, visitados, {}, float(
comando_anterior.get("angulo_aplicado", comando_anterior.get("angulo", 0.0))
)
def _nova_posicao(self, x, y, theta, omega, velocidade, tipo, angulo_rad, dt=None):
"""V16: integra UM passo com a cinemática 4WS unificada.

File diff suppressed because it is too large Load Diff

View File

@ -8,8 +8,10 @@ Exporta o checkpoint PyTorch do SegFormer Multi-Head OAK-FCC-3 para ONNX.
Exemplo:
python .\_10_export_onnx.py --config config.json --checkpoint backup/segformer_b1/2026_08_17/stacked_raw5_ndvi_ndre/best_score.pt --out backup/segformer_b1/2026_08_17/stacked_raw5_ndvi_ndre/best_score.onnx --include-norm --postprocess argmax_fullres
python .\_10_export_onnx.py --config .\config.json --train-script .\_8_train_multihead_v2.py --opset 17 --device cuda --include-norm --postprocess argmax_fullres
python .\_10_export_onnx.py --config .\config.json --train-script .\_8_train_multihead_v2.py --checkpoint .\backup\segformer_b1\2026_09_18\stacked_raw5\best_score.pt --out .\backup\segformer_b1\2026_09_18\stacked_raw5\best_score_audit.onnx --opset 17 --device cuda --include-norm --norm-stats .\backup\segformer_b1\2026_09_18\stacked_raw5\norm_stats.json --postprocess none
python .\_10_export_onnx.py --config .\config.json --train-script .\_8_train_multihead_v2.py --checkpoint .\backup\segformer_b1\2026_09_18\stacked_raw5\best_operational.pt --out .\backup\segformer_b1\2026_09_18\stacked_raw5\best_operation.onnx --opset 17 --device cuda --include-norm --norm-stats .\backup\segformer_b1\2026_09_18\stacked_raw5\norm_stats.json --postprocess argmax_fullres
"""
from __future__ import annotations

File diff suppressed because it is too large Load Diff

View File

@ -2,56 +2,63 @@
# -*- coding: utf-8 -*-
"""
_2_apply_review_masks_final.py
_2_apply_review_masks.py
Aplica as máscaras revisadas de volta em dataset/brutas/group.
Aplica máscaras revisadas de volta em dataset/brutas/group com política
CONSERVADORA: qualquer ambiguidade é isolada em quarentena e NUNCA entra no
--apply.
VERSÃO FINAL / RÁPIDA
Regras de segurança:
- 1 máscara de review + 1 destino nas BRUTAS -> elegível para apply.
- Mesmo base em mais de um grupo/destino das BRUTAS -> QUARENTENA.
- Mesmo base mais de uma vez no REVIEW -> QUARENTENA, mesmo se as máscaras
forem pixel-a-pixel idênticas.
- Duplicidade simultânea em REVIEW e BRUTAS -> QUARENTENA.
- Bases sem destino -> SKIP, sem alteração.
- O --apply contém uma checagem final de invariantes e só aceita exatamente
1 review mask + 1 target por base.
Princípio:
- O nome-base da máscara revisada é a chave.
- Os previews das BRUTAS são indexados UMA ÚNICA VEZ em memória.
- Se o mesmo base existir em MAIS DE UM grupo das BRUTAS, a máscara revisada
é aplicada em TODOS eles. Isso é intencional: são duplicatas que serão
consolidadas posteriormente pelo reorganizador do dataset.
- Se o mesmo base existir MAIS DE UMA VEZ dentro do próprio REVIEW:
* se as máscaras forem pixel-a-pixel equivalentes após normalização para
raw_size, tratamos como uma única revisão;
* se forem diferentes, é CONFLITO REAL e esse base é pulado para não
escolher arbitrariamente uma revisão.
Quarentena:
- É gerada também no dry-run (somente cópias; nada nas BRUTAS é alterado).
- Para cada caso ambíguo é criada uma pasta por combinação review x preview.
- Cada pasta contém:
preview.<ext> -> preview original das BRUTAS
mask.png -> máscara original vinda do REVIEW
info.txt -> origem exata, grupos e target correspondente
Transformação:
- config["resolucao"] = resolução do review/treino, ex. 960x600
- config["raw_size"] = resolução RAW, ex. 1280x800
- Máscaras categóricas são redimensionadas EXCLUSIVAMENTE com INTER_NEAREST.
Exemplo:
dataset/brutas/review_quarantine/run_YYYYMMDD_HHMMSS/
QUARENTENA_MULTI_TARGET/
<base>/
pair_001__review-X__preview-A/
preview.jpg
mask.png
info.txt
pair_002__review-X__preview-B/
...
Performance:
- O dry-run não lê milhares de imagens.
- Só lê imagens no dry-run quando um mesmo base aparece várias vezes no REVIEW,
para confirmar se essas revisões duplicadas são equivalentes.
- No --apply, cada base lógico é lido/redimensionado apenas UMA vez e depois
gravado em todos os destinos encontrados.
- A máscara antiga NÃO é lida.
- Não há comparação com máscara antiga, hash global, paleta ou backup.
Uso dry-run:
Uso:
python .\\_2_apply_review_masks_final.py `
python .\\_2_apply_review_masks.py `
--review-root "C:\\Users\\USER\\Desktop\\fotos_multiespectrais\\review" `
--brutas-root dataset\\brutas\\group `
--labelmap dataset\\labelmap.txt `
--config config.json
python .\\_2_apply_review_masks_final.py `
Uso apply:
python .\\_2_apply_review_masks.py `
--review-root "C:\\Users\\USER\\Desktop\\fotos_multiespectrais\\review" `
--brutas-root dataset\\brutas\\group `
--labelmap dataset\\labelmap.txt `
--config config.json `
--apply
Opcional:
--quarantine-root caminho\\para\\quarentena
Observação:
--labelmap é aceito apenas para manter compatibilidade com o comando antigo.
Esta versão não precisa dele.
--labelmap é aceito apenas para compatibilidade com o comando antigo.
"""
from __future__ import annotations
@ -60,12 +67,14 @@ import argparse
import csv
import json
import os
import re
import shutil
import sys
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
from typing import Dict, Iterable, List, Optional, Tuple
import cv2
import numpy as np
@ -89,7 +98,13 @@ class LogicalReview:
representative: Path
targets: List[Path]
preview_paths: List[Path]
duplicate_review_equivalent: bool = False
@dataclass
class QuarantineResult:
case_dir: Path
pair_count: int
errors: List[str]
def load_sizes(config_path: Path) -> Sizes:
@ -111,9 +126,8 @@ def load_sizes(config_path: Path) -> Sizes:
try:
w, h = int(value[0]), int(value[1])
except Exception:
raise RuntimeError(
f'config["{key}"] inválido: {value!r}'
)
raise RuntimeError(f'config["{key}"] inválido: {value!r}')
if w <= 0 or h <= 0:
raise RuntimeError(
f'config["{key}"] deve conter valores positivos: {value!r}'
@ -123,7 +137,6 @@ def load_sizes(config_path: Path) -> Sizes:
review_w, review_h = parse("resolucao")
raw_w, raw_h = parse("raw_size")
# Resize puro é seguro geometricamente apenas com a mesma proporção.
if review_w * raw_h != raw_w * review_h:
raise RuntimeError(
"resolucao e raw_size possuem aspect ratios diferentes: "
@ -146,8 +159,7 @@ def discover_review_masks(review_root: Path) -> List[Path]:
e:
review/<grupo>/final_masks/<base>.png
Se existir final_masks, usa somente esses arquivos para não engolir
predictions/previews auxiliares.
Se existir qualquer final_masks, usa somente arquivos dentro de final_masks.
"""
final_masks = sorted(
p for p in review_root.rglob("*.png")
@ -158,9 +170,7 @@ def discover_review_masks(review_root: Path) -> List[Path]:
masks: List[Path] = []
for group_dir in sorted(p for p in review_root.iterdir() if p.is_dir()):
masks.extend(
sorted(p for p in group_dir.glob("*.png") if p.is_file())
)
masks.extend(sorted(p for p in group_dir.glob("*.png") if p.is_file()))
return masks
@ -170,13 +180,12 @@ def review_group(path: Path) -> str:
return path.parent.name
def build_preview_index(brutas_root: Path) -> Dict[str, List[Path]]:
"""
Varre o filesystem UMA ÚNICA VEZ.
def preview_group(path: Path) -> str:
# .../<grupo>/previews/<base>.<ext>
return path.parent.parent.name
Resultado:
base -> [preview_path_1, preview_path_2, ...]
"""
def build_preview_index(brutas_root: Path) -> Dict[str, List[Path]]:
index: Dict[str, List[Path]] = defaultdict(list)
for group_dir in sorted(p for p in brutas_root.iterdir() if p.is_dir()):
@ -199,15 +208,6 @@ def group_reviews_by_base(masks: Iterable[Path]) -> Dict[str, List[Path]]:
def load_and_resize_mask(path: Path, sizes: Sizes) -> np.ndarray:
"""
Abre uma máscara e a normaliza para raw_size.
Aceita:
- config.resolucao
- config.raw_size
Qualquer outro tamanho é erro.
"""
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
if img is None:
raise RuntimeError(f"não consegui abrir máscara: {path}")
@ -236,9 +236,8 @@ def duplicate_reviews_are_equivalent(
sizes: Sizes,
) -> Tuple[bool, str]:
"""
Usado SOMENTE quando o mesmo base aparece várias vezes no review.
Como são pouquíssimos casos, podemos comparar pixel a pixel sem afetar
a performance geral.
A equivalência serve APENAS para diagnóstico/relatório.
Mesmo equivalentes, reviews duplicados continuam indo para quarentena.
"""
if len(paths) <= 1:
return True, ""
@ -261,17 +260,10 @@ def duplicate_reviews_are_equivalent(
+ " | ".join(str(p) for p in paths),
)
return True, ""
return True, "máscaras duplicadas equivalentes pixel-a-pixel após normalização"
def unique_targets_from_previews(
previews: List[Path],
base: str,
) -> List[Path]:
"""
Um mesmo stem pode, em teoria, aparecer em PNG e JPG dentro da mesma pasta.
Deduplicamos pelo caminho final da mask.
"""
def unique_targets_from_previews(previews: List[Path], base: str) -> List[Path]:
by_target: Dict[str, Path] = {}
for preview in previews:
@ -282,12 +274,7 @@ def unique_targets_from_previews(
def atomic_write_png(img: np.ndarray, dst: Path) -> None:
"""
PNG continua lossless. Compressão 1 prioriza velocidade.
Escreve em temporário e troca atomicamente.
"""
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_name(dst.stem + ".tmp_review_apply.png")
ok = cv2.imwrite(
@ -301,11 +288,115 @@ def atomic_write_png(img: np.ndarray, dst: Path) -> None:
os.replace(tmp, dst)
def safe_component(value: str, max_len: int = 120) -> str:
value = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", value)
value = value.strip().rstrip(".")
if not value:
value = "sem_nome"
return value[:max_len]
def quarantine_case(
*,
quarantine_root: Path,
status: str,
base: str,
review_paths: List[Path],
previews: List[Path],
targets: List[Path],
message: str,
) -> QuarantineResult:
"""
Gera uma pasta por combinação REVIEW x PREVIEW.
Isso é deliberadamente redundante: para poucos casos ambíguos, é melhor
mostrar todas as combinações ao operador do que tentar adivinhar qual
pareamento é o correto.
"""
case_dir = quarantine_root / safe_component(status) / safe_component(base)
case_dir.mkdir(parents=True, exist_ok=True)
errors: List[str] = []
pair_count = 0
# Manifesto geral do caso.
manifest = {
"status": status,
"base": base,
"message": message,
"review_masks": [str(p) for p in review_paths],
"previews": [str(p) for p in previews],
"targets": [str(p) for p in targets],
}
try:
(case_dir / "case.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception as e:
errors.append(f"falha ao gravar case.json: {e}")
# Casos de quarentena aqui sempre devem ter pelo menos um preview e uma mask.
# Ainda assim, tratamos defensivamente para não abortar o preflight inteiro.
if not review_paths or not previews:
errors.append("caso sem review mask ou sem preview; nenhuma dupla criada")
return QuarantineResult(case_dir=case_dir, pair_count=0, errors=errors)
pair_no = 0
for r_idx, review_path in enumerate(review_paths, 1):
r_group = review_group(review_path)
for p_idx, preview_path in enumerate(previews, 1):
p_group = preview_group(preview_path)
pair_no += 1
pair_name = (
f"pair_{pair_no:03d}__"
f"review-{safe_component(r_group, 45)}__"
f"preview-{safe_component(p_group, 45)}"
)
pair_dir = case_dir / pair_name
pair_dir.mkdir(parents=True, exist_ok=True)
preview_dst = pair_dir / f"preview{preview_path.suffix.lower()}"
mask_dst = pair_dir / "mask.png"
target = preview_path.parent.parent / "masks" / f"{base}.png"
try:
shutil.copy2(preview_path, preview_dst)
shutil.copy2(review_path, mask_dst)
info = (
f"status={status}\n"
f"base={base}\n"
f"message={message}\n"
f"review_index={r_idx}/{len(review_paths)}\n"
f"review_group={r_group}\n"
f"review_original={review_path}\n"
f"preview_index={p_idx}/{len(previews)}\n"
f"preview_group={p_group}\n"
f"preview_original={preview_path}\n"
f"target_correspondente={target}\n"
)
(pair_dir / "info.txt").write_text(info, encoding="utf-8")
pair_count += 1
except Exception as e:
errors.append(f"{pair_name}: {e}")
return QuarantineResult(
case_dir=case_dir,
pair_count=pair_count,
errors=errors,
)
def write_report(path: Path, rows: List[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fields = [
"status",
"apply_eligible",
"base",
"review_count",
"review_groups",
@ -315,6 +406,8 @@ def write_report(path: Path, rows: List[dict]) -> None:
"previews",
"target_count",
"targets",
"quarantine_dir",
"quarantine_pairs",
"message",
]
@ -332,8 +425,8 @@ def write_report(path: Path, rows: List[dict]) -> None:
def main() -> int:
ap = argparse.ArgumentParser(
description=(
"Aplica rapidamente masks revisadas em todas as cópias "
"correspondentes encontradas nas BRUTAS."
"Aplica masks revisadas somente quando o mapeamento é 1:1; "
"toda duplicidade é enviada para quarentena visual."
)
)
ap.add_argument("--review-root", required=True)
@ -344,10 +437,18 @@ def main() -> int:
ap.add_argument("--labelmap", default="", help=argparse.SUPPRESS)
ap.add_argument("--report", default="")
ap.add_argument(
"--quarantine-root",
default="",
help=(
"Pasta-base da quarentena. Se omitido, usa "
"<brutas-root>/../review_quarantine/run_TIMESTAMP."
),
)
ap.add_argument(
"--apply",
action="store_true",
help="Sem esta flag, faz somente dry-run.",
help="Sem esta flag, faz dry-run e gera somente relatório/quarentena.",
)
args = ap.parse_args()
@ -356,28 +457,37 @@ def main() -> int:
config_path = Path(args.config).resolve()
if not review_root.is_dir():
raise SystemExit(
f"[ERRO] review-root não encontrado: {review_root}"
)
raise SystemExit(f"[ERRO] review-root não encontrado: {review_root}")
if not brutas_root.is_dir():
raise SystemExit(
f"[ERRO] brutas-root não encontrado: {brutas_root}"
)
raise SystemExit(f"[ERRO] brutas-root não encontrado: {brutas_root}")
sizes = load_sizes(config_path)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_path = (
Path(args.report).resolve()
if args.report
else brutas_root.parent / f"apply_review_masks_safe_{timestamp}.csv"
)
quarantine_root = (
Path(args.quarantine_root).resolve() / f"run_{timestamp}"
if args.quarantine_root
else brutas_root.parent / "review_quarantine" / f"run_{timestamp}"
)
print("[CONFIG]")
print(
f" resolucao : {sizes.review_w}x{sizes.review_h} "
f" resolucao : {sizes.review_w}x{sizes.review_h} "
"(review/treino)"
)
print(
f" raw_size : {sizes.raw_w}x{sizes.raw_h} "
f" raw_size : {sizes.raw_w}x{sizes.raw_h} "
"(brutas)"
)
print(
" resize : cv2.INTER_NEAREST"
)
print(" resize : cv2.INTER_NEAREST")
print(" política : SOMENTE mapeamento 1 review : 1 target")
print(f" quarantine root : {quarantine_root}")
masks = discover_review_masks(review_root)
if not masks:
@ -394,34 +504,33 @@ def main() -> int:
"encontrados nas BRUTAS."
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_path = (
Path(args.report).resolve()
if args.report
else brutas_root.parent
/ f"apply_review_masks_final_{timestamp}.csv"
)
print("=" * 96)
print("APPLY REVIEW MASKS - FINAL")
print("=" * 100)
print("APPLY REVIEW MASKS - SAFE / QUARANTINE")
print(f"Modo : {'APPLY' if args.apply else 'DRY-RUN'}")
print(f"Review root : {review_root}")
print(f"Brutas root : {brutas_root}")
print(f"Arquivos review : {len(masks)}")
print(f"Bases review : {len(reviews_by_base)}")
print("=" * 96)
print("=" * 100)
rows: List[dict] = []
logical_reviews: List[LogicalReview] = []
not_found = 0
duplicate_review_ok = 0
duplicate_review_conflict = 0
multi_target_bases = 0
quarantine_bases = 0
quarantine_pairs = 0
quarantine_copy_errors = 0
q_multi_target = 0
q_review_dup_equivalent = 0
q_review_dup_conflict = 0
q_mixed_equivalent = 0
q_mixed_conflict = 0
total_target_files = 0
# ------------------------------------------------------------------
# PREFLIGHT LEVE
# PREFLIGHT CONSERVADOR
# ------------------------------------------------------------------
for base in sorted(reviews_by_base):
review_paths = reviews_by_base[base]
@ -429,12 +538,11 @@ def main() -> int:
targets = unique_targets_from_previews(previews, base)
groups_review = sorted({review_group(p) for p in review_paths})
groups_preview = sorted(
{p.parent.parent.name for p in previews}
)
groups_preview = sorted({preview_group(p) for p in previews})
row = {
"status": "",
"apply_eligible": "NAO",
"base": base,
"review_count": len(review_paths),
"review_groups": " | ".join(groups_review),
@ -444,53 +552,101 @@ def main() -> int:
"previews": " | ".join(str(p) for p in previews),
"target_count": len(targets),
"targets": " | ".join(str(p) for p in targets),
"quarantine_dir": "",
"quarantine_pairs": 0,
"message": "",
}
if not targets:
not_found += 1
row["status"] = "NAO_ENCONTRADO"
row["message"] = (
"nenhum preview correspondente encontrado nas BRUTAS"
)
row["message"] = "nenhum preview correspondente encontrado nas BRUTAS"
rows.append(row)
continue
duplicate_equivalent = False
review_duplicate = len(review_paths) > 1
multi_target = len(targets) > 1
if len(review_paths) > 1:
quarantine_status: Optional[str] = None
quarantine_message = ""
if review_duplicate:
equivalent, reason = duplicate_reviews_are_equivalent(
review_paths,
sizes,
)
if not equivalent:
duplicate_review_conflict += 1
row["status"] = "CONFLITO_REVIEW_DUPLICADO"
row["message"] = reason
rows.append(row)
continue
duplicate_equivalent = True
duplicate_review_ok += 1
if multi_target:
if equivalent:
q_mixed_equivalent += 1
quarantine_status = "QUARENTENA_DUPLICIDADE_MISTA_EQUIVALENTE"
kind = "equivalentes"
else:
q_mixed_conflict += 1
quarantine_status = "QUARENTENA_DUPLICIDADE_MISTA_CONFLITANTE"
kind = "diferentes/conflitantes"
quarantine_message = (
f"base duplicado no REVIEW ({len(review_paths)} masks, {kind}) "
f"e também nas BRUTAS ({len(targets)} targets). {reason}"
)
elif equivalent:
q_review_dup_equivalent += 1
quarantine_status = "QUARENTENA_REVIEW_DUPLICADO_EQUIVALENTE"
quarantine_message = (
f"mesmo base aparece {len(review_paths)} vezes no REVIEW; "
"apesar de equivalente, política segura exige revisão manual. "
f"{reason}"
)
else:
q_review_dup_conflict += 1
quarantine_status = "QUARENTENA_CONFLITO_REVIEW_DUPLICADO"
quarantine_message = reason
if len(targets) > 1:
multi_target_bases += 1
row["status"] = "MULTI_TARGET"
row["message"] = (
f"mesma revisão será aplicada em {len(targets)} "
"cópias das BRUTAS"
elif multi_target:
q_multi_target += 1
quarantine_status = "QUARENTENA_MULTI_TARGET"
quarantine_message = (
f"mesmo base aponta para {len(targets)} destinos nas BRUTAS; "
"nenhuma cópia será alterada automaticamente"
)
elif duplicate_equivalent:
row["status"] = "REVIEW_DUPLICADO_OK"
row["message"] = (
"mesmo base apareceu várias vezes no review, "
"mas as masks são equivalentes"
if quarantine_status is not None:
quarantine_bases += 1
qres = quarantine_case(
quarantine_root=quarantine_root,
status=quarantine_status,
base=base,
review_paths=review_paths,
previews=previews,
targets=targets,
message=quarantine_message,
)
else:
row["status"] = "RESOLVIDO"
total_target_files += len(targets)
quarantine_pairs += qres.pair_count
quarantine_copy_errors += len(qres.errors)
row["status"] = quarantine_status
row["quarantine_dir"] = str(qres.case_dir)
row["quarantine_pairs"] = qres.pair_count
row["message"] = quarantine_message
if qres.errors:
row["message"] += " | ERROS_QUARENTENA: " + " || ".join(qres.errors)
rows.append(row)
continue
# Só chega aqui quando é estritamente 1 review : 1 target.
if len(review_paths) != 1 or len(targets) != 1:
raise RuntimeError(
f"invariante de segurança quebrada no preflight: {base} "
f"review_count={len(review_paths)} target_count={len(targets)}"
)
row["status"] = "RESOLVIDO_1x1"
row["apply_eligible"] = "SIM"
row["message"] = "mapeamento estritamente 1 review : 1 target"
total_target_files += 1
logical_reviews.append(
LogicalReview(
base=base,
@ -498,114 +654,112 @@ def main() -> int:
representative=review_paths[0],
targets=targets,
preview_paths=previews,
duplicate_review_equivalent=duplicate_equivalent,
)
)
rows.append(row)
print("[PREFLIGHT]")
print(f" Bases prontas : {len(logical_reviews)}")
print(f" Bases com múltiplos alvos : {multi_target_bases}")
print(f" Review duplicado idêntico : {duplicate_review_ok}")
print(f" Conflito review duplicado : {duplicate_review_conflict}")
print(f" Não encontrados : {not_found}")
print(f" Arquivos de mask a gravar : {total_target_files}")
print(f" Bases elegíveis 1:1 : {len(logical_reviews)}")
print(f" Bases em quarentena : {quarantine_bases}")
print(f" Multi-target : {q_multi_target}")
print(f" Review duplicado equivalente : {q_review_dup_equivalent}")
print(f" Review duplicado conflitante : {q_review_dup_conflict}")
print(f" Duplicidade mista equivalente : {q_mixed_equivalent}")
print(f" Duplicidade mista conflitante : {q_mixed_conflict}")
print(f" Duplas visuais geradas : {quarantine_pairs}")
print(f" Erros ao copiar quarentena : {quarantine_copy_errors}")
print(f" Não encontrados : {not_found}")
print(f" Arquivos que o apply pode gravar : {total_target_files}")
write_report(report_path, rows)
if quarantine_bases:
print(f"[QUARENTENA] {quarantine_root}")
if not args.apply:
print(f"[DRY-RUN] Relatório: {report_path}")
print("[DRY-RUN] Nenhuma mask dentro das BRUTAS foi alterada.")
print(
"[DRY-RUN] Nenhuma mask das BRUTAS foi lida ou alterada."
"[DRY-RUN] A quarentena contém somente CÓPIAS dos casos ambíguos "
"para inspeção manual."
)
print(
"[INFO] Bases com múltiplos previews NÃO são mais ambíguas: "
"a mesma mask revisada será aplicada em todas as cópias."
)
if duplicate_review_conflict:
if quarantine_copy_errors:
print(
"[ATENÇÃO] Existem conflitos reais entre masks duplicadas "
"no próprio review. Esses bases serão pulados também no apply."
"[ATENÇÃO] Houve erro ao montar parte da quarentena. "
"Não rode --apply até revisar o relatório."
)
return 2
return 0
# Se a própria quarentena falhou, aborta o apply inteiro. Melhor não correr.
if quarantine_copy_errors:
print(
"[ABORTADO] Houve falha ao copiar arquivos da quarentena. "
"Nenhuma mask será aplicada."
)
return 2
# ------------------------------------------------------------------
# APPLY
# APPLY ESTRITAMENTE 1:1
# ------------------------------------------------------------------
applied_bases = 0
applied_files = 0
image_errors = 0
for i, item in enumerate(logical_reviews, 1):
for item in logical_reviews:
try:
# Lê/redimensiona UMA vez por base lógico.
# Segunda barreira de segurança antes de qualquer escrita.
if len(item.review_masks) != 1 or len(item.targets) != 1:
raise RuntimeError(
"item ambíguo chegou ao APPLY; operação bloqueada "
f"(reviews={len(item.review_masks)}, targets={len(item.targets)})"
)
img = load_and_resize_mask(item.representative, sizes)
target = item.targets[0]
atomic_write_png(img, target)
# Aplica em TODAS as cópias existentes das BRUTAS.
for target in item.targets:
atomic_write_png(img, target)
applied_files += 1
applied_files += 1
applied_bases += 1
if (
applied_bases <= 10
or applied_bases % 100 == 0
or len(item.targets) > 1
):
suffix = (
f" -> {len(item.targets)} cópias"
if len(item.targets) > 1
else ""
)
if applied_bases <= 10 or applied_bases % 100 == 0:
print(
f"[{applied_bases}/{len(logical_reviews)}] "
f"{item.base}{suffix}"
f"{item.base} -> 1 target"
)
except Exception as e:
image_errors += 1
print(f"[ERRO] {item.base}: {e}")
# Atualiza a linha correspondente no relatório.
for row in rows:
if row["base"] == item.base:
row["status"] = "ERRO_APPLY"
row["apply_eligible"] = "NAO"
row["message"] = str(e)
break
# Atualiza status dos itens que chegaram ao fim sem erro.
error_bases = {
row["base"]
for row in rows
if row["status"] == "ERRO_APPLY"
}
for row in rows:
if (
row["base"] not in error_bases
and row["status"]
in {"RESOLVIDO", "MULTI_TARGET", "REVIEW_DUPLICADO_OK"}
):
if row["base"] not in error_bases and row["status"] == "RESOLVIDO_1x1":
row["status"] = "APLICADO"
write_report(report_path, rows)
print("=" * 96)
print(f"[OK] Bases aplicadas : {applied_bases}")
print(f"[OK] Arquivos gravados : {applied_files}")
print(f"[SKIP] Não encontrados : {not_found}")
print(
f"[SKIP] Conflito review duplic.: "
f"{duplicate_review_conflict}"
)
print(f"[ERRO] Falhas de imagem/escrita: {image_errors}")
print(f"[OK] Relatório : {report_path}")
if duplicate_review_conflict:
print(
"[INFO] Conflitos de revisão duplicada foram deixados intactos "
"para resolução manual."
)
print("=" * 100)
print(f"[OK] Bases aplicadas 1:1 : {applied_bases}")
print(f"[OK] Arquivos gravados : {applied_files}")
print(f"[SKIP] Bases em quarentena : {quarantine_bases}")
print(f"[SKIP] Não encontrados : {not_found}")
print(f"[ERRO] Falhas imagem/escrita : {image_errors}")
print(f"[OK] Relatório : {report_path}")
if quarantine_bases:
print(f"[OK] Quarentena : {quarantine_root}")
return 0 if image_errors == 0 else 2

File diff suppressed because it is too large Load Diff

View File

@ -54,6 +54,8 @@ python _7_split.py ^
--synthetic-train-only ^
--synthetic-respect-family-split ^
--clear-dst
python .\_7_split.py --src-root dataset/960x600/group --train 0.85 --val 0.15 --test 0 --strategy agri_diverse --use-tensor-features --merge-near-duplicate-families --seed 42 --clear-dst
"""
from __future__ import annotations

View File

@ -1,7 +1,7 @@
{
"camera": "oak-fcc-3",
"modelo": "segformer_b1",
"model_name": "2026_09_08",
"model_name": "2026_09_18",
"main_class_name": "erva",
"es_classes": "",
"model_to_use": "geral",