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 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
+ }