agrobot_base/Python/OAK/datasets/_8_train_fastscnn.py

700 lines
28 KiB
Python
Raw Normal View History

2025-09-15 10:23:07 +00:00
# 👉 force backend headless (sem Tk)
import itertools
import os
import random
os.environ["MPLBACKEND"] = "Agg" # extra-garantia
import matplotlib
matplotlib.use("Agg") # tem que vir antes do pyplot!
import matplotlib.pyplot as plt
plt.ioff() # desliga modo interativo
import json
import time
import argparse
from PIL import Image
import numpy as np
import torch
import torch.nn.functional as F
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from fast_scnn import FastSCNN
from roi_seg_dataset import ROISegDataset
from utils import carregar_labelmap_completo, compute_roi_indices
# ⚙️ Configurações
with open("config.json", "r") as f:
config = json.load(f)
MODELO = config["camera"]
MODEL_NAME = config["model_name"]
RESOLUCAO = config["resolucao"]
ROI_INICIO = config["roi_inicio"]
ROI_TAMANHO = config["roi_tamanho"]
MAIN_CLASS_NAME = config["main_class_name"]
save_path = os.path.join(MODELO, "backup", config["modelo"], MODEL_NAME)
dataset_path = os.path.join(MODELO, "dataset")
labelmap_path = os.path.join(dataset_path, "labelmap.txt")
batch_size = 32
num_workers = 4
# ---- Helpers de métricas ----
def _infer_ignore_id(ignore_rgb, default_id=255):
import numpy as _np
if isinstance(ignore_rgb, (list, tuple)):
if len(ignore_rgb) == 1 and isinstance(ignore_rgb[0], (int, _np.integer)):
return int(ignore_rgb[0])
if len(ignore_rgb) == 3:
return default_id
if isinstance(ignore_rgb, (int, _np.integer)):
return int(ignore_rgb)
return default_id
def compute_class_weights_from_split(
train_split_root: str,
labelmap_path: str,
roi_inicio: float,
roi_tamanho: float,
*,
alpha: float = 1.2, # ↑ reforça classes raras (1.0 a 1.5 costuma ir bem)
w_min: float = 0.3, # piso geral
w_max: float = 4.0, # teto geral
floor_bg: float = 0.4, # piso específico pro 'chao' / background
max_samples_per_group: int = 300 # amostras p/ grupo (acelera o cálculo)
):
"""
Calcula pesos dinâmicos (median frequency balancing ^ alpha) SOBRE A ROI das máscaras do split/train.
- Normaliza por ROI (mesma fatia usada no dataset).
- Clampa pesos entre [w_min, w_max] e mantém 'chao' no mínimo floor_bg.
Retorna: tensor de pesos (indexado por ID de classe).
"""
# Labelmap
_, _, classes, ignore_rgb = carregar_labelmap_completo(labelmap_path)
ignore_id = _infer_ignore_id(ignore_rgb, default_id=255)
class_ids = sorted(classes.keys()) # ex.: [0,1,2]
counts = np.zeros(len(class_ids), dtype=np.int64)
# Onde estão as máscaras do train?
group_root = os.path.join(train_split_root, "group")
group_dirs = []
if os.path.isdir(group_root):
for g in sorted(os.listdir(group_root)):
mdir = os.path.join(group_root, g, "masks")
if os.path.isdir(mdir):
group_dirs.append(mdir)
else:
# fallback legado
mdir = os.path.join(train_split_root, "masks")
if os.path.isdir(mdir):
group_dirs.append(mdir)
# Conta pixels por classe DENTRO DA ROI
for mdir in group_dirs:
n = 0
for p in os.listdir(mdir):
if not p.lower().endswith(".png"):
continue
m = np.array(Image.open(os.path.join(mdir, p)).convert("L"))
H = m.shape[0]
y_fim, y_ini = compute_roi_indices(H, roi_inicio, roi_tamanho)
roi = m[y_fim:y_ini, :]
# ignora 'ignore' e só soma classes válidas
for i, cid in enumerate(class_ids):
if cid == ignore_id:
continue
counts[i] += int((roi == cid).sum())
n += 1
if n >= max_samples_per_group:
break
total = int(counts.sum())
if total == 0:
# fallback seguro
print("⚠️ compute_class_weights_from_split: não encontrei pixels válidos; usando pesos [1,1,...].")
return None, None
freqs = counts / total # frequência por classe
nonzero = freqs[freqs > 0]
base = np.median(nonzero) if nonzero.size > 0 else 1.0
weights_arr = np.zeros_like(freqs, dtype=np.float32)
for i, f in enumerate(freqs):
if f <= 0:
w = w_max
else:
# median-freq ^ alpha
w = (base / f) ** alpha
w = float(np.clip(w, w_min, w_max))
weights_arr[i] = w
# Piso do 'chao' (ou 'background'), se existir
for i, cid in enumerate(class_ids):
name = str(classes[cid]).lower()
if ("chao" in name) or ("background" in name):
weights_arr[i] = max(weights_arr[i], floor_bg)
# Constrói vetor na indexação por ID de classe (0..max_id)
max_cid = max(class_ids)
weights_full = np.ones(max_cid + 1, dtype=np.float32)
for i, cid in enumerate(class_ids):
weights_full[cid] = weights_arr[i]
# Log bonitinho
pretty = {int(cid): (str(classes[cid]), float(weights_full[cid]), float(freqs[i]))
for i, cid in enumerate(class_ids)}
return weights_full, pretty
@torch.no_grad()
def confmat_update(confmat, pred, target, num_classes, ignore_index=None):
# pred, target: (B,H,W)
if ignore_index is not None:
mask = target != ignore_index
target = target[mask]
pred = pred[mask]
k = (target * num_classes + pred).to(torch.int64)
binc = torch.bincount(k, minlength=num_classes**2)
confmat += binc.reshape(num_classes, num_classes)
return confmat
def metrics_from_confmat(confmat, main_class_id=None):
# confmat: CxC
cm = confmat.float()
tp = torch.diag(cm)
fp = cm.sum(0) - tp
fn = cm.sum(1) - tp
denom_iou = tp + fp + fn + 1e-7
iou_per_class = tp / denom_iou
miou = iou_per_class.mean().item()
pix_acc = tp.sum() / (cm.sum() + 1e-7)
main_class_metrics = None
if main_class_id is not None and 0 <= main_class_id < cm.shape[0]:
p = tp[main_class_id] / (tp[main_class_id] + fp[main_class_id] + 1e-7)
r = tp[main_class_id] / (tp[main_class_id] + fn[main_class_id] + 1e-7)
f1 = 2 * p * r / (p + r + 1e-7)
main_class_metrics = {
"precision": p.item(),
"recall": r.item(),
"f1": f1.item(),
"iou": iou_per_class[main_class_id].item(),
}
return {
"miou": miou,
"pixel_acc": pix_acc.item(),
"iou_per_class": iou_per_class.cpu().tolist(),
"main_class": main_class_metrics
}
def _id_by_name(d, name):
name = name.lower()
for cid, nm in d.items():
if isinstance(nm, str) and name in nm.lower():
return cid
return None
def _get_mask_roi_from_ds(ds, i, roi_inicio, roi_tamanho):
"""Tenta obter o caminho da máscara; se não der, usa ds[i]."""
mask_path = None
if hasattr(ds, "mask_paths"):
mask_path = ds.mask_paths[i]
elif hasattr(ds, "items"):
item = ds.items[i]
if isinstance(item, dict) and "mask" in item:
mask_path = item["mask"]
if mask_path is not None:
m = np.array(Image.open(mask_path).convert("L"))
else:
# fallback: carrega a máscara já processada pelo dataset
_, y = ds[i] # y: Tensor [H,W]
m = y.cpu().numpy()
H = m.shape[0]
y_fim, y_ini = compute_roi_indices(H, roi_inicio, roi_tamanho)
return m[y_fim:y_ini, :]
def compute_presence_indices(ds, class_ids, roi_inicio, roi_tamanho):
"""
presence: {cid: [idxs que CONTÊM essa classe na ROI]}
others: [idxs que NÃO contêm NENHUMA das 'class_ids' na ROI]
"""
presence = {cid: [] for cid in class_ids}
others = []
for i in range(len(ds)):
roi = _get_mask_roi_from_ds(ds, i, roi_inicio, roi_tamanho)
found_any = False
for cid in class_ids:
if (roi == cid).any():
presence[cid].append(i)
found_any = True
if not found_any:
others.append(i)
return presence, others
class EnsureClassesBatchSampler(torch.utils.data.Sampler):
"""
Garante >=1 amostra de CADA classe em 'required_classes' por batch.
Preenche o resto com índices do pool (others + todo o conjunto).
Use com DataLoader(..., batch_sampler= sampler) sem passar batch_size/sampler/shuffle.
"""
def __init__(self, presence, total_indices, batch_size, required_classes, seed=42):
self.presence = presence # dict cid -> list[idx]
self.required = [c for c in required_classes if len(presence.get(c, [])) > 0]
self.batch_size = batch_size
# iteradores cíclicos (com reposição) por classe requerida
self.iters = {
c: itertools.cycle(self.presence[c]) for c in self.required
}
# pool de preenchimento: todos os índices (mistura bem)
self.rest_iter = itertools.cycle(list(total_indices))
self.rng = random.Random(seed)
# tamanho lógico: nº de batches por época
self._length = max(1, int(np.ceil(len(total_indices) / float(batch_size))))
def __iter__(self):
for _ in range(self._length):
batch = []
# 1 de cada classe requerida (se existir)
for c in self.required:
batch.append(next(self.iters[c]))
# completa o batch
while len(batch) < self.batch_size:
batch.append(next(self.rest_iter))
self.rng.shuffle(batch)
yield batch
def __len__(self):
return self._length
def train(args):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")
# --- Dataset ---
ds_train = ROISegDataset(
os.path.join(dataset_path, "split", "train"),
save_path, ROI_INICIO, ROI_TAMANHO,
RESOLUCAO[0], RESOLUCAO[1], labelmap_path
)
ds_val = ROISegDataset(
os.path.join(dataset_path, "split", "val"),
save_path, ROI_INICIO, ROI_TAMANHO,
RESOLUCAO[0], RESOLUCAO[1], labelmap_path
)
# --- Mapa id->nome (usa o do dataset; se não houver, carrega do labelmap) ---
if hasattr(ds_train, "classes") and isinstance(ds_train.classes, dict) and len(ds_train.classes) > 0:
id_to_name = {int(k): str(v) for k, v in ds_train.classes.items()}
else:
# fallback seguro ao arquivo de labelmap
_, _, id_to_name, _ = carregar_labelmap_completo(labelmap_path)
id_to_name = {int(k): str(v) for k, v in id_to_name.items()}
# name->id (case-insensitive)
name_to_id = {v.strip().lower(): k for k, v in id_to_name.items()}
# --- Lista dinâmica de classes a garantir por batch ---
raw = getattr(args, "ensure_per_batch", "")
req_names = [s.strip().lower() for s in raw.split(",") if s.strip()]
req_ids = []
for nm in req_names:
cid = name_to_id.get(nm)
if cid is None:
# tenta correspondência parcial (p.ex. "erva" casa com "Erva", "weed_erva", etc.)
matches = [k for k, v in id_to_name.items() if nm in v.lower()]
if len(matches) == 1:
cid = matches[0]
elif len(matches) > 1:
print(f"⚠️ '--ensure-per-batch {nm}': ambíguo entre {[id_to_name[m] for m in matches]}; ignorando este nome.")
cid = None
else:
print(f"⚠️ '--ensure-per-batch {nm}': classe não encontrada nas classes {list(name_to_id.keys())}.")
if cid is not None and cid not in req_ids:
req_ids.append(cid)
if len(req_ids) > 0:
presence, _ = compute_presence_indices(
ds_train, class_ids=req_ids, roi_inicio=ROI_INICIO, roi_tamanho=ROI_TAMANHO
)
total_indices = range(len(ds_train))
batch_sampler = EnsureClassesBatchSampler(
presence=presence,
total_indices=total_indices,
batch_size=batch_size,
required_classes=req_ids,
seed=getattr(args, "seed", 42)
)
# ⚠️ Use 'batch_sampler' (NÃO passe batch_size/sampler/shuffle)
dl_train = DataLoader(ds_train, batch_sampler=batch_sampler,
num_workers=num_workers, pin_memory=True)
else:
dl_train = DataLoader(ds_train, batch_size=batch_size, shuffle=True,
num_workers=num_workers, pin_memory=True)
# Validação normal
dl_val = DataLoader(ds_val, batch_size=batch_size, shuffle=False,
num_workers=num_workers, pin_memory=True)
# Detecta automaticamente o ID da classe ERVA
main_class_id = None
try:
if hasattr(ds_train, "classes") and isinstance(ds_train.classes, dict):
for k, v in ds_train.classes.items():
if isinstance(v, str) and MAIN_CLASS_NAME in v.lower():
main_class_id = k
break
elif isinstance(ds_train.classes, (list, tuple)):
main_class_id = next((i for i, c in enumerate(ds_train.classes) if isinstance(c, str) and MAIN_CLASS_NAME in c.lower()), None)
if main_class_id is not None:
print(f"🌿 Classe PRIMARIA detectada: id={main_class_id}, nome='{ds_train.classes[main_class_id]}'")
else:
print("⚠️ Classe PRIMARIA não encontrada; métricas específicas da classe primaria serão puladas.")
except Exception as e:
print(f"⚠️ Erro ao detectar classe PRIMARIA: {e}")
num_classes = len(ds_train.classes)
# --- Modelo / Otimizador / Schedulers ---
model = FastSCNN(num_classes=num_classes).to(device)
# --- Pesos dinâmicos por classe (sobre a ROI do split/train) ---
train_split_root = os.path.join(dataset_path, "split", "train")
weights_np, debug_info = compute_class_weights_from_split(
train_split_root=train_split_root,
labelmap_path=labelmap_path,
roi_inicio=ROI_INICIO,
roi_tamanho=ROI_TAMANHO,
alpha=getattr(args, "cw_alpha", 1.05),
w_min=getattr(args, "cw_min", 0.3),
w_max=getattr(args, "cw_max", 2.0),
floor_bg=getattr(args, "cw_bgfloor", 0.6),
max_samples_per_group=getattr(args, "cw_max_per_group", 300)
)
if weights_np is None or not getattr(args, "use_wights", False):
# fallback seguro
weights_t = None
print("⚠️ Pesos dinâmicos indisponíveis; usando CrossEntropy sem pesos.")
else:
import pprint
pprint.pprint({"class_weights": debug_info})
weights_t = torch.tensor(weights_np, device=device)
# --- Warm-up de pesos por época ---
ones = torch.ones_like(weights_t) if weights_t is not None else None
def make_epoch_weights(epoch, *, cw_warmup=8):
"""
Interpola: w_epoch = (1 - λ) * 1 + λ * weights_t
λ cresce de 01 nas primeiras `cw_warmup` épocas.
Retorna (w_epoch, dice_w_normalized) ou (None, None) se sem pesos.
"""
if weights_t is None:
return None, None
# lê da CLI ou usa default
cw_warmup = getattr(args, "cw_warmup", cw_warmup)
# λ linear 0→1 (pode trocar por cosseno, ver abaixo)
cw_lambda = min(1.0, max(0.0, (epoch - 1) / max(1, cw_warmup)))
w_epoch = (1.0 - cw_lambda) * ones + cw_lambda * weights_t
# normaliza para o Dice (evita distorção)
dice_w = (w_epoch / w_epoch.mean()).detach()
return w_epoch, dice_w
optimizer = optim.AdamW(model.parameters(), lr=getattr(args, "lr", 3e-4), weight_decay=1e-4)
def dice_loss(logits, target, ignore_index=255, class_weights=None, eps=1e-6):
"""
logits: [N, C, H, W] (antes do softmax)
target: [N, H, W] com IDs de classe; 'ignore_index' será mascarado
class_weights: tensora opcional [C] (ex.: pesos da CE, normalizados)
"""
N, C, H, W = logits.shape
# Probabilidades por classe
pred = F.softmax(logits, dim=1) # [N,C,H,W]
# Máscara de válidos (ignora 255)
valid = (target != ignore_index) # [N,H,W]
target_clamped = torch.clamp(target, 0, C-1) # evita index out of range
# One-hot do target (com válidos)
one_hot = torch.zeros((N, C, H, W),
device=logits.device,
dtype=pred.dtype)
one_hot.scatter_(1, target_clamped.unsqueeze(1), 1.0) # [N,1,H,W] -> [N,C,H,W]
# Aplica máscara de válidos
valid = valid.unsqueeze(1) # [N,1,H,W]
pred = pred * valid
one_hot = one_hot * valid
# Dice por classe (agrega em N,H,W)
inter = (pred * one_hot).sum(dim=(0, 2, 3)) # [C]
pred_sum = pred.sum(dim=(0, 2, 3)) # [C]
tgt_sum = one_hot.sum(dim=(0, 2, 3)) # [C]
dice = (2 * inter + eps) / (pred_sum + tgt_sum + eps) # [C]
if class_weights is not None:
# opcional: ponderar o Dice com pesos (normalize antes!)
# garante shape [C]
w = torch.ones(C, device=logits.device, dtype=pred.dtype)
w[:class_weights.numel()] = class_weights
loss = 1.0 - (w * dice).sum() / (w.sum() + eps)
else:
loss = 1.0 - dice.mean()
return loss
# Scheduler inteligente: começa em Cosine, muda pra Plateau se travar
min_lr = getattr(args, "min_lr", 1e-6)
plateau_factor = getattr(args, "plateau_factor", 0.5)
plateau_patience = getattr(args, "plateau_patience", 6) # épocas sem melhora antes de trocar
plateau_cooldown = getattr(args, "plateau_cooldown", 1)
cosine = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.epochs, eta_min=min_lr)
plateau = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode="min", factor=plateau_factor,
patience=plateau_patience, cooldown=plateau_cooldown,
min_lr=min_lr, verbose=True
)
active_sched = "cosine"
scaler = torch.amp.GradScaler('cuda', enabled=args.amp)
start_epoch = 1
best_val_loss = float("inf")
best_main_class_f1 = -1.0
train_loss_history, val_loss_history, lr_history = [], [], []
f1_history, miou_history = [], []
# --- no topo (config) ---
patience_loss = 12 # ligeiramente > plateau_patience + 2
patience_f1 = 6 # deixa o F1 respirar
delta_f1_min = 0.0015 # ignora ruído
grace_after_switch = 4 # épocas de graça após mudar pro Plateau
no_imp_loss = 0
no_imp_f1 = 0
epochs_since_switch = 0
active_sched = "cosine" # como já está
# --- Checkpoint ---
if args.checkpoint and os.path.exists(args.checkpoint):
print(f"🔁 Carregando modelo salvo: {args.checkpoint}")
checkpoint = torch.load(args.checkpoint, map_location=device)
if "model" in checkpoint:
model.load_state_dict(checkpoint["model"])
optimizer.load_state_dict(checkpoint["optimizer"])
scaler.load_state_dict(checkpoint["scaler"])
start_epoch = checkpoint.get("epoch", 1) + 1
best_val_loss = checkpoint.get("best_val_loss", float("inf"))
else:
model.load_state_dict(checkpoint)
# --- Loop de treino ---
for epoch in range(start_epoch, args.epochs + 1):
t0 = time.time()
# pesos deste epoch
w_epoch, dice_w = make_epoch_weights(epoch)
dice_mix = min(0.3, (epoch-1)/10 * 0.3) # 0.0→0.3 nas 10 primeiras
ce_mix = 1.0 - dice_mix
if w_epoch is not None:
erva_id = _id_by_name(ds_train.classes, "erva")
cana_id = _id_by_name(ds_train.classes, "cana")
chao_id = _id_by_name(ds_train.classes, "chao")
# calcula lambda atual (igual ao make_epoch_weights)
cw_warmup = getattr(args, "cw_warmup", 8)
cw_lambda = min(1.0, max(0.0, (epoch - 1) / max(1, cw_warmup)))
if cw_lambda < 1.0:
if erva_id is not None:
w_epoch[erva_id] = torch.clamp(w_epoch[erva_id], min=1.2)
if cana_id is not None:
w_epoch[cana_id] = torch.clamp(w_epoch[cana_id], max=2.2)
if chao_id is not None:
w_epoch[chao_id] = torch.clamp(w_epoch[chao_id], min=0.5)
# re-normaliza o peso do Dice após clamps
dice_w = (w_epoch / w_epoch.mean()).detach()
ce = nn.CrossEntropyLoss(ignore_index=ds_train.ignore_id, weight=w_epoch)
else:
ce = nn.CrossEntropyLoss(ignore_index=ds_train.ignore_id)
# ----- Treino -----
model.train()
running_train_loss = 0
for x, y in dl_train:
x, y = x.to(device), y.to(device)
optimizer.zero_grad(set_to_none=True)
with torch.amp.autocast('cuda', enabled=args.amp):
logits = model(x)
dloss = dice_loss(logits, y, ignore_index=ds_train.ignore_id, class_weights=None)
loss = ce_mix * ce(logits, y) + dice_mix * dloss
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
running_train_loss += loss.item() * x.size(0)
avg_train_loss = running_train_loss / len(ds_train)
train_loss_history.append(avg_train_loss)
# ----- Validação + métricas -----
model.eval()
running_val_loss = 0
confmat = torch.zeros((num_classes, num_classes), dtype=torch.int64, device=device)
with torch.no_grad():
for x, y in dl_val:
x, y = x.to(device), y.to(device)
with torch.amp.autocast('cuda', enabled=args.amp):
logits = model(x)
dloss = dice_loss(logits, y, ignore_index=ds_train.ignore_id, class_weights=None)
loss = ce_mix * ce(logits, y) + dice_mix * dloss
running_val_loss += loss.item() * x.size(0)
pred = logits.argmax(1)
confmat = confmat_update(confmat, pred, y, num_classes, ignore_index=ds_train.ignore_id)
avg_val_loss = running_val_loss / len(ds_val)
val_loss_history.append(avg_val_loss)
m = metrics_from_confmat(confmat, main_class_id=main_class_id)
miou_history.append(m["miou"])
main_class_f1 = m["main_class"]["f1"] if (m["main_class"] is not None) else None
if main_class_f1 is not None:
f1_history.append(main_class_f1)
cur_lr = optimizer.param_groups[0]["lr"]
lr_history.append(cur_lr)
elapsed = time.time() - t0
msg = (f"[{epoch}/{args.epochs}] "
f"train_loss={avg_train_loss:.4f} "
f"val_loss={avg_val_loss:.4f} "
f"mIoU={m['miou']:.4f} "
f"pixAcc={m['pixel_acc']:.4f} "
f"lr={cur_lr:.2e} "
f"time={elapsed:.1f}s")
if main_class_f1 is not None:
msg += f" | {MAIN_CLASS_NAME}: F1={main_class_f1:.4f} IoU={m['main_class']['iou']:.4f}"
print(msg)
# ----- Tracking de melhora por LOSS -----
improved_loss = avg_val_loss < best_val_loss - 1e-6
if improved_loss:
best_val_loss = avg_val_loss
no_imp_loss = 0
# checkpoint por loss
torch.save(model.state_dict(), os.path.join(save_path, f"{MODEL_NAME}_best.pth"))
torch.save({
"model": model.state_dict(),
"optimizer": optimizer.state_dict(),
"scaler": scaler.state_dict(),
"epoch": epoch,
"best_val_loss": best_val_loss
}, os.path.join(save_path, f"{MODEL_NAME}_best_checkpoint.pth"))
print("✅ Novo melhor modelo salvo (val_loss).")
else:
no_imp_loss += 1
# ----- Tracking + checkpoint por F1 da classe principal -----
if main_class_f1 is not None:
if main_class_f1 > best_main_class_f1 + delta_f1_min:
best_main_class_f1 = main_class_f1
no_imp_f1 = 0
torch.save(model.state_dict(), os.path.join(save_path, f"{MODEL_NAME}_best_f1_{MAIN_CLASS_NAME}.pth"))
print(f"🌿💾 Checkpoint salvo (melhor F1 da {MAIN_CLASS_NAME}).")
else:
no_imp_f1 += 1
else:
# se não houver F1 (ex: id não definido), ignora o critério
no_imp_f1 = 0
# ----- Scheduler inteligente -----
if active_sched == "cosine":
# se travar por plateau_patience, troca pra ReduceLROnPlateau
if no_imp_loss >= plateau_patience:
active_sched = "plateau"
print("🔁 Mudando scheduler: Cosine → ReduceLROnPlateau (platô detectado).")
# resets ao trocar
no_imp_loss = 0
no_imp_f1 = 0
epochs_since_switch = 0
plateau.step(avg_val_loss) # primeiro passo do plateau
# (opcional) “adiantar” a queda do LR:
for g in optimizer.param_groups:
g['lr'] = max(g['lr'] * plateau_factor, min_lr)
else:
cosine.step()
else:
plateau.step(avg_val_loss)
epochs_since_switch += 1
# ----- Log de estagnação -----
if no_imp_loss > 0 or no_imp_f1 > 0:
print(f"⏳ Sem melhora — loss: {no_imp_loss}/{patience_loss}, {MAIN_CLASS_NAME}: {no_imp_f1}/{patience_f1}")
# ----- Early stopping bi-critério (com 'graça' após switch) -----
if (no_imp_loss >= patience_loss and
(main_class_f1 is None or no_imp_f1 >= patience_f1) and
(active_sched == "cosine" or epochs_since_switch >= grace_after_switch)):
print("⏹ Early stopping: loss e F1 sem melhora (com período de graça respeitado).")
break
# ----- Plots periódicos -----
if epoch % 5 == 0 or epoch == args.epochs:
x_epochs = list(range(start_epoch, start_epoch + len(train_loss_history)))
# Loss
plt.figure()
plt.plot(x_epochs, train_loss_history, marker="o", label="Train Loss")
plt.plot(x_epochs, val_loss_history, marker="s", label="Val Loss")
plt.xlabel("Época"); plt.ylabel("Loss"); plt.grid(True); plt.legend(); plt.title("Curva de Loss")
plt.tight_layout()
plt.savefig(os.path.join(save_path, "loss_curve.png")); plt.close()
# LR
plt.figure()
plt.plot(x_epochs, lr_history, marker=".")
plt.xlabel("Época"); plt.ylabel("LR"); plt.grid(True); plt.title("Learning Rate")
plt.tight_layout()
plt.savefig(os.path.join(save_path, "lr_curve.png")); plt.close()
# mIoU e F1(erva)
plt.figure()
plt.plot(x_epochs, miou_history, marker="^", label="mIoU")
if len(f1_history) == len(miou_history):
plt.plot(x_epochs, f1_history, marker="*", label=f"F1 {MAIN_CLASS_NAME}")
plt.xlabel("Época"); plt.ylabel("Score"); plt.grid(True); plt.legend(); plt.title(f"mIoU / F1({MAIN_CLASS_NAME})")
plt.tight_layout()
plt.savefig(os.path.join(save_path, "metrics_curve.png")); plt.close()
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--epochs", type=int, default=30)
ap.add_argument("--lr", type=float, default=3e-4)
ap.add_argument("--amp", action="store_true")
ap.add_argument("--checkpoint", type=str, default=None, help="Caminho do modelo .pth para continuar o treinamento")
ap.add_argument("--ensure-per-batch", type=str, default="", help="Lista de classes por nome para garantir >=1 por batch. Ex.: 'erva,cana'")
ap.add_argument("--use-weights", action="store_true")
return ap.parse_args()
if __name__ == "__main__":
args = parse_args()
os.makedirs(save_path, exist_ok=True)
train(args)