ovicore 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ovicore/__init__.py +20 -0
- ovicore/anotacoes.py +51 -0
- ovicore/backends/__init__.py +1 -0
- ovicore/backends/detectron2_backend.py +126 -0
- ovicore/backends/huggingface_backend.py +69 -0
- ovicore/backends/ultralytics_backend.py +85 -0
- ovicore/contracts.py +250 -0
- ovicore/fatiamento.py +121 -0
- ovicore/io.py +241 -0
- ovicore/metrics.py +133 -0
- ovicore/relatorio.py +122 -0
- ovicore-0.1.0.dist-info/METADATA +316 -0
- ovicore-0.1.0.dist-info/RECORD +16 -0
- ovicore-0.1.0.dist-info/WHEEL +5 -0
- ovicore-0.1.0.dist-info/licenses/LICENSE +21 -0
- ovicore-0.1.0.dist-info/top_level.txt +1 -0
ovicore/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Núcleo de avaliação padronizada para comparação de frameworks de detecção
|
|
3
|
+
de objetos.
|
|
4
|
+
|
|
5
|
+
Projeto UFSM-Fiocruz: detecção de ovos de Aedes aegypti em ovitrampas.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from ovicore.metrics import avaliar, compute_iou, compute_metrics
|
|
11
|
+
from ovicore.io import (
|
|
12
|
+
gravar_predicoes, carregar_predicoes, montar_metadata,
|
|
13
|
+
validar_contrato, padronizar_labels_para_int,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"avaliar", "compute_iou", "compute_metrics",
|
|
18
|
+
"gravar_predicoes", "carregar_predicoes", "montar_metadata",
|
|
19
|
+
"validar_contrato", "padronizar_labels_para_int",
|
|
20
|
+
]
|
ovicore/anotacoes.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Leitura da verdade-terreno a partir de um JSON no formato COCO.
|
|
3
|
+
|
|
4
|
+
Devolve as caixas já em [x1, y1, x2, y2], prontas para metrics.avaliar e
|
|
5
|
+
io.montar_predicoes. Só lê: não altera o arquivo.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from ovicore.contracts import BBox
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ler_anotacoes_coco(
|
|
14
|
+
caminho_json: str, deslocamento_classe: int = 0
|
|
15
|
+
) -> dict[str, tuple[list[list[float]], list[int]]]:
|
|
16
|
+
"""
|
|
17
|
+
Devolve {file_name: (caixas, classes)}, com uma entrada para cada
|
|
18
|
+
imagem do JSON, inclusive as que não têm anotação (listas vazias).
|
|
19
|
+
|
|
20
|
+
As caixas saem em xyxy via BBox.from_coco: [x, y, x + w, y + h]. Ver E0.
|
|
21
|
+
A classe é o category_id, como int, somado a deslocamento_classe.
|
|
22
|
+
|
|
23
|
+
pred_labels e true_labels precisam estar no mesmo espaço de ids. Use
|
|
24
|
+
deslocamento_classe só se os ids das predições forem diferentes dos do
|
|
25
|
+
JSON. Confira antes: no Faster R-CNN de referência não há deslocamento.
|
|
26
|
+
"""
|
|
27
|
+
with open(caminho_json, encoding="utf-8") as f:
|
|
28
|
+
coco = json.load(f)
|
|
29
|
+
|
|
30
|
+
for campo in ("images", "annotations"):
|
|
31
|
+
if campo not in coco:
|
|
32
|
+
raise ValueError(f"{caminho_json} não é COCO: falta o campo '{campo}'")
|
|
33
|
+
|
|
34
|
+
por_imagem = {}
|
|
35
|
+
for ann in coco["annotations"]:
|
|
36
|
+
faltando = [c for c in ("image_id", "bbox", "category_id") if c not in ann]
|
|
37
|
+
if faltando:
|
|
38
|
+
raise ValueError(f"anotação {ann.get('id')} sem os campos {faltando}")
|
|
39
|
+
caixa = BBox.from_coco(ann["bbox"])
|
|
40
|
+
classe = int(ann["category_id"]) + deslocamento_classe
|
|
41
|
+
caixas, classes = por_imagem.setdefault(ann["image_id"], ([], []))
|
|
42
|
+
caixas.append([caixa.x1, caixa.y1, caixa.x2, caixa.y2])
|
|
43
|
+
classes.append(classe)
|
|
44
|
+
|
|
45
|
+
resultado = {}
|
|
46
|
+
for img in coco["images"]:
|
|
47
|
+
faltando = [c for c in ("id", "file_name") if c not in img]
|
|
48
|
+
if faltando:
|
|
49
|
+
raise ValueError(f"imagem {img} sem os campos {faltando}")
|
|
50
|
+
resultado[img["file_name"]] = por_imagem.get(img["id"], ([], []))
|
|
51
|
+
return resultado
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adaptadores concretos de DetectorBackend, um por framework."""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend do Detectron2. Estão prontos extrair_budget e gerar_config; os
|
|
3
|
+
outros métodos dependem de rodar com o framework instalado.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from ovicore.contracts import DetectorBackend, TrainingBudget, BBoxFormat
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Detectron2Backend(DetectorBackend):
|
|
12
|
+
|
|
13
|
+
def __init__(self, batch_size_treino: int, optimizer: str = "SGD"):
|
|
14
|
+
"""
|
|
15
|
+
batch_size_treino: o batch do treino (ex.: 8), usado para converter
|
|
16
|
+
iterações em épocas. Não fica salvo no metrics.json.
|
|
17
|
+
|
|
18
|
+
optimizer: suposição, o padrão do Detectron2. O metrics.json não
|
|
19
|
+
registra o otimizador usado.
|
|
20
|
+
"""
|
|
21
|
+
self._batch_size_treino = batch_size_treino
|
|
22
|
+
self._optimizer = optimizer
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def nome(self) -> str:
|
|
26
|
+
return "detectron2"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def formato_anotacao_esperado(self) -> BBoxFormat:
|
|
30
|
+
return BBoxFormat.XYWH
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def score_range_observado(self) -> tuple:
|
|
34
|
+
# Faixa observada em execução real, fold-01-416 (ver E5).
|
|
35
|
+
return (0.0, 0.995)
|
|
36
|
+
|
|
37
|
+
def extrair_budget(self, caminho_metrics_json: str) -> TrainingBudget:
|
|
38
|
+
"""
|
|
39
|
+
Lê o orçamento do metrics.json gerado pelo Detectron2, não de
|
|
40
|
+
configuração externa. Ver E6.
|
|
41
|
+
|
|
42
|
+
O arquivo é JSON Lines: uma linha por avaliação periódica, cada uma
|
|
43
|
+
com a iteração e a learning rate do momento.
|
|
44
|
+
"""
|
|
45
|
+
linhas = []
|
|
46
|
+
with open(caminho_metrics_json) as f:
|
|
47
|
+
for linha in f:
|
|
48
|
+
linha = linha.strip()
|
|
49
|
+
if linha:
|
|
50
|
+
linhas.append(json.loads(linha))
|
|
51
|
+
|
|
52
|
+
if not linhas:
|
|
53
|
+
raise ValueError(f"{caminho_metrics_json} está vazio")
|
|
54
|
+
|
|
55
|
+
# O campo "iteration" conta a partir de zero: a última linha de um
|
|
56
|
+
# treino de 3000 iterações registra 2999.
|
|
57
|
+
ultima_iteracao = linhas[-1]["iteration"] + 1
|
|
58
|
+
# A learning rate declarada no arquivo varia com o schedule.
|
|
59
|
+
# O valor de pico (o maior visto) é o que a configuração pediu.
|
|
60
|
+
learning_rate = max(l["lr"] for l in linhas if "lr" in l)
|
|
61
|
+
|
|
62
|
+
return TrainingBudget(
|
|
63
|
+
iterations=ultima_iteracao,
|
|
64
|
+
epochs=None, # calculado à parte, precisa do tamanho do dataset
|
|
65
|
+
batch_size=self._batch_size_treino,
|
|
66
|
+
learning_rate=learning_rate,
|
|
67
|
+
optimizer=self._optimizer,
|
|
68
|
+
fonte="metrics.json",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def epocas_equivalentes(self, budget: TrainingBudget, tamanho_dataset: int) -> float:
|
|
72
|
+
"""
|
|
73
|
+
Converte iterações em épocas equivalentes. Ver E7: contar por
|
|
74
|
+
iteração esconde que os modelos viram o dataset um número de vezes
|
|
75
|
+
bem diferente.
|
|
76
|
+
|
|
77
|
+
O amostrador do Detectron2 é infinito: sorteia lotes sem fechar
|
|
78
|
+
épocas, então o treino viu iterações × lote imagens. Arredondar os
|
|
79
|
+
lotes por época para cima (como o notebook do grupo faz para chegar
|
|
80
|
+
ao MAX_ITER) só coincide quando o dataset é múltiplo do lote: com
|
|
81
|
+
131 imagens daria 176,5 épocas em vez de 183,2.
|
|
82
|
+
"""
|
|
83
|
+
return budget.iterations * budget.batch_size / tamanho_dataset
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def gerar_config(arquivo_zoo: str, num_classes: int, destino: str,
|
|
87
|
+
mascara: bool = True) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Grava um config.yaml para inferência a partir do model zoo, com o
|
|
90
|
+
número de classes do treino. Os zips de modelo do grupo não trazem o
|
|
91
|
+
config usado no treino.
|
|
92
|
+
|
|
93
|
+
Para inferência só pesam a arquitetura e o NUM_CLASSES; lote e
|
|
94
|
+
solver só afetam o treino. Os datasets ficam vazios para o sahi não
|
|
95
|
+
tomar os nomes das 80 classes do COCO. Vale para modelos com
|
|
96
|
+
ROI_HEADS (Faster R-CNN, Mask R-CNN), não para RetinaNet.
|
|
97
|
+
|
|
98
|
+
mascara=False desliga a cabeça de máscara do Mask R-CNN. Com máscara,
|
|
99
|
+
o sahi 0.11.19 monta a caixa a partir do contorno da máscara, em
|
|
100
|
+
pixels inteiros; sem ela, a caixa vem da cabeça de detecção, em
|
|
101
|
+
coordenadas decimais, como no Faster R-CNN. Os pesos são os mesmos.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
from detectron2.config import get_cfg
|
|
105
|
+
from detectron2.model_zoo import get_config_file
|
|
106
|
+
except ImportError as e:
|
|
107
|
+
raise ImportError("gerar_config precisa do detectron2 instalado") from e
|
|
108
|
+
|
|
109
|
+
cfg = get_cfg()
|
|
110
|
+
cfg.merge_from_file(get_config_file(arquivo_zoo))
|
|
111
|
+
cfg.MODEL.ROI_HEADS.NUM_CLASSES = num_classes
|
|
112
|
+
cfg.DATASETS.TRAIN = ()
|
|
113
|
+
cfg.DATASETS.TEST = ()
|
|
114
|
+
if not mascara:
|
|
115
|
+
cfg.MODEL.MASK_ON = False
|
|
116
|
+
with open(destino, "w", encoding="utf-8") as f:
|
|
117
|
+
f.write(cfg.dump())
|
|
118
|
+
return destino
|
|
119
|
+
|
|
120
|
+
# --- ainda não implementado ---
|
|
121
|
+
|
|
122
|
+
def carregar_modelo(self, caminho: str) -> None:
|
|
123
|
+
raise NotImplementedError("carregar_modelo: próxima etapa")
|
|
124
|
+
|
|
125
|
+
def inferir(self, caminho_imagem, slicing, score_threshold):
|
|
126
|
+
raise NotImplementedError("inferir: próxima etapa")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend do HuggingFace (DETR). Só extrair_budget está pronto, lendo o
|
|
3
|
+
orçamento do trainer_state.json que o Trainer grava com save_state().
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from ovicore.contracts import DetectorBackend, TrainingBudget, BBoxFormat
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HuggingFaceBackend(DetectorBackend):
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def nome(self) -> str:
|
|
15
|
+
return "huggingface"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def formato_anotacao_esperado(self) -> BBoxFormat:
|
|
19
|
+
return BBoxFormat.XYWH
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def score_range_observado(self) -> tuple:
|
|
23
|
+
# conditional-detr-resnet-50, fold-01-256, sahi 0.12.6: o sahi usa a
|
|
24
|
+
# sigmoid do próprio modelo. 0,394 em 07/09/2026, 0,371 no retreino
|
|
25
|
+
# de 08/11/2026 (ver E10).
|
|
26
|
+
return (0.0, 0.394)
|
|
27
|
+
|
|
28
|
+
def extrair_budget(self, caminho_trainer_state: str,
|
|
29
|
+
caminho_parametros: str = None) -> TrainingBudget:
|
|
30
|
+
"""
|
|
31
|
+
Épocas, passos e lote efetivo vêm do trainer_state.json, o registro
|
|
32
|
+
do treino que rodou: com auto_find_batch_size o lote só é conhecido
|
|
33
|
+
ali. A learning rate e o otimizador vêm do parametros_treino.json do
|
|
34
|
+
notebook de treino, quando informado. Sem ele, a learning rate é a
|
|
35
|
+
maior registrada no log, que fica um pouco abaixo da pedida porque o
|
|
36
|
+
log não cai exatamente no pico do warmup.
|
|
37
|
+
"""
|
|
38
|
+
with open(caminho_trainer_state, encoding="utf-8") as f:
|
|
39
|
+
estado = json.load(f)
|
|
40
|
+
if "log_history" not in estado:
|
|
41
|
+
raise ValueError(f"{caminho_trainer_state} não é um trainer_state.json do Trainer")
|
|
42
|
+
|
|
43
|
+
learning_rate, otimizador = None, None
|
|
44
|
+
if caminho_parametros:
|
|
45
|
+
with open(caminho_parametros, encoding="utf-8") as f:
|
|
46
|
+
orcamento = json.load(f)["orcamento"]
|
|
47
|
+
learning_rate, otimizador = orcamento.get("learning_rate"), orcamento.get("optim")
|
|
48
|
+
if learning_rate is None:
|
|
49
|
+
learning_rate = max((l["learning_rate"] for l in estado["log_history"]
|
|
50
|
+
if "learning_rate" in l), default=None)
|
|
51
|
+
|
|
52
|
+
return TrainingBudget(
|
|
53
|
+
epochs=estado.get("num_train_epochs"),
|
|
54
|
+
iterations=estado.get("global_step"),
|
|
55
|
+
batch_size=estado.get("train_batch_size"),
|
|
56
|
+
learning_rate=learning_rate,
|
|
57
|
+
optimizer=otimizador,
|
|
58
|
+
# menos passos que o previsto: o treino parou antes do fim
|
|
59
|
+
early_stopping=estado.get("global_step", 0) < estado.get("max_steps", 0),
|
|
60
|
+
fonte="trainer_state.json",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# --- ainda não implementado ---
|
|
64
|
+
|
|
65
|
+
def carregar_modelo(self, caminho: str) -> None:
|
|
66
|
+
raise NotImplementedError("carregar_modelo: próxima etapa")
|
|
67
|
+
|
|
68
|
+
def inferir(self, caminho_imagem, slicing, score_threshold):
|
|
69
|
+
raise NotImplementedError("inferir: próxima etapa")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend do Ultralytics (YOLO). Só extrair_budget está pronto, lendo o
|
|
3
|
+
orçamento do artefato real em vez de configuração externa. Ver E6.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ovicore.contracts import DetectorBackend, TrainingBudget, BBoxFormat
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UltralyticsBackend(DetectorBackend):
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def nome(self) -> str:
|
|
13
|
+
return "ultralytics"
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def formato_anotacao_esperado(self) -> BBoxFormat:
|
|
17
|
+
return BBoxFormat.CXCYWH_NORM
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def score_range_observado(self) -> tuple:
|
|
21
|
+
# Faixa observada em execução real, fold-01-416, yolov8n (ver E5).
|
|
22
|
+
return (0.0, 0.907)
|
|
23
|
+
|
|
24
|
+
def extrair_budget(self, caminho_checkpoint: str) -> TrainingBudget:
|
|
25
|
+
"""
|
|
26
|
+
Lê o orçamento direto do checkpoint .pt. O Ultralytics guarda os
|
|
27
|
+
hiperparâmetros em 'train_args', dentro do próprio arquivo, sem
|
|
28
|
+
precisar de metrics.json nem do batch_size_treino do Detectron2.
|
|
29
|
+
"""
|
|
30
|
+
# Assimetria: ler este checkpoint exige torch e o pacote ultralytics
|
|
31
|
+
# instalados (o pickle referencia classes deles); o Detectron2Backend só usa json.
|
|
32
|
+
import torch
|
|
33
|
+
ckpt = torch.load(caminho_checkpoint, map_location="cpu", weights_only=False)
|
|
34
|
+
return self._parse_checkpoint(ckpt)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _parse_checkpoint(ckpt: dict) -> TrainingBudget:
|
|
38
|
+
"""Validação + interpretação, sem depender de torch para ser testada."""
|
|
39
|
+
if "train_args" not in ckpt:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"checkpoint sem 'train_args'. Não é um checkpoint de treino "
|
|
42
|
+
"do Ultralytics, ou está corrompido."
|
|
43
|
+
)
|
|
44
|
+
budget = UltralyticsBackend._budget_from_train_args(ckpt["train_args"])
|
|
45
|
+
resultados = ckpt.get("train_results") or {}
|
|
46
|
+
if resultados.get("epoch"):
|
|
47
|
+
budget.epocas_executadas, budget.melhor_epoca = (
|
|
48
|
+
UltralyticsBackend._epocas_de_train_results(resultados))
|
|
49
|
+
return budget
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _epocas_de_train_results(resultados: dict) -> tuple:
|
|
53
|
+
"""
|
|
54
|
+
Épocas que de fato rodaram e a época do best.pt, a de maior fitness.
|
|
55
|
+
O fitness do Ultralytics para detecção é 0,1 × mAP50 + 0,9 × mAP50-95.
|
|
56
|
+
"""
|
|
57
|
+
epocas = resultados["epoch"]
|
|
58
|
+
fitness = [0.1 * a + 0.9 * b for a, b in
|
|
59
|
+
zip(resultados["metrics/mAP50(B)"], resultados["metrics/mAP50-95(B)"])]
|
|
60
|
+
melhor = max(range(len(fitness)), key=fitness.__getitem__)
|
|
61
|
+
return int(max(epocas)), int(epocas[melhor])
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _budget_from_train_args(args: dict) -> TrainingBudget:
|
|
65
|
+
"""
|
|
66
|
+
Parte pura da leitura, sem torch nem ultralytics. Separada para que
|
|
67
|
+
a maioria dos testes rode sem as dependências pesadas.
|
|
68
|
+
"""
|
|
69
|
+
return TrainingBudget(
|
|
70
|
+
epochs=args.get("epochs"),
|
|
71
|
+
iterations=None, # Ultralytics não conta por iteração, só por época
|
|
72
|
+
batch_size=args.get("batch"),
|
|
73
|
+
learning_rate=args.get("lr0"),
|
|
74
|
+
optimizer=args.get("optimizer"),
|
|
75
|
+
early_stopping=bool(args.get("patience", 0) > 0),
|
|
76
|
+
fonte="checkpoint.train_args",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# --- ainda não implementado ---
|
|
80
|
+
|
|
81
|
+
def carregar_modelo(self, caminho: str) -> None:
|
|
82
|
+
raise NotImplementedError("carregar_modelo: próxima etapa")
|
|
83
|
+
|
|
84
|
+
def inferir(self, caminho_imagem, slicing, score_threshold):
|
|
85
|
+
raise NotImplementedError("inferir: próxima etapa")
|
ovicore/contracts.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tipos e interfaces do core: como uma caixa é representada, o que descreve
|
|
3
|
+
um dataset e o que cada backend precisa implementar.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BBoxFormat(str, Enum):
|
|
13
|
+
XYXY = "xyxy" # [x1, y1, x2, y2], formato interno
|
|
14
|
+
XYWH = "xywh" # [x, y, largura, altura], COCO
|
|
15
|
+
CXCYWH_NORM = "cxcywh_n" # [cx, cy, w, h] normalizado, YOLO
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Granularity(str, Enum):
|
|
19
|
+
FULL_IMAGE = "full_image" # imagem inteira, recortada na inferência
|
|
20
|
+
PRE_TILED = "pre_tiled" # recortes já salvos em disco
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class BBox:
|
|
25
|
+
"""
|
|
26
|
+
Caixa no formato interno do core: [x1, y1, x2, y2] em pixels.
|
|
27
|
+
|
|
28
|
+
Só aceita float comum do Python. np.float32 quebra a gravação do CSV
|
|
29
|
+
e int corta as casas decimais, o que distorce a medição em objetos
|
|
30
|
+
pequenos. Ver docs/EVIDENCIAS.md, E1 e E2.
|
|
31
|
+
"""
|
|
32
|
+
x1: float
|
|
33
|
+
y1: float
|
|
34
|
+
x2: float
|
|
35
|
+
y2: float
|
|
36
|
+
|
|
37
|
+
def __post_init__(self):
|
|
38
|
+
for nome in ("x1", "y1", "x2", "y2"):
|
|
39
|
+
if type(getattr(self, nome)) is not float:
|
|
40
|
+
raise TypeError(
|
|
41
|
+
f"BBox.{nome} deve ser float comum, recebeu "
|
|
42
|
+
f"{type(getattr(self, nome)).__name__}"
|
|
43
|
+
)
|
|
44
|
+
if self.x2 <= self.x1 or self.y2 <= self.y1:
|
|
45
|
+
raise ValueError(f"caixa inválida: {self}")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def largura(self) -> float:
|
|
49
|
+
return self.x2 - self.x1
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def altura(self) -> float:
|
|
53
|
+
return self.y2 - self.y1
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_coco(cls, bbox_xywh) -> "BBox":
|
|
57
|
+
"""Converte de [x, y, largura, altura]."""
|
|
58
|
+
x, y, w, h = (float(v) for v in bbox_xywh)
|
|
59
|
+
return cls(x, y, x + w, y + h)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Detection:
|
|
64
|
+
bbox: BBox
|
|
65
|
+
class_id: int
|
|
66
|
+
score: float
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Annotation:
|
|
71
|
+
bbox: BBox
|
|
72
|
+
class_id: int
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DatasetSpec:
|
|
77
|
+
"""Descreve um dataset e permite verificar se dois são comparáveis."""
|
|
78
|
+
nome: str
|
|
79
|
+
fold: str
|
|
80
|
+
tile_size: int
|
|
81
|
+
granularidade: Granularity
|
|
82
|
+
id2label: dict
|
|
83
|
+
caminho_imagens: str
|
|
84
|
+
caminho_anotacoes: str
|
|
85
|
+
formato_anotacao: BBoxFormat
|
|
86
|
+
seed_split: Optional[int] = None
|
|
87
|
+
|
|
88
|
+
def validar_contra(self, outro: "DatasetSpec") -> list:
|
|
89
|
+
"""Retorna o que impede comparar resultados destes dois datasets."""
|
|
90
|
+
problemas = []
|
|
91
|
+
if self.granularidade != outro.granularidade:
|
|
92
|
+
problemas.append(
|
|
93
|
+
f"granularidade: {self.granularidade} vs {outro.granularidade}")
|
|
94
|
+
if self.fold != outro.fold:
|
|
95
|
+
problemas.append(f"fold: {self.fold} vs {outro.fold}")
|
|
96
|
+
if self.tile_size != outro.tile_size:
|
|
97
|
+
problemas.append(f"tile_size: {self.tile_size} vs {outro.tile_size}")
|
|
98
|
+
if self.id2label != outro.id2label:
|
|
99
|
+
problemas.append("mapeamento de classes diferente")
|
|
100
|
+
return problemas
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class TrainingBudget:
|
|
105
|
+
"""
|
|
106
|
+
Como o modelo foi treinado.
|
|
107
|
+
|
|
108
|
+
Os valores devem vir do modelo salvo (metrics.json, train_args do
|
|
109
|
+
checkpoint), não do notebook que o carrega. Ver E6.
|
|
110
|
+
|
|
111
|
+
epochs é o máximo configurado. Com parada antecipada, o treino roda
|
|
112
|
+
menos: epocas_executadas e melhor_epoca (a do modelo salvo) dizem
|
|
113
|
+
quanto, quando o artefato registra. Ver E11.
|
|
114
|
+
"""
|
|
115
|
+
epochs: Optional[int] = None
|
|
116
|
+
iterations: Optional[int] = None
|
|
117
|
+
batch_size: Optional[int] = None
|
|
118
|
+
learning_rate: Optional[float] = None
|
|
119
|
+
optimizer: Optional[str] = None
|
|
120
|
+
early_stopping: bool = False
|
|
121
|
+
fonte: str = "desconhecida"
|
|
122
|
+
epocas_executadas: Optional[int] = None
|
|
123
|
+
melhor_epoca: Optional[int] = None
|
|
124
|
+
|
|
125
|
+
def comparavel_com(self, outro: "TrainingBudget") -> list:
|
|
126
|
+
problemas = []
|
|
127
|
+
if self.epochs and outro.epochs and self.epochs != outro.epochs:
|
|
128
|
+
problemas.append(f"épocas: {self.epochs} vs {outro.epochs}")
|
|
129
|
+
if (self.learning_rate and outro.learning_rate
|
|
130
|
+
and self.learning_rate != outro.learning_rate):
|
|
131
|
+
problemas.append(f"lr: {self.learning_rate} vs {outro.learning_rate}")
|
|
132
|
+
if (self.batch_size and outro.batch_size
|
|
133
|
+
and self.batch_size != outro.batch_size):
|
|
134
|
+
problemas.append(f"batch: {self.batch_size} vs {outro.batch_size}")
|
|
135
|
+
if self.optimizer != outro.optimizer:
|
|
136
|
+
problemas.append(f"otimizador: {self.optimizer} vs {outro.optimizer}")
|
|
137
|
+
return problemas
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class SlicingConfig:
|
|
142
|
+
"""Configuração de recorte (SAHI), igual para todos os frameworks."""
|
|
143
|
+
slice_height: int = 416
|
|
144
|
+
slice_width: int = 416
|
|
145
|
+
overlap_ratio: float = 0.25
|
|
146
|
+
postprocess_type: str = "NMS"
|
|
147
|
+
postprocess_match_metric: str = "IOU"
|
|
148
|
+
postprocess_match_threshold: float = 0.45
|
|
149
|
+
class_agnostic: bool = False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class DetectorBackend(ABC):
|
|
153
|
+
"""
|
|
154
|
+
O que cada framework precisa implementar para entrar no core.
|
|
155
|
+
|
|
156
|
+
O core não treina nada. A entrada é uma fonte única em COCO, descrita
|
|
157
|
+
por DatasetSpec. O que o core padroniza é a saída (list[Detection]).
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def nome(self) -> str:
|
|
163
|
+
"""detectron2, ultralytics, huggingface"""
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
@abstractmethod
|
|
167
|
+
def formato_anotacao_esperado(self) -> BBoxFormat:
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
@abstractmethod
|
|
172
|
+
def score_range_observado(self) -> tuple:
|
|
173
|
+
"""
|
|
174
|
+
Faixa de confiança que este framework costuma produzir.
|
|
175
|
+
O YOLOv8n não passa de 0,907; o Detectron2 chega a 0,995.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def preparar_dataset(self, spec: DatasetSpec) -> str:
|
|
179
|
+
"""Converter o dataset para o formato do framework é trabalho futuro."""
|
|
180
|
+
raise NotImplementedError(
|
|
181
|
+
"Conversão de formatos na entrada está fora do escopo desta versão")
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
def carregar_modelo(self, caminho: str) -> None:
|
|
185
|
+
...
|
|
186
|
+
|
|
187
|
+
@abstractmethod
|
|
188
|
+
def extrair_budget(self, caminho: str) -> TrainingBudget:
|
|
189
|
+
"""Lê os hiperparâmetros do modelo salvo."""
|
|
190
|
+
|
|
191
|
+
@abstractmethod
|
|
192
|
+
def inferir(self, caminho_imagem: str, slicing: Optional[SlicingConfig],
|
|
193
|
+
score_threshold: float) -> list:
|
|
194
|
+
"""
|
|
195
|
+
Devolve list[Detection]. Coordenadas em float, sem truncar.
|
|
196
|
+
Classe sempre int.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class RunSpec:
|
|
202
|
+
"""Uma execução, com tudo que é preciso para reproduzi-la."""
|
|
203
|
+
backend: str
|
|
204
|
+
arquitetura: str
|
|
205
|
+
dataset: DatasetSpec
|
|
206
|
+
budget: TrainingBudget
|
|
207
|
+
slicing: Optional[SlicingConfig]
|
|
208
|
+
inference_score_threshold: float
|
|
209
|
+
evaluation_score_threshold: float
|
|
210
|
+
iou_threshold: float = 0.45
|
|
211
|
+
score_max_observado: Optional[float] = None
|
|
212
|
+
device: str = "cuda"
|
|
213
|
+
data_execucao: Optional[str] = None
|
|
214
|
+
observacoes: str = ""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ComparisonGuard:
|
|
218
|
+
"""Verifica se dois resultados podem ser comparados."""
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def validar(run_a: RunSpec, run_b: RunSpec) -> dict:
|
|
222
|
+
"""
|
|
223
|
+
Devolve bloqueios (impedem qualquer comparação) e alertas
|
|
224
|
+
(permitem comparar o formato, não a qualidade).
|
|
225
|
+
"""
|
|
226
|
+
bloqueios = run_a.dataset.validar_contra(run_b.dataset)
|
|
227
|
+
alertas = []
|
|
228
|
+
|
|
229
|
+
divergencias = run_a.budget.comparavel_com(run_b.budget)
|
|
230
|
+
if divergencias:
|
|
231
|
+
alertas.append(
|
|
232
|
+
"treino não equalizado: " + "; ".join(divergencias))
|
|
233
|
+
|
|
234
|
+
if (run_a.evaluation_score_threshold == run_b.evaluation_score_threshold
|
|
235
|
+
and run_a.score_max_observado and run_b.score_max_observado):
|
|
236
|
+
if abs(run_a.score_max_observado - run_b.score_max_observado) > 0.05:
|
|
237
|
+
alertas.append(
|
|
238
|
+
f"mesmo limiar ({run_a.evaluation_score_threshold}) para "
|
|
239
|
+
f"faixas de confiança diferentes "
|
|
240
|
+
f"({run_a.score_max_observado} vs {run_b.score_max_observado})")
|
|
241
|
+
|
|
242
|
+
if run_a.iou_threshold != run_b.iou_threshold:
|
|
243
|
+
bloqueios.append(
|
|
244
|
+
f"iou_threshold: {run_a.iou_threshold} vs {run_b.iou_threshold}")
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"pode_comparar": len(bloqueios) == 0,
|
|
248
|
+
"bloqueios": bloqueios,
|
|
249
|
+
"alertas": alertas,
|
|
250
|
+
}
|