agrobot_base/Python/OAK/datasets/_8_train_fastscnn_v2.py

1242 lines
61 KiB
Python
Raw Normal View History

2025-09-15 10:23:07 +00:00
# 👉 Treinador robusto para FastSCNN com ROI, pesos dinâmicos, Dice+CE, EMA, acumulação de gradiente e early-stop bicritério
# Compatível com a sua estrutura atual (FastSCNN, ROISegDataset, utils.compute_roi_indices / carregar_labelmap_completo)
# Principais melhorias vs _8_train_fastscnn.py:
# - Corrige bug do argumento --use-weights (antes "use_wights")
# - Usa de fato os pesos também no Dice (opcional, com normalização)
# - Semeadura determinística opcional + cudnn.benchmark para performance
# - Gradient clipping, acumulação de gradiente e AMP habilitáveis por CLI
# - EMA (Exponential Moving Average) do modelo para validação/checkout
# - Sampler que garante classes por batch (igual ao seu), mas com opções por CLI
# - Scheduler Cosine → Plateau com aquecimento (warmup) do LR e dos pesos das classes
# - Checkpoints por menor val_loss e por melhor F1 da classe principal
# - Plots e logs semelhantes, mantendo compatibilidade de caminhos
"""
python _8_train_fastscnn_v2.py --epochs 120 --batch 16 --accum 2 --workers 4 --amp --lr 3e-4 --warmup-epochs 5 --min-lr 1e-5 --use-weights --cw-alpha 1.1 --cw-erva-min 1.0 --cw-cana-max 2.2 --cw-chao-min 0.6 --dice-max 0.15 --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg macro --patience-es 12 --delta-es-min 2e-4 --plateau-patience 4 --ema --tversky-classes "cana" --tversky-weights "0.10" --tversky-alphas "0.8" --tversky-betas "0.2"
python _8_train_fastscnn_v2.py --epochs 8 --batch 16 --accum 2 --workers 4 --amp --lr 1.5e-4 --min-lr 1e-5 --warmup-epochs 1 --use-weights --cw-alpha 1.2 --cw-erva-min 1.8 --cw-cana-max 1.2 --cw-chao-min 0.6 --dice-max 0.12 --tversky-classes "erva,cana" --tversky-weights "0.40,0.08" --tversky-alphas "0.65,0.25" --tversky-betas "0.35,0.75" --ensure-per-batch "erva" --es-classes "erva,cana" --es-agg harmonic --patience-es 10 --delta-es-min 2e-4 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_f1_erva_checkpoint.pth
python _8_train_fastscnn_v2.py --epochs 12 --batch 16 --accum 2 --workers 4 --amp --lr 9e-5 --min-lr 6e-6 --warmup-epochs 0 --use-weights --cw-alpha 1.1 --cw-erva-min 1.6 --cw-cana-max 1.5 --cw-chao-min 0.6 --dice-max 0.12 --tversky-classes "erva,cana" --tversky-weights "0.32,0.14" --tversky-alphas "0.70,0.35" --tversky-betas "0.30,0.65" --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg harmonic --patience-es 10 --delta-es-min 1e-4 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_es_harmonic_erva_cana_checkpoint.pth
python _8_train_fastscnn_v2.py --epochs 12 --batch 16 --accum 2 --workers 4 --amp --lr 6e-5 --min-lr 5e-6 --warmup-epochs 0 --use-weights --cw-alpha 1.1 --cw-erva-min 1.5 --cw-cana-max 1.6 --cw-chao-min 0.6 --dice-max 0.12 --tversky-classes "erva,cana" --tversky-weights "0.30,0.14" --tversky-alphas "0.70,0.40" --tversky-betas "0.30,0.60" --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg macro --patience-es 10 --delta-es-min 1e-4 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_es_harmonic_erva_cana_checkpoint.pth
python _8_train_fastscnn_v2.py --epochs 10 --batch 16 --accum 2 --workers 4 --amp --lr 5e-5 --min-lr 5e-6 --warmup-epochs 0 --use-weights --cw-alpha 1.15 --cw-erva-min 1.7 --cw-cana-max 1.5 --cw-chao-min 0.55 --dice-max 0.16 --tversky-classes "erva,cana" --tversky-weights "0.34,0.12" --tversky-alphas "0.35,0.60" --tversky-betas "0.65,0.40" --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg macro --patience-es 10 --delta-es-min 1e-4 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_es_macro_erva_cana_checkpoint.pth
python _8_train_fastscnn_v2.py --epochs 10 --batch 16 --accum 2 --workers 4 --amp --lr 4e-5 --min-lr 4e-6 --warmup-epochs 0 --use-weights --cw-alpha 1.15 --cw-erva-min 1.9 --cw-cana-max 1.35 --cw-chao-min 0.55 --dice-max 0.20 --tversky-classes "erva,cana" --tversky-weights "0.36,0.10" --tversky-alphas "0.30,0.65" --tversky-betas "0.70,0.35" --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg macro --patience-es 10 --delta-es-min 1e-4 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_es_macro_erva_cana_checkpoint.pth
python _8_train_fastscnn_v2.py --epochs 8 --batch 16 --accum 2 --workers 4 --amp --lr 3e-5 --min-lr 3e-6 --warmup-epochs 0 --use-weights --cw-alpha 1.15 --cw-erva-min 2.0 --cw-cana-max 1.30 --cw-chao-min 0.55 --dice-max 0.22 --tversky-classes "erva,cana" --tversky-weights "0.38,0.09" --tversky-alphas "0.25,0.70" --tversky-betas "0.75,0.30" --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg macro --patience-es 10 --delta-es-min 1e-4 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_es_macro_erva_cana_checkpoint.pth
"""
# A partir do novo dataset
"""
python _8_train_fastscnn_v2.py --epochs 120 --batch 16 --accum 2 --workers 4 --amp --lr 1e-4 --min-lr 5e-6 --warmup-epochs 1 --use-weights --cw-alpha 1.15 --cw-erva-min 1.9 --cw-cana-max 1.35 --cw-chao-min 0.55 --dice-max 0.20 --tversky-classes "erva,cana" --tversky-weights "0.36,0.10" --tversky-alphas "0.30,0.65" --tversky-betas "0.70,0.35" --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg macro --patience-es 12 --delta-es-min 2e-4 --plateau-patience 3 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_es_macro_erva_cana_checkpoint_bkp.pth
python _8_train_fastscnn_v2.py --epochs 30 --batch 16 --accum 2 --workers 4 --amp --lr 7e-5 --min-lr 5e-6 --warmup-epochs 0 --use-weights --cw-alpha 1.12 --cw-erva-min 1.7 --cw-cana-max 1.6 --cw-chao-min 0.55 --dice-max 0.20 --tversky-classes "erva,cana" --tversky-weights "0.32,0.16" --tversky-alphas "0.35,0.45" --tversky-betas "0.65,0.55" --ensure-per-batch "erva,cana" --es-classes "erva,cana" --es-agg harmonic --patience-es 10 --delta-es-min 2e-4 --plateau-patience 2 --ema --checkpoint .\oak-1\backup\fast_scnn\ervas_es\ervas_es_best_es_macro_erva_cana_checkpoint_bkp.pth
"""
"""
python _8_train_fastscnn_v2.py --epochs 50 --batch 12 --accum 2 --workers 4 --amp --lr 1.0e-4 --min-lr 8e-6 --ensure-per-batch "erva,cana" --use-weights --cw-alpha 1.15 --tversky-classes "erva,cana" --tversky-weights "0.40,0.60" --tversky-alphas "0.35,0.35" --tversky-betas "0.65,0.65" --dice-max 0.15 --es-classes "erva,cana" --es-agg harmonic --patience-es 10 --delta-es-min 2e-4 --ema
python _8_train_fastscnn_v2.py --epochs 20 --batch 12 --accum 2 --workers 4 --amp --lr 1.2e-4 --min-lr 1e-5 --ensure-per-batch "cana" --use-weights --cw-alpha 1.35 --tversky-classes "cana" --tversky-weights "1.0" --tversky-alphas "0.20" --tversky-betas "0.80" --dice-max 0.10 --es-classes "cana" --es-agg max --patience-es 6 --delta-es-min 1e-4 --ema
python _8_train_fastscnn_v2.py --epochs 40 --batch 12 --accum 2 --workers 4 --amp --lr 1.2e-4 --min-lr 1e-5 --ensure-per-batch "cana" --use-weights --cw-alpha 1.60 --tversky-classes "cana" --tversky-weights "1.0" --tversky-alphas "0.10" --tversky-betas "0.90" --dice-max 0.10 --es-classes "cana" --es-agg max --patience-es 8 --delta-es-min 1e-4 --ema
python _8_train_fastscnn_v2.py --epochs 25 --batch 12 --accum 2 --workers 4 --amp --lr 8e-5 --min-lr 6e-6 --ensure-per-batch "erva,cana" --use-weights --cw-alpha 1.25 --tversky-classes "erva,cana" --tversky-weights "0.35,0.65" --tversky-alphas "0.25,0.20" --tversky-betas "0.75,0.80" --dice-max 0.12 --es-classes "erva,cana" --es-agg harmonic --patience-es 10 --delta-es-min 2e-4 --ema
"""
"""
python _8_train_fastscnn_v2.py --epochs 80 --batch 4 --accum 4 --workers 4 --lr 1e-4 --min-lr 1e-6 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.08 --cw-bgfloor 0.6 --dice-max 0.12 --warmup-epochs 5 --plateau-patience 6 --plateau-factor 0.5 --plateau-cooldown 1 --es-classes "erva,cana" --es-agg harmonic --delta-es-min 1e-4 --patience-es 8 --ensure-per-batch "erva,cana" --cudnn-benchmark --seed 42
python _8_train_fastscnn_v2.py --epochs 20 --batch 4 --accum 4 --workers 4 --lr 6e-5 --min-lr 6e-6 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.12 --cw-cana-max 6.0 --cw-bgfloor 0.45 --dice-max 0.25 --tversky-classes "cana,erva" --tversky-weights "1.10,0.05" --tversky-alphas "0.20,0.55" --tversky-betas "0.80,0.45" --tversky-in-val --ensure-per-batch "cana" --es-classes "cana" --es-agg macro --delta-es-min 2e-4 --patience-es 6 --warmup-epochs 0 --plateau-patience 2 --plateau-factor 0.5 --plateau-cooldown 1 --checkpoint
python _8_train_fastscnn_v2.py --epochs 12 --batch 4 --accum 4 --workers 4 --lr 4e-5 --min-lr 6e-6 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.14 --cw-cana-max 7.5 --cw-bgfloor 0.40 --dice-max 0.30 --tversky-classes "cana,erva" --tversky-weights "1.30,0.05" --tversky-alphas "0.15,0.55" --tversky-betas "0.85,0.45" --tversky-in-val --ensure-per-batch "cana" --es-classes "cana" --es-agg macro --delta-es-min 2e-4 --patience-es 6 --plateau-patience 2 --plateau-factor 0.5 --plateau-cooldown 1 --checkpoint
python _8_train_fastscnn_v2.py --epochs 18 --batch 4 --accum 4 --workers 4 --lr 2.5e-5 --min-lr 6e-6 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.18 --cw-cana-max 9.0 --cw-bgfloor 0.35 --dice-max 0.35 --tversky-classes "cana,erva" --tversky-weights "1.60,0.05" --tversky-alphas "0.10,0.55" --tversky-betas "0.90,0.45" --tversky-in-val --ensure-per-batch "cana" --es-classes "cana" --es-agg macro --delta-es-min 2e-4 --patience-es 8 --plateau-patience 2 --plateau-factor 0.6 --plateau-cooldown 1 --checkpoint
python _8_train_fastscnn_v2.py --epochs 12 --batch 4 --accum 4 --workers 4 --lr 1.8e-5 --min-lr 6e-6 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.20 --cw-cana-max 10.5 --cw-bgfloor 0.32 --dice-max 0.35 --tversky-classes "cana,erva" --tversky-weights "1.80,0.10" --tversky-alphas "0.08,0.55" --tversky-betas "0.92,0.45" --tversky-in-val --ensure-per-batch "cana,erva" --es-classes "cana" --es-agg macro --delta-es-min 2e-4 --patience-es 8 --plateau-patience 2 --plateau-factor 0.6 --plateau-cooldown 1 --checkpoint
python _8_train_fastscnn_v2.py --epochs 10 --batch 4 --accum 4 --workers 4 --lr 1.5e-5 --min-lr 6e-6 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.06 --cw-cana-max 6.0 --cw-bgfloor 0.52 --dice-max 0.25 --tversky-classes "cana,erva" --tversky-weights "1.00,0.20" --tversky-alphas "0.35,0.55" --tversky-betas "0.65,0.45" --tversky-in-val --ensure-per-batch "cana,erva" --es-classes "erva,cana" --es-agg harmonic --delta-es-min 2e-4 --patience-es 8 --plateau-patience 2 --plateau-factor 0.6 --plateau-cooldown 1 --checkpoint
python _8_train_fastscnn_v2.py --epochs 8 --batch 4 --accum 4 --workers 4 --lr 1.2e-5 --min-lr 6e-6 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.04 --cw-cana-max 5.5 --cw-bgfloor 0.50 --dice-max 0.22 --tversky-classes "cana,erva" --tversky-weights "1.00,0.25" --tversky-alphas "0.40,0.55" --tversky-betas "0.60,0.45" --tversky-in-val --ensure-per-batch "cana,erva" --es-classes "erva,cana" --es-agg harmonic --delta-es-min 2e-4 --patience-es 8 --plateau-patience 2 --plateau-factor 0.7 --plateau-cooldown 1 --checkpoint .\oak-1\backup\fast_scnn\ervas_res\ervas_res_best_es_harmonic_erva_cana_checkpoint.pth
python _8_train_fastscnn_v2.py --epochs 8 --batch 4 --accum 4 --workers 4 --lr 8.4e-06 --min-lr 6e-06 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.04 --cw-cana-max 5.8 --cw-bgfloor 0.50 --dice-max 0.22 --tversky-classes "cana,erva" --tversky-weights "1.10,0.25" --tversky-alphas "0.38,0.55" --tversky-betas "0.62,0.45" --tversky-in-val --ensure-per-batch "cana,erva" --es-classes "erva,cana" --es-agg harmonic --delta-es-min 2e-4 --patience-es 6 --plateau-patience 2 --plateau-factor 0.7 --plateau-cooldown 1 --checkpoint .\oak-1\backup\fast_scnn\ervas_res\ervas_res_best_es_harmonic_erva_cana_checkpoint.pth
python _8_train_fastscnn_v2.py --epochs 6 --batch 4 --accum 4 --workers 4 --lr 7.5e-06 --min-lr 6e-06 --amp --ema --ema-decay 0.999 --use-weights --cw-alpha 1.04 --cw-cana-max 5.5 --cw-bgfloor 0.53 --dice-max 0.22 --tversky-classes "cana,erva" --tversky-weights "1.00,0.25" --tversky-alphas "0.46,0.55" --tversky-betas "0.54,0.45" --tversky-in-val --ensure-per-batch "cana,erva" --es-classes "erva,cana" --es-agg harmonic --delta-es-min 2e-4 --patience-es 6 --plateau-patience 2 --plateau-factor 0.7 --plateau-cooldown 1 --checkpoint .\oak-1\backup\fast_scnn\ervas_res\ervas_res_best_es_harmonic_erva_cana_checkpoint.pth
"""
import itertools
import math
import os
import random
os.environ["MPLBACKEND"] = "Agg" # segurança extra
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.ioff()
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 collections import OrderedDict
from fast_scnn import FastSCNN
from roi_seg_dataset import ROISegDataset
from utils import carregar_labelmap_completo, compute_roi_indices
# =============================
# Utilidades
# =============================
def set_seed(seed: int = 42):
if seed is None:
return
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
def maybe_benchmark(use_benchmark: bool = True):
# Em GPUs modernas, benchmark True acelera convs com shapes fixos
torch.backends.cudnn.benchmark = bool(use_benchmark)
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
@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 (linhas = GT, colunas = Pred)
cm = confmat.float()
eps = 1e-7
tp = torch.diag(cm)
fp = cm.sum(0) - tp
fn = cm.sum(1) - tp
# Por-classe
precision_per_class = tp / (tp + fp + eps)
recall_per_class = tp / (tp + fn + eps)
f1_per_class = 2 * precision_per_class * recall_per_class / (precision_per_class + recall_per_class + eps)
# IoU por classe
denom_iou = tp + fp + fn + eps
iou_per_class = tp / denom_iou
# mIoU: média só das classes presentes no GT deste batch (tp+fn > 0)
present_mask = (tp + fn) > 0
miou = iou_per_class[present_mask].mean().item() if present_mask.any() else 0.0
# Pixel accuracy (micro)
pix_acc = tp.sum() / (cm.sum() + eps)
# Métricas da classe principal (se pedida)
main_class_metrics = None
if main_class_id is not None and 0 <= main_class_id < cm.shape[0]:
main_class_metrics = {
"precision": precision_per_class[main_class_id].item(),
"recall": recall_per_class[main_class_id].item(),
"f1": f1_per_class[main_class_id].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(),
"precision_per_class": precision_per_class.cpu().tolist(),
"recall_per_class": recall_per_class.cpu().tolist(),
"f1_per_class": f1_per_class.cpu().tolist(),
"tp_per_class": tp.cpu().tolist(),
"fp_per_class": fp.cpu().tolist(),
"fn_per_class": fn.cpu().tolist(),
"present_mask": present_mask.cpu().tolist(), # opcional, ajuda a depurar
"main_class": main_class_metrics,
}
def _id_by_name(d, name):
name = str(name).lower()
if isinstance(d, dict):
for cid, nm in d.items():
if isinstance(nm, str) and name in nm.lower():
return cid
elif isinstance(d, (list, tuple)):
for i, nm in enumerate(d):
if isinstance(nm, str) and name in nm.lower():
return i
return None
def _ids_by_names(CLASS_NAMES, names_csv):
names = _parse_csv_strs(names_csv)
norm = _normalize_class_names(CLASS_NAMES, len(CLASS_NAMES))
ids = []
for nm in names:
cid = _id_by_name(norm, nm)
if cid is not None:
ids.append(cid)
return ids
def _aggregate(vals, mode="macro"):
vals = [float(v) for v in vals if v is not None]
if not vals: return None
if mode == "macro":
return sum(vals) / len(vals)
if mode == "min":
return min(vals)
if mode == "harmonic":
eps = 1e-8
return len(vals) / sum(1.0 / (v + eps) for v in vals)
return sum(vals) / len(vals)
def _to_list(x):
if x is None:
return None
if torch.is_tensor(x):
return x.detach().cpu().tolist()
return list(x)
def _normalize_class_names(class_names, n_classes):
"""
Aceita:
- list/tuple -> usa direto (ajusta tamanho se preciso)
- dict id->name (chaves int) -> ordena por id
- dict name->id (chaves str) -> inverte p/ lista por id
- None -> ['0','1',...]
"""
if class_names is None:
return [str(i) for i in range(n_classes)]
# já é lista/tupla
if isinstance(class_names, (list, tuple)):
names = list(class_names)
# ajusta tamanho se vier curto/long
if len(names) < n_classes:
names += [f"{len(names)+i}" for i in range(n_classes - len(names))]
return names[:n_classes]
# é dict
if isinstance(class_names, dict):
# id->name
if all(isinstance(k, int) for k in class_names.keys()):
return [class_names.get(i, str(i)) for i in range(n_classes)]
# name->id
names = [f"{i}" for i in range(n_classes)]
for name, idx in class_names.items():
if isinstance(idx, int) and 0 <= idx < n_classes:
names[idx] = str(name)
return names
# fallback
return [str(i) for i in range(n_classes)]
def _format_all_classes(m, class_names, main_idx):
# pega vetores
f1s = _to_list(m.get('f1_per_class'))
ious = _to_list(m.get('iou_per_class'))
precs = _to_list(m.get('precision_per_class'))
recs = _to_list(m.get('recall_per_class'))
tps = _to_list(m.get('tp_per_class'))
fps = _to_list(m.get('fp_per_class'))
fns = _to_list(m.get('fn_per_class'))
# descobre n_classes
lens = [len(x) for x in [f1s, ious, precs, recs, tps, fps, fns] if x is not None]
n = max(lens) if lens else 0
names = _normalize_class_names(class_names, n)
# totais p/ shares (usa total de pixels de GT como base)
total_gt = 0.0
if tps is not None and fns is not None:
total_gt = float(sum((tp + fn) for tp, fn in zip(tps, fns)))
total_gt = total_gt if total_gt > 0 else 1.0
parts = []
for i in range(n):
name = names[i]
tag = "🌿" if (main_idx is not None and i == main_idx) else ""
f1 = f1s[i] if (f1s is not None and i < len(f1s)) else None
iou = ious[i] if (ious is not None and i < len(ious)) else None
pr = precs[i] if (precs is not None and i < len(precs)) else None
rc = recs[i] if (recs is not None and i < len(recs)) else None
tp = tps[i] if (tps is not None and i < len(tps)) else None
fp = fps[i] if (fps is not None and i < len(fps)) else None
fn = fns[i] if (fns is not None and i < len(fns)) else None
# shares (% de pixels GT dessa classe e % preditos nessa classe)
gt_share = ((tp + fn) / total_gt) if (tp is not None and fn is not None) else None
pred_share = ((tp + fp) / total_gt) if (tp is not None and fp is not None) else None
def _fmt(x, nd=4):
try:
if x is None or (isinstance(x, float) and (math.isnan(x) or math.isinf(x))):
return ""
return f"{float(x):.{nd}f}"
except Exception:
return ""
s = (f"{tag}{name}: "
f"F1={_fmt(f1)} IoU={_fmt(iou)} "
f"P={_fmt(pr)} R={_fmt(rc)} "
f"GT%={_fmt(100.0*gt_share, nd=1)} Pred%={_fmt(100.0*pred_share, nd=1)}")
parts.append(s)
return " | " + " ".join(parts)
def _parse_csv_floats(s):
if not s:
return []
out = []
for tok in s.split(","):
t = tok.strip()
if t:
out.append(float(t))
return out
def _parse_csv_strs(s):
if not s:
return []
return [tok.strip() for tok in s.split(",") if tok.strip()]
def tversky_loss_one(logits, target, class_id, alpha=0.5, beta=0.5, ignore_index=255, eps=1e-6):
"""
Tversky para UMA classe: 1 - TP / (TP + α*FP + β*FN)
logits: [N,C,H,W], target: [N,H,W]
"""
N, C, H, W = logits.shape
# prob da classe
p = torch.softmax(logits, dim=1)[:, class_id, ...] # [N,H,W]
valid = (target != ignore_index)
tgt = (target == class_id).float()
p = p * valid
tgt = tgt * valid
tp = (p * tgt).sum()
fp = (p * (1.0 - tgt)).sum()
fn = ((1.0 - p) * tgt).sum()
ti = tp / (tp + alpha * fp + beta * fn + eps)
return 1.0 - ti
def build_tversky_cfg(args, class_names):
"""
args e cria uma lista de dicts:
[{"name":str, "id":int, "weight":float, "alpha":float, "beta":float}, ...]
Suporta broadcast quando weights/alphas/betas têm 1 elemento.
"""
cls_names = _parse_csv_strs(args.tversky_classes)
if not cls_names:
return []
ws = _parse_csv_floats(args.tversky_weights) or [0.2] # default weight
al = _parse_csv_floats(args.tversky_alphas) or [0.5]
be = _parse_csv_floats(args.tversky_betas) or [0.5]
def bcast(v, n):
return v * n if len(v) == 1 else v
ws = bcast(ws, len(cls_names))
al = bcast(al, len(cls_names))
be = bcast(be, len(cls_names))
if not (len(ws) == len(al) == len(be) == len(cls_names)):
raise ValueError(f"Comprimentos inconsistentes: classes={len(cls_names)} "
f"weights={len(ws)} alphas={len(al)} betas={len(be)}")
norm_names = _normalize_class_names(class_names, len(class_names))
cfg = []
for name, w, a, b in zip(cls_names, ws, al, be):
cid = _id_by_name(norm_names, name)
if cid is None:
print(f"⚠️ Tversky: classe '{name}' não encontrada nas classes do dataset — ignorando.")
continue
if w <= 0:
# peso zero -> silenciosamente ignora
continue
cfg.append({"name": name, "id": int(cid),
"weight": float(w), "alpha": float(a), "beta": float(b)})
return cfg
def _merge_logits_eq_rest(logits, eq_ids, rest_ids):
# logits: [N,C,H,W]
l_eq = torch.logsumexp(logits[:, eq_ids, ...], dim=1, keepdim=True)
l_rest = torch.logsumexp(logits[:, rest_ids, ...], dim=1, keepdim=True)
return torch.cat([l_eq, l_rest], dim=1) # [N,2,H,W]
def _make_y_bin(y, eq_ids, ignore_id):
# 0 = EQ, 1 = REST; preserva ignore_id
y_bin = torch.full_like(y, fill_value=1)
for cid in eq_ids:
y_bin = torch.where(y == cid, torch.zeros_like(y_bin), y_bin)
if ignore_id is not None:
y_bin = torch.where(y == ignore_id, torch.tensor(ignore_id, device=y.device, dtype=y.dtype), y_bin)
return y_bin
def _dice_from_probs(p, t, mask=None, eps=1e-6):
# p,t: [N,H,W] em {0..1}; mask opcional booleana
if mask is not None:
p = p[mask]; t = t[mask]
inter = (p * t).sum()
denom = p.sum() + t.sum()
return 1.0 - (2*inter + eps) / (denom + eps) # dice loss
def _get_mask_roi_from_ds(ds, i, roi_inicio, roi_tamanho):
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:
_, y = ds[i]
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: [] 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 requerida por batch."""
def __init__(self, presence, total_indices, batch_size, required_classes, seed=42):
self.presence = presence
self.required = [c for c in required_classes if len(presence.get(c, [])) > 0]
self.batch_size = batch_size
self.iters = {c: itertools.cycle(self.presence[c]) for c in self.required}
self.rest_iter = itertools.cycle(list(total_indices))
self.rng = random.Random(seed)
self._length = max(1, int(np.ceil(len(total_indices) / float(batch_size))))
def __iter__(self):
for _ in range(self._length):
batch = []
for c in self.required:
batch.append(next(self.iters[c]))
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 compute_class_weights_from_split(
train_split_root: str,
labelmap_path: str,
roi_inicio: float,
roi_tamanho: float,
*,
alpha: float = 1.2,
w_min: float = 0.3,
w_max: float = 4.0,
floor_bg: float = 0.4,
max_samples_per_group: int = 300,
):
"""
Calcula pesos dinâmicos (median freq ^ alpha) SOBRE A ROI das máscaras do split/train.
"""
_, _, classes, ignore_rgb = carregar_labelmap_completo(labelmap_path)
ignore_id = _infer_ignore_id(ignore_rgb, default_id=255)
class_ids = sorted(classes.keys())
counts = np.zeros(len(class_ids), dtype=np.int64)
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:
mdir = os.path.join(train_split_root, "masks")
if os.path.isdir(mdir):
group_dirs.append(mdir)
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, :]
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:
print("⚠️ compute_class_weights_from_split: não encontrei pixels válidos; usando pesos [1,1,...].")
return None, None
freqs = counts / total
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:
w = (base / f) ** alpha
w = float(np.clip(w, w_min, w_max))
weights_arr[i] = w
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)
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]
pretty = {int(cid): (str(classes[cid]), float(weights_full[cid]), float(freqs[i]))
for i, cid in enumerate(class_ids)}
return weights_full, pretty
class ModelEMA:
"""EMA dos pesos do modelo (independente da arquitetura)."""
def __init__(self, model: nn.Module, decay: float = 0.999):
import copy
self.decay = decay
device = next(model.parameters()).device
self.ema = copy.deepcopy(model).to(device)
self.ema.eval()
for p in self.ema.parameters():
p.requires_grad_(False)
@torch.no_grad()
def update(self, model: nn.Module):
d = self.decay
msd = model.state_dict()
for k, v_ema in self.ema.state_dict().items():
v_model = msd[k].detach()
v_ema.copy_(v_ema * d + (1. - d) * v_model)
def dice_loss(logits, target, ignore_index=255, class_weights=None, eps=1e-6):
N, C, H, W = logits.shape
pred = F.softmax(logits, dim=1)
valid = (target != ignore_index)
target_clamped = torch.clamp(target, 0, C-1)
one_hot = torch.zeros((N, C, H, W), device=logits.device, dtype=pred.dtype)
one_hot.scatter_(1, target_clamped.unsqueeze(1), 1.0)
valid = valid.unsqueeze(1)
pred = pred * valid
one_hot = one_hot * valid
inter = (pred * one_hot).sum(dim=(0, 2, 3))
pred_sum = pred.sum(dim=(0, 2, 3))
tgt_sum = one_hot.sum(dim=(0, 2, 3))
dice = (2 * inter + eps) / (pred_sum + tgt_sum + eps)
if class_weights is not None:
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
def _safe_torch_load(path, map_location, try_weights_only=False):
try:
return torch.load(path, map_location=map_location, weights_only=try_weights_only)
except TypeError:
# versões de torch que não têm weights_only
return torch.load(path, map_location=map_location)
# =============================
# Treino principal
# =============================
def train(args):
set_seed(args.seed)
maybe_benchmark(args.cudnn_benchmark)
# Config base do seu ambiente
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 = str(config.get("main_class_name", "erva")).lower()
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")
os.makedirs(save_path, exist_ok=True)
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
)
CLASS_NAMES = getattr(ds_train, "classes", None) # pode ser list OU dict
ES_CLASS_IDS = _ids_by_names(CLASS_NAMES, args.es_classes) # ex.: ids de ['erva','cana']
if not ES_CLASS_IDS:
print("⚠️ ES: nenhuma classe válida encontrada; ES será ignorado.")
best_es = -1.0
no_imp_es = 0
TV_CFG = build_tversky_cfg(args, CLASS_NAMES)
if TV_CFG:
print({"tversky_cfg": TV_CFG})
else:
print("Tversky OFF (sem classes ou pesos <= 0)")
eq_ids = None
rest_ids = None
if args.eq_classes:
eq_names = [s.strip() for s in args.eq_classes.split(',') if s.strip()]
norm_names = _normalize_class_names(CLASS_NAMES, len(CLASS_NAMES))
eq_ids = sorted([_id_by_name(norm_names, nm) for nm in eq_names if _id_by_name(norm_names, nm) is not None])
# REST = todas as classes válidas que NÃO estão no grupo eq (exc. ignore_id)
rest_ids = [i for i in range(len(CLASS_NAMES)) if (i not in eq_ids)]
# id->nome (preferir do dataset; fallback 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:
_, _, id_to_name, _ = carregar_labelmap_completo(labelmap_path)
id_to_name = {int(k): str(v) for k, v in id_to_name.items()}
name_to_id = {v.strip().lower(): k for k, v in id_to_name.items()}
# Classe principal
main_class_id = _id_by_name(id_to_name, MAIN_CLASS_NAME)
if main_class_id is not None:
print(f"🌿 Classe PRIMARIA: id={main_class_id}, nome='{id_to_name[main_class_id]}'")
else:
print("⚠️ Classe PRIMARIA não encontrada; F1 específico será ignorado.")
# ----- Sampler ensure-per-batch (opcional) -----
req_ids = []
if args.ensure_per_batch:
for nm in [s.strip().lower() for s in args.ensure_per_batch.split(",") if s.strip()]:
cid = name_to_id.get(nm)
if cid is None:
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=args.batch,
required_classes=req_ids,
seed=args.seed or 42,
)
dl_train = DataLoader(ds_train, batch_sampler=batch_sampler,
num_workers=args.workers, pin_memory=True,
persistent_workers=bool(args.workers > 0))
else:
dl_train = DataLoader(ds_train, batch_size=args.batch, shuffle=True,
num_workers=args.workers, pin_memory=True,
persistent_workers=bool(args.workers > 0))
dl_val = DataLoader(ds_val, batch_size=args.batch, shuffle=False,
num_workers=args.workers, pin_memory=True,
persistent_workers=bool(args.workers > 0))
num_classes = len(ds_train.classes)
ignore_id = getattr(ds_train, "ignore_id", 255)
# ================= Modelo / Opt / LR =================
model = FastSCNN(num_classes=num_classes).to(device)
if args.freeze_bn:
for m in model.modules():
if isinstance(m, (nn.BatchNorm2d, nn.SyncBatchNorm)):
m.eval()
for p in m.parameters():
p.requires_grad_(False)
print("🧊 BatchNorm congelado.")
optimizer = optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
# Warmup do LR (linear nos primeiros warmup_epochs)
def lr_warmup_factor(epoch):
if args.warmup_epochs <= 0:
return 1.0
return min(1.0, max(0.0, epoch / float(args.warmup_epochs)))
from torch import amp
scaler = amp.GradScaler('cuda', enabled=args.amp)
# ================= Pesos por classe (ROI) =================
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=args.cw_alpha, w_min=args.cw_min, w_max=args.cw_max,
floor_bg=args.cw_bgfloor, max_samples_per_group=args.cw_max_per_group,
)
if weights_np is None or not args.use_weights:
weights_t = None
print("⚠️ Pesos dinâmicos indisponíveis ou desabilitados; usando CE sem pesos.")
else:
import pprint
pprint.pprint({"class_weights": debug_info})
weights_t = torch.tensor(weights_np, device=device)
# Warmup dos pesos de classe (λ 0→1) + clamps amigos
ones = torch.ones_like(weights_t) if weights_t is not None else None
def make_epoch_weights(epoch):
if weights_t is None:
return None, None
cw_lambda = min(1.0, max(0.0, (epoch - 1) / max(1, args.cw_warmup)))
w_epoch = (1.0 - cw_lambda) * ones + cw_lambda * weights_t
# clamps opcionais (antes da normalização do Dice)
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")
if erva_id is not None:
w_epoch[erva_id] = torch.clamp(w_epoch[erva_id], min=args.cw_erva_min)
if cana_id is not None:
w_epoch[cana_id] = torch.clamp(w_epoch[cana_id], max=args.cw_cana_max)
if chao_id is not None:
w_epoch[chao_id] = torch.clamp(w_epoch[chao_id], min=args.cw_chao_min)
dice_w = (w_epoch / w_epoch.mean()).detach()
return w_epoch, dice_w
# EMA opcional
ema = ModelEMA(model, decay=args.ema_decay) if args.ema else None
# ================= Loop =================
start_epoch = 1
num_epochs = args.epochs
best_val_loss = float("inf")
best_main_class_f1 = -1.0
train_loss_history, val_loss_history, lr_history = [], [], []
f1_history, miou_history = [], []
no_imp_loss = no_imp_f1 = 0
epochs_since_switch = 0
# Recarregar checkpoint opcional
if args.checkpoint and os.path.exists(args.checkpoint):
print(f"🔁 Carregando: {args.checkpoint}")
ckpt = _safe_torch_load(args.checkpoint, map_location=device, try_weights_only=False)
def _is_plain_statedict(obj):
if isinstance(obj, (dict, OrderedDict)):
# heurística: parece um state_dict de modelo (tensores nas folhas)
return any(k.endswith(".weight") or k.endswith(".bias") for k in obj.keys())
return False
start_epoch = 1
num_epochs = args.epochs
if _is_plain_statedict(ckpt):
model.load_state_dict(ckpt, strict=True)
# recomeça “limpo”: novo optimizer com LR do args
for g in optimizer.param_groups:
g['lr'] = args.lr
print("🔁 Iniciei do state_dict (ex: *_best_f1_erva.pth).")
elif isinstance(ckpt, dict) and "model" in ckpt:
model.load_state_dict(ckpt["model"], strict=True)
# se quiser retomar “igual”, descomente as duas linhas abaixo:
# optimizer.load_state_dict(ckpt.get("optimizer", optimizer.state_dict()))
# scaler.load_state_dict(ckpt.get("scaler", scaler.state_dict()))
# mas como é fine-tune, geralmente é melhor resetar o otimizador:
for g in optimizer.param_groups:
g['lr'] = args.lr
start_epoch = ckpt.get("epoch", 0) + 1 # não é obrigatório usar
num_epochs = start_epoch + args.epochs
# se você usa EMA e quer começar a partir do EMA salvo:
if args.ema and "ema" in ckpt:
ema.ema.load_state_dict(ckpt["ema"])
if "best_val_loss" in ckpt:
best_val_loss = ckpt["best_val_loss"]
if "best_main_class_f1" in ckpt:
best_main_class_f1 = ckpt["best_main_class_f1"]
if "best_es" in ckpt:
best_es = ckpt["best_es"]
print("🔁 Iniciei do checkpoint completo (ex: *_best_checkpoint.pth).")
else:
print("⚠️ Formato de checkpoint não reconhecido; seguindo sem carregar pesos.")
cosine = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs, eta_min=args.min_lr)
plateau = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode="min", factor=args.plateau_factor,
patience=args.plateau_patience, cooldown=args.plateau_cooldown,
min_lr=args.min_lr, verbose=True
)
active_sched = "cosine"
# mistura Dice/CE crescente 0→dice_max nas primeiras 10 épocas
dice_max = args.dice_max
for epoch in range(start_epoch, num_epochs + 1):
t0 = time.time()
model.train()
# Ajuste de LR warmup (manual multiplicativo)
if args.warmup_epochs > 0 and epoch <= args.warmup_epochs:
wf = epoch / float(args.warmup_epochs)
for g in optimizer.param_groups:
g['lr'] = args.lr * wf
# Pesos deste epoch
w_epoch, dice_w = make_epoch_weights(epoch)
ce = nn.CrossEntropyLoss(ignore_index=ignore_id, weight=w_epoch)
# mistura de perdas
dice_mix = min(dice_max, (epoch-1)/10.0 * dice_max)
ce_mix = 1.0 - dice_mix
running_train_loss = 0.0
optimizer.zero_grad(set_to_none=True)
for step, (x, y) in enumerate(dl_train, start=1):
x, y = x.to(device), y.to(device)
with torch.amp.autocast('cuda', enabled=args.amp):
logits = model(x)
if eq_ids is not None:
# ===== FASE 1: perda binária EQ vs REST =====
logits2 = _merge_logits_eq_rest(logits, eq_ids, rest_ids) # [N,2,H,W]
y_bin = _make_y_bin(y, eq_ids, ignore_id)
# CE binária sobre logits2
ce_val = F.cross_entropy(logits2, y_bin, ignore_index=ignore_id)
# Dice (ou Tversky) sobre probabilidade somada da EQ
if dice_mix > 0:
p_eq = logits2.softmax(1)[:, 0, ...] # prob da EQ
t_eq = (y_bin == 0).float()
# máscara válida (ignora pixels ignore_id)
mask_valid = (y != ignore_id) if ignore_id is not None else None
dloss = _dice_from_probs(p_eq, t_eq, mask_valid)
d_val = dloss
else:
d_val = 0.0
tv_sum = 0.0 # (opcional: implementar tversky_from_probs aqui)
else:
ce_val = ce(logits, y)
dloss = dice_loss(logits, y, ignore_index=ignore_id, class_weights=dice_w)
d_val = dloss if dice_mix > 0 else 0.0
tv_sum = 0.0
for itv in TV_CFG:
tv_sum = tv_sum + itv["weight"] * tversky_loss_one(
logits, y, itv["id"],
alpha=itv["alpha"], beta=itv["beta"], ignore_index=ignore_id
)
loss = ce_mix * ce_val + dice_mix * d_val + tv_sum
scaler.scale(loss).backward()
if step % args.accum == 0:
if args.grad_clip > 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
if ema:
ema.update(model)
running_train_loss += loss.item() * x.size(0) * max(1, args.accum)
avg_train_loss = running_train_loss / len(ds_train)
train_loss_history.append(avg_train_loss)
# ================= Validação =================
def evaluate(mdl: nn.Module, eq_ids):
mdl.eval()
running_val_loss = 0.0
confmat = torch.zeros((num_classes, num_classes), dtype=torch.int64, device=device)
# extra: confusão 2x2 VEG vs REST (somente se eq_ids)
conf_veg = torch.zeros((2,2), dtype=torch.int64, device=device) if eq_ids is not None else None
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 = mdl(x)
if eq_ids is not None:
logits2 = _merge_logits_eq_rest(logits, eq_ids, rest_ids)
y_bin = _make_y_bin(y, eq_ids, ignore_id)
ce_val = F.cross_entropy(logits2, y_bin, ignore_index=ignore_id)
if dice_mix > 0:
p_eq = logits2.softmax(1)[:, 0, ...]
t_eq = (y_bin == 0).float()
mask_valid = (y != ignore_id) if ignore_id is not None else None
d_val = _dice_from_probs(p_eq, t_eq, mask_valid)
else:
d_val = 0.0
tv_sum = 0.0
loss = ce_mix * ce_val + dice_mix * d_val + tv_sum
else:
ce_val = ce(logits, y)
dloss = dice_loss(logits, y, ignore_index=ignore_id, class_weights=dice_w)
d_val = dloss if dice_mix > 0 else 0.0
tv_sum = 0.0
if args.tversky_in_val and TV_CFG:
for itv in TV_CFG:
tv_sum = tv_sum + itv["weight"] * tversky_loss_one(
logits, y, itv["id"],
alpha=itv["alpha"], beta=itv["beta"], ignore_index=ignore_id
)
loss = ce_mix * ce_val + dice_mix * d_val + tv_sum
running_val_loss += loss.item() * x.size(0)
pred = logits.argmax(1)
confmat = confmat_update(confmat, pred, y, num_classes, ignore_index=ignore_id)
# ==== Métrica da UNIÃO (veg = erva cana) ====
if args.eq_classes:
# ids das classes do grupo (ex.: erva,cana)
eq_names = [s.strip() for s in args.eq_classes.split(",") if s.strip()]
cls_norm = _normalize_class_names(CLASS_NAMES, len(CLASS_NAMES))
eq_ids = [_id_by_name(cls_norm, n) for n in eq_names]
valid = (y != ignore_id)
# GT: 1 se for qualquer classe do grupo, 0 caso contrário
y_veg = torch.zeros_like(y, dtype=torch.bool)
for cid in eq_ids:
y_veg |= (y == cid)
y_veg &= valid
# Pred: idem, 1 se previu qualquer classe do grupo
p_veg = torch.zeros_like(pred, dtype=torch.bool)
for cid in eq_ids:
p_veg |= (pred == cid)
p_veg &= valid
# compacta só os válidos para evitar contar ignore como negativo
yb = y_veg[valid].long()
pb = p_veg[valid].long()
idx = yb * 2 + pb # [0..3] = (TN, FP, FN, TP) na ordem abaixo
b = torch.bincount(idx.view(-1), minlength=4)
# acumula em um confusion 2x2 da união ao longo dos batches
# crie antes do loop: conf_veg = torch.zeros((2,2), dtype=torch.long, device=device)
conf_veg[0,0] += b[0] # TN
conf_veg[0,1] += b[1] # FP
conf_veg[1,0] += b[2] # FN
conf_veg[1,1] += b[3] # TP
avg_val = running_val_loss / len(ds_val)
mets = metrics_from_confmat(confmat, main_class_id)
if args.eq_classes:
TP = conf_veg[1,1].float(); FP = conf_veg[0,1].float()
FN = conf_veg[1,0].float()
prec = TP / (TP + FP + 1e-9)
rec = TP / (TP + FN + 1e-9)
f1 = 2 * prec * rec / (prec + rec + 1e-9)
iou = TP / (TP + FP + FN + 1e-9)
mets["veg_union"] = {"precision": float(prec), "recall": float(rec), "f1": float(f1), "iou": float(iou)}
return avg_val, mets
# avalia com EMA se disponível
if ema:
# usa os pesos do EMA para validação
avg_val_loss, m = evaluate(ema.ema, eq_ids)
else:
avg_val_loss, m = evaluate(model, eq_ids)
# ===== Métricas pós-eval =====
val_loss_history.append(avg_val_loss)
miou_history.append(m["miou"])
cur_lr = optimizer.param_groups[0]['lr']
lr_history.append(cur_lr)
# F1 principal (se ainda quiser logar)
main_class_f1 = m["main_class"]["f1"] if (m.get("main_class") is not None) else None
if main_class_f1 is not None:
f1_history.append(main_class_f1)
# ES metric (agregado nas classes escolhidas)
es_metric = None
if args.eq_classes and m.get("veg_union"):
es_metric = float(m["veg_union"]["f1"])
elif ES_CLASS_IDS and "f1_per_class" in m and m["f1_per_class"] is not None:
es_vals = [float(m["f1_per_class"][cid]) for cid in ES_CLASS_IDS if cid < len(m["f1_per_class"])]
es_metric = _aggregate(es_vals, mode=args.es_agg)
elapsed = time.time() - t0
# ===== LOG =====
MAIN_IDX = _id_by_name(_normalize_class_names(CLASS_NAMES, len(CLASS_NAMES)), MAIN_CLASS_NAME) if CLASS_NAMES else None
msg = (f"[{epoch}/{num_epochs}] train_loss={avg_train_loss:.4f} "
f"val_loss={avg_val_loss:.4f} mIoU={m['miou']:.4f} "
f"pixAcc={m['pixel_acc']:.4f} lr={cur_lr:.2e} time={elapsed:.1f}s")
if CLASS_NAMES is not None:
msg += _format_all_classes(m, CLASS_NAMES, MAIN_IDX)
if es_metric is not None:
# mostra o valor agregado e os f1s das classes do ES
cls_names = _normalize_class_names(CLASS_NAMES, len(CLASS_NAMES))
es_names = [cls_names[i] for i in ES_CLASS_IDS]
es_vals_str = ", ".join([f"{cls_names[i]}={m['f1_per_class'][i]:.4f}" for i in ES_CLASS_IDS])
msg += f" || ES[{args.es_agg}:{','.join(es_names)}]={es_metric:.4f} ({es_vals_str})"
if args.eq_classes and "veg_union" in m:
vu = m["veg_union"]
msg += f" || VEG(uniao): F1={vu['f1']:.4f} IoU={vu['iou']:.4f} P={vu['precision']:.4f} R={vu['recall']:.4f}"
print(msg)
# ===== Checkpoints =====
improved_loss = avg_val_loss < best_val_loss - 1e-6
if improved_loss:
best_val_loss = avg_val_loss
no_imp_loss = 0
tosave = model.state_dict()
payload = {
"model": tosave,
"optimizer": optimizer.state_dict(),
"scaler": scaler.state_dict() if 'scaler' in locals() else None,
"epoch": epoch,
"best_val_loss": best_val_loss,
"best_main_class_f1": best_main_class_f1,
"best_es": best_es,
}
if ema: payload["ema"] = ema.ema.state_dict()
torch.save(tosave, os.path.join(save_path, f"{MODEL_NAME}_best.pth"))
torch.save(payload, os.path.join(save_path, f"{MODEL_NAME}_best_checkpoint.pth"))
print("✅ Novo melhor modelo salvo (val_loss).")
else:
no_imp_loss += 1
# checkpoint por ES (macro/min/harmonic de erva+cana)
if es_metric is not None and (es_metric > best_es + args.delta_es_min):
best_es = es_metric
no_imp_es = 0
tosave = model.state_dict()
payload = {
"model": tosave,
"optimizer": optimizer.state_dict(),
"scaler": scaler.state_dict() if 'scaler' in locals() else None,
"epoch": epoch,
"best_val_loss": best_val_loss,
"best_main_class_f1": best_main_class_f1,
"best_es": best_es,
"es_agg": args.es_agg,
"es_classes": args.es_classes,
}
if ema: payload["ema"] = ema.ema.state_dict()
tag = f"best_es_{args.es_agg}_{args.es_classes.replace(',','_')}"
torch.save(tosave, os.path.join(save_path, f"{MODEL_NAME}_{tag}.pth"))
torch.save(payload, os.path.join(save_path, f"{MODEL_NAME}_{tag}_checkpoint.pth"))
print(f"💾 Checkpoint salvo (melhor ES={best_es:.4f} [{args.es_agg}] em {args.es_classes}).")
else:
no_imp_es += 1
# (opcional) ainda manter checkpoint por F1 da classe principal:
if main_class_f1 is not None:
if main_class_f1 > best_main_class_f1 + args.delta_f1_min:
best_main_class_f1 = main_class_f1
no_imp_f1 = 0
tosave = model.state_dict()
payload = {
"model": tosave,
"optimizer": optimizer.state_dict(),
"scaler": scaler.state_dict() if 'scaler' in locals() else None,
"epoch": epoch,
"best_val_loss": best_val_loss,
"best_main_class_f1": best_main_class_f1,
"best_es": best_es,
}
if ema: payload["ema"] = ema.ema.state_dict()
torch.save(tosave, os.path.join(save_path, f"{MODEL_NAME}_best_f1_{MAIN_CLASS_NAME}.pth"))
torch.save(payload, os.path.join(save_path, f"{MODEL_NAME}_best_f1_{MAIN_CLASS_NAME}_checkpoint.pth"))
print(f"🌿💾 Checkpoint salvo (melhor F1 da {MAIN_CLASS_NAME}).")
else:
no_imp_f1 += 1
else:
no_imp_f1 = 0
# ===== Scheduler =====
if active_sched == "cosine":
if no_imp_loss >= args.plateau_patience:
active_sched = "plateau"
print("🔁 Mudando scheduler: Cosine → ReduceLROnPlateau (platô detectado).")
no_imp_loss = 0; no_imp_f1 = 0; no_imp_es = 0
epochs_since_switch = 0
plateau.step(avg_val_loss)
for g in optimizer.param_groups:
g['lr'] = max(g['lr'] * args.plateau_factor, args.min_lr)
else:
cosine.step()
else:
plateau.step(avg_val_loss) # se quiser platear por ES, precisa criar um plateau 'mode=max'
epochs_since_switch += 1
# ===== Counters =====
if no_imp_loss > 0 or no_imp_f1 > 0 or no_imp_es > 0:
print(f"⏳ Sem melhora — loss: {no_imp_loss}/{args.patience_loss}, ES: {no_imp_es}/{args.patience_es}, {MAIN_CLASS_NAME}: {no_imp_f1}/{args.patience_f1}")
# ===== Early stopping (usa ES + loss) =====
if (no_imp_loss >= args.patience_loss and
(es_metric is None or no_imp_es >= args.patience_es) and
(config["model_to_use"] == "main_class" and no_imp_f1 > args.patience_f1) and
(active_sched == "cosine" or epochs_since_switch >= args.grace_after_switch)):
print("⏹ Early stopping: loss e ES sem melhora.")
break
# ===== Plots periódicos =====
if epoch % args.plot_every == 0 or epoch == num_epochs:
x_epochs = list(range(1, 1 + 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
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()
# Treino base
ap.add_argument("--epochs", type=int, default=120)
ap.add_argument("--batch", type=int, default=16)
ap.add_argument("--workers", type=int, default=4)
ap.add_argument("--lr", type=float, default=3e-4)
ap.add_argument("--min-lr", dest="min_lr", type=float, default=1e-6)
ap.add_argument("--weight-decay", dest="weight_decay", type=float, default=1e-4)
ap.add_argument("--amp", action="store_true", help="Ativa mixed precision")
ap.add_argument("--accum", type=int, default=1, help="Acumulação de gradiente")
ap.add_argument("--grad-clip", dest="grad_clip", type=float, default=1.0)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--cudnn-benchmark", action="store_true")
ap.add_argument("--freeze-bn", action="store_true")
# EMA
ap.add_argument("--ema", action="store_true", help="Ativa EMA do modelo para validação/checkpoints")
ap.add_argument("--ema-decay", dest="ema_decay", type=float, default=0.999, help="Fator de decaimento da EMA (0..1)")
# Pesos de classe (ROI)
ap.add_argument("--use-weights", action="store_true")
ap.add_argument("--cw-alpha", type=float, default=1.05)
ap.add_argument("--cw-min", type=float, default=0.3)
ap.add_argument("--cw-max", type=float, default=2.0)
ap.add_argument("--cw-bgfloor", type=float, default=0.6)
ap.add_argument("--cw-max-per-group", type=int, default=300)
ap.add_argument("--cw-warmup", type=int, default=8)
ap.add_argument("--cw-erva-min", type=float, default=1.2)
ap.add_argument("--cw-cana-max", type=float, default=2.2)
ap.add_argument("--cw-chao-min", type=float, default=0.5)
# Dice mix
ap.add_argument("--dice-max", type=float, default=0.3, help="Fraçao máxima de Dice na loss (0..1)")
# Scheduler
ap.add_argument("--warmup-epochs", type=int, default=5)
ap.add_argument("--plateau-factor", type=float, default=0.5)
ap.add_argument("--plateau-patience", type=int, default=6)
ap.add_argument("--plateau-cooldown", type=int, default=1)
# Early stop
ap.add_argument("--patience-loss", type=int, default=12)
ap.add_argument("--patience-f1", type=int, default=6)
ap.add_argument("--delta-f1-min", type=float, default=0.0015)
ap.add_argument("--grace-after-switch", type=int, default=4)
# Sampler por nome de classe
ap.add_argument("--ensure-per-batch", type=str, default="", help="Ex.: 'erva,cana'")
# Tversky
ap.add_argument("--tversky-classes", type=str, default="", help="Classes (nomes) separadas por vírgula, ex: 'erva,cana'")
ap.add_argument("--tversky-weights", type=str, default="", help="Pesos para cada classe, ex: '0.2,0.15' (valor único faz broadcast)")
ap.add_argument("--tversky-alphas", type=str, default="", help="Alphas por classe (FP weight). Ex: '0.72,0.35' (valor único faz broadcast, default=0.5)")
ap.add_argument("--tversky-betas", type=str, default="", help="Betas por classe (FN weight). Ex: '0.28,0.65' (valor único faz broadcast, default=0.5)")
ap.add_argument("--tversky-in-val", action="store_true", help="Se setado, também soma Tversky no loss de validação (default: não)")
# Foco do treinamento
ap.add_argument("--es-classes", type=str, default="erva,cana", help="Classes usadas no critério de early stop/checkpoint (ex.: 'erva,cana').")
ap.add_argument("--es-agg", type=str, default="macro", choices=["macro", "min", "harmonic"], help="Agregação sobre as classes do ES: macro (média), min (mais rígido), harmonic.")
ap.add_argument("--delta-es-min", type=float, default=1e-4, help="Mín. variação para considerar melhora no ES.")
ap.add_argument("--patience-es", type=int, default=8, help="Paciência (épocas) sem melhora no ES antes de contar para early stop.")
# Classes iguais fase 1
ap.add_argument("--eq-classes", type=str, default="", help="Classes da mesma familia, como vegetacao (ex.: 'erva,cana').")
# Outros
ap.add_argument("--checkpoint", type=str, default=None)
ap.add_argument("--plot-every", dest="plot_every", type=int, default=5)
return ap.parse_args()
if __name__ == "__main__":
args = parse_args()
train(args)