This commit is contained in:
Diego Freitas 2026-09-11 22:19:46 -03:00
commit 608c9b5e85
3 changed files with 673 additions and 54 deletions

View File

@ -43,7 +43,7 @@ Exemplos:
--save-visuals --visual-every 20 --save-visuals --visual-every 20
# Revisão manual e criação do fixed/group # Revisão manual e criação do fixed/group
python -m audito.audit_dataset_manual ^ python -m audit.audit_dataset_manual ^
--input_path dataset/1024x640/group ^ --input_path dataset/1024x640/group ^
--out_dir audit_out_manual ^ --out_dir audit_out_manual ^
--manual-review --build-fixed-dataset --save-rejected-previews --manual-review --build-fixed-dataset --save-rejected-previews

View File

@ -16,16 +16,26 @@ IMPORTANTE: discordância modelo x GT NÃO é tratada automaticamente como erro
modelo. Em um dataset com rótulos imperfeitos, um conflito de alta confiança pode modelo. Em um dataset com rótulos imperfeitos, um conflito de alta confiança pode
ser justamente um forte candidato a máscara incorreta. ser justamente um forte candidato a máscara incorreta.
Saída de revisão (quando não usa --report_only): Saída de revisão física RAW-SAFE (quando não usa --report_only):
dataset/revisao/group/<grupo>/ dataset/revisao/group/<grupo>/
previews/<base>.png raw_previews/<base>.png # PREVIEW ORIGINAL de dataset/brutas, sem warp/resize
masks/<base>.png # GT semantic colorido raw_masks/<base>.png # MASK ORIGINAL de dataset/brutas, sem warp/resize
predictions/<base>.png # predição semantic colorida predictions_raw/<base>.png # prediction já reprojetada para o domínio RAW
panels/<base>.png # opcional, --save_panels panels/<base>.png # opcional, --save_panels
final_masks/<base>.png # NÃO é tocado pelo auditor final_masks/<base>.png # NÃO é tocado pelo auditor
review_order.csv review_order.csv
dataset/revisao/group/review_domain_manifest.csv
dataset/revisao/group/README_DOMAIN_CONTRACT.txt
IMPORTANTE:
- raw_previews/raw_masks pertencem ao espaço de anotação BRUTO.
- predictions_raw pertence ao espaço RAW (modelo retroprojetado).
- NUNCA faça apenas resize entre esses dois domínios para reinjetar máscaras.
- Esta versão deliberadamente NÃO cria os diretórios legados previews/ e masks/,
para impedir que um reviewer antigo misture os domínios silenciosamente.
Modos de exportação: Modos de exportação:
--export_mode flagged --export_mode flagged
Exporta casos acionados pelos critérios técnicos. Exporta casos acionados pelos critérios técnicos.
@ -162,7 +172,13 @@ def clear_dir(path: Path) -> None:
def clear_generated_review(review_group_root: Path) -> None: def clear_generated_review(review_group_root: Path) -> None:
"""Limpa somente artefatos regeneráveis e PRESERVA final_masks/.""" """
Limpa somente artefatos regeneráveis e PRESERVA final_masks/.
Também remove os diretórios legados previews/ e masks/ para evitar que
arquivos de domínio final de execuções antigas sejam confundidos com
raw_previews/raw_masks desta versão RAW-SAFE.
"""
if not review_group_root.exists(): if not review_group_root.exists():
review_group_root.mkdir(parents=True, exist_ok=True) review_group_root.mkdir(parents=True, exist_ok=True)
return return
@ -171,7 +187,11 @@ def clear_generated_review(review_group_root: Path) -> None:
if not group_dir.is_dir(): if not group_dir.is_dir():
continue continue
for name in ("previews", "masks", "predictions", "panels"): for name in (
"previews", "masks", # legado perigoso
"raw_previews", "raw_masks", "predictions_raw",
"aligned_previews", "predictions", "panels", # legados/debug
):
p = group_dir / name p = group_dir / name
if p.exists(): if p.exists():
shutil.rmtree(p) shutil.rmtree(p)
@ -180,6 +200,11 @@ def clear_generated_review(review_group_root: Path) -> None:
if order_csv.exists(): if order_csv.exists():
order_csv.unlink() order_csv.unlink()
for name in ("review_domain_manifest.csv", "README_DOMAIN_CONTRACT.txt"):
p = review_group_root / name
if p.exists():
p.unlink()
def load_test_module(script_path: Path): def load_test_module(script_path: Path):
if not script_path.is_file(): if not script_path.is_file():
@ -611,6 +636,437 @@ def build_reasons(
return reasons return reasons
# ============================================================
# Fontes BRUTAS para revisão RAW-SAFE
# ============================================================
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff")
def _image_files_by_stem(directory: Path) -> Dict[str, Path]:
out: Dict[str, Path] = {}
if not directory.is_dir():
return out
for p in sorted(directory.iterdir()):
if not p.is_file() or p.suffix.lower() not in IMAGE_EXTS:
continue
# Se houver duas extensões com o mesmo stem na mesma pasta, falha fechado.
if p.stem in out and out[p.stem].resolve() != p.resolve():
raise RuntimeError(
f"Base duplicada em {directory}: {p.stem}\n"
f" - {out[p.stem]}\n"
f" - {p}"
)
out[p.stem] = p
return out
def index_raw_review_sources(raw_group_root: Path):
"""
Indexa pares preview+mask diretamente de dataset/brutas/group/<grupo>.
Retorna:
by_key[(group, base)] -> dict
by_base[base] -> list[dict]
O par só entra se preview e mask coexistirem DENTRO do mesmo grupo bruto.
"""
if not raw_group_root.is_dir():
raise FileNotFoundError(f"RAW group não encontrado: {raw_group_root}")
by_key: Dict[Tuple[str, str], dict] = {}
by_base: Dict[str, List[dict]] = defaultdict(list)
for group_dir in sorted(p for p in raw_group_root.iterdir() if p.is_dir()):
previews = _image_files_by_stem(group_dir / "previews")
masks = _image_files_by_stem(group_dir / "masks")
common = sorted(set(previews) & set(masks))
for base in common:
rec = {
"group": group_dir.name,
"base": base,
"preview": previews[base],
"mask": masks[base],
}
key = (group_dir.name, base)
if key in by_key:
raise RuntimeError(f"Bundle RAW duplicado para {key}: {by_key[key]} vs {rec}")
by_key[key] = rec
by_base[base].append(rec)
return by_key, by_base
def _same_image_pixels(path_a: Path, path_b: Path) -> bool:
if not path_a.is_file() or not path_b.is_file():
return False
a = imread_raw_required(path_a, cv2.IMREAD_UNCHANGED)
b = imread_raw_required(path_b, cv2.IMREAD_UNCHANGED)
return (
a.shape == b.shape
and a.dtype == b.dtype
and np.array_equal(a, b)
)
def _resolve_normalized_meta_path(sample) -> Optional[Path]:
# dataset/960x600/group/<grupo>/tensors/<base>.npy
# ↓
# dataset/960x600/group/<grupo>/metas/<base>.json
tensor_path = Path(sample.tensor_path)
meta_path = (
tensor_path.parent.parent
/ "metas"
/ f"{sample.base}.json"
)
return meta_path if meta_path.is_file() else None
def _resolve_source_path_from_normalized_meta(
value,
normalized_meta_path: Path,
dataset_root: Path,
) -> Optional[Path]:
if not value:
return None
p = Path(str(value))
if p.is_absolute():
return p if p.is_file() else None
# O normalize grava source_* relativo à raiz dataset/
p_dataset = dataset_root / p
if p_dataset.is_file():
return p_dataset.resolve()
# fallback conservador
p_meta = normalized_meta_path.parent / p
if p_meta.is_file():
return p_meta.resolve()
return None
def resolve_raw_review_source(
sample,
by_key,
by_base,
dataset_root: Path,
) -> Tuple[dict, str]:
"""
Resolve fonte RAW de forma fail-closed.
Ordem:
1) mesmo group+base;
2) base globalmente único;
3) provenance do meta normalizado + comparação pixel-exact;
4) ambiguidade continua sendo erro.
"""
key = (str(sample.group), str(sample.base))
# --------------------------------------------------------
# 1. Caminho trivial
# --------------------------------------------------------
if key in by_key:
return by_key[key], "same_group"
matches = list(by_base.get(str(sample.base), []))
# --------------------------------------------------------
# 2. Base único em todo RAW
# --------------------------------------------------------
if len(matches) == 1:
return matches[0], "unique_base_fallback"
if not matches:
raise RuntimeError(
f"Não encontrei preview+mask BRUTOS para "
f"{sample.group}/{sample.base}. "
"Não vou exportar revisão em domínio errado."
)
# --------------------------------------------------------
# 3. Base duplicado:
# usa provenance gravada pelo normalize.
# --------------------------------------------------------
normalized_meta_path = _resolve_normalized_meta_path(sample)
if normalized_meta_path is not None:
nmeta = load_json(normalized_meta_path)
source_preview = _resolve_source_path_from_normalized_meta(
nmeta.get("source_preview"),
normalized_meta_path,
dataset_root,
)
source_mask = _resolve_source_path_from_normalized_meta(
nmeta.get("source_mask"),
normalized_meta_path,
dataset_root,
)
provenance_matches = []
for rec in matches:
evidence = []
if source_preview is not None:
evidence.append(
_same_image_pixels(
source_preview,
Path(rec["preview"]),
)
)
if source_mask is not None:
evidence.append(
_same_image_pixels(
source_mask,
Path(rec["mask"]),
)
)
# Só aceita quando existe evidência e TODA evidência
# disponível aponta para esse mesmo bundle.
if evidence and all(evidence):
provenance_matches.append(rec)
if len(provenance_matches) == 1:
rec = provenance_matches[0]
print(
f"[RAW-SAFE][PROVENANCE] "
f"{sample.group}/{sample.base} "
f"-> RAW {rec['group']}/{rec['base']}"
)
return rec, "provenance_pixel_exact"
if len(provenance_matches) > 1:
raise RuntimeError(
f"Proveniência ainda ambígua para "
f"{sample.group}/{sample.base}: "
+ ", ".join(
f"{m['group']}:{m['preview']}"
for m in provenance_matches
)
)
# --------------------------------------------------------
# 4. Continua fail-closed
# --------------------------------------------------------
raise RuntimeError(
f"Base RAW ambígua e provenance não resolveu para "
f"{sample.group}/{sample.base}: "
+ ", ".join(
f"{m['group']}:{m['preview']}"
for m in matches
)
)
def imread_raw_required(path: Path, flags: int) -> np.ndarray:
img = cv2.imread(str(path), flags)
if img is None or img.size == 0:
# Fallback robusto para caminhos Windows/PyInstaller.
try:
raw = np.fromfile(str(path), dtype=np.uint8)
img = cv2.imdecode(raw, flags)
except Exception:
img = None
if img is None or img.size == 0:
raise RuntimeError(f"Falha ao abrir imagem RAW: {path}")
return img
def write_png_required(path: Path, img: np.ndarray) -> None:
ensure_dir(path.parent)
ok = cv2.imwrite(str(path), img, [cv2.IMWRITE_PNG_COMPRESSION, 1])
if not ok:
raise RuntimeError(f"Falha ao gravar PNG: {path}")
def validate_raw_source_geometry(raw_source: dict) -> Tuple[int, int]:
"""
Valida que preview e mask brutos vivem no mesmo espaço espacial.
Não faz resize, crop, warp ou qualquer outra transformação.
"""
preview = imread_raw_required(Path(raw_source["preview"]), cv2.IMREAD_COLOR)
mask = imread_raw_required(Path(raw_source["mask"]), cv2.IMREAD_UNCHANGED)
if mask.ndim == 2:
mh, mw = mask.shape[:2]
else:
mh, mw = mask.shape[:2]
ph, pw = preview.shape[:2]
if (ph, pw) != (mh, mw):
raise RuntimeError(
"Preview/mask BRUTOS não têm a mesma geometria:\n"
f" preview={raw_source['preview']} shape={pw}x{ph}\n"
f" mask={raw_source['mask']} shape={mw}x{mh}"
)
return pw, ph
def _ensure_bgr3(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
if img.ndim == 3 and img.shape[2] >= 3:
return img[:, :, :3]
raise RuntimeError(f"Imagem inválida para conversão BGR: shape={getattr(img, 'shape', None)}")
def resolve_normalized_meta_path(sample) -> Optional[Path]:
candidates = []
for attr in ("meta_path", "metadata_path"):
value = getattr(sample, attr, None)
if value:
candidates.append(Path(value))
tensor_path = getattr(sample, "tensor_path", None)
if tensor_path:
tp = Path(tensor_path)
candidates.append(tp.parent.parent / "metas" / f"{sample.base}.json")
preview_path = getattr(sample, "preview_path", None)
if preview_path:
pp = Path(preview_path)
candidates.append(pp.parent.parent / "metas" / f"{sample.base}.json")
seen = set()
for c in candidates:
try:
rc = c.resolve()
except Exception:
rc = c
key = str(rc)
if key in seen:
continue
seen.add(key)
if rc.is_file():
return rc
return None
def load_fusion_result_for_sample(sample) -> Tuple[dict, Path]:
meta_path = resolve_normalized_meta_path(sample)
if meta_path is None:
raise RuntimeError(
f"Meta normalizado não encontrado para {sample.group}/{sample.base}. "
"Necessário para reprojetar a prediction para o domínio RAW."
)
meta = load_json(meta_path)
processing = meta.get("processing") or {}
fusion_result = processing.get("fusion_result") or {}
if not isinstance(fusion_result, dict) or not fusion_result:
raise RuntimeError(
f"fusion_result ausente/inválido em {meta_path} para {sample.group}/{sample.base}"
)
return fusion_result, meta_path
def compose_prediction_raw_from_fusion(
raw_mask_bgr: np.ndarray,
pred_bgr: np.ndarray,
fusion_result: dict,
sample_key: str,
) -> Tuple[np.ndarray, dict]:
"""
Inverso coerente com o normalize atual:
raw_mask (espaço RGB/CAM_A bruto) -> crop_box em ref_shape -> resize nearest para target_size
Aqui fazemos o caminho inverso APENAS para a prediction semantic:
pred_sem no target_size -> resize nearest para tamanho do crop_box -> cola sobre a máscara humana RAW,
preservando fora do crop.
Não existe homografia inversa da mask semantic porque a própria GT semantic no normalize
nunca foi homografada; ela apenas sofreu crop+resize no espaço de referência RGB/CAM_A.
"""
raw_mask_bgr = _ensure_bgr3(raw_mask_bgr)
pred_bgr = _ensure_bgr3(pred_bgr)
raw_h, raw_w = raw_mask_bgr.shape[:2]
model_h, model_w = pred_bgr.shape[:2]
ref_shape = fusion_result.get("ref_shape")
if isinstance(ref_shape, (list, tuple)) and len(ref_shape) == 2:
ref_h, ref_w = int(ref_shape[0]), int(ref_shape[1])
else:
ref_h, ref_w = raw_h, raw_w
crop_box = fusion_result.get("crop_box")
if crop_box is None or len(crop_box) != 4:
x0_ref, y0_ref, x1_ref, y1_ref = 0, 0, ref_w, ref_h
else:
x0_ref, y0_ref, x1_ref, y1_ref = [int(v) for v in crop_box]
x0_ref = max(0, min(ref_w - 1, x0_ref)) if ref_w > 0 else 0
y0_ref = max(0, min(ref_h - 1, y0_ref)) if ref_h > 0 else 0
x1_ref = max(x0_ref + 1, min(ref_w, x1_ref)) if ref_w > 0 else 1
y1_ref = max(y0_ref + 1, min(ref_h, y1_ref)) if ref_h > 0 else 1
crop_w_ref = x1_ref - x0_ref
crop_h_ref = y1_ref - y0_ref
if crop_w_ref <= 0 or crop_h_ref <= 0:
raise RuntimeError(f"crop_box inválido em {sample_key}: {crop_box}")
target_size = fusion_result.get("target_size")
if isinstance(target_size, (list, tuple)) and len(target_size) == 2:
target_w, target_h = int(target_size[0]), int(target_size[1])
if (target_w, target_h) != (model_w, model_h):
raise RuntimeError(
f"Prediction com shape incompatível em {sample_key}: "
f"pred={model_w}x{model_h} fusion.target_size={target_w}x{target_h}"
)
pred_crop_ref = cv2.resize(pred_bgr, (crop_w_ref, crop_h_ref), interpolation=cv2.INTER_NEAREST)
sx = float(raw_w) / float(ref_w) if ref_w > 0 else 1.0
sy = float(raw_h) / float(ref_h) if ref_h > 0 else 1.0
x0_raw = int(round(x0_ref * sx))
y0_raw = int(round(y0_ref * sy))
x1_raw = int(round(x1_ref * sx))
y1_raw = int(round(y1_ref * sy))
x0_raw = max(0, min(raw_w - 1, x0_raw)) if raw_w > 0 else 0
y0_raw = max(0, min(raw_h - 1, y0_raw)) if raw_h > 0 else 0
x1_raw = max(x0_raw + 1, min(raw_w, x1_raw)) if raw_w > 0 else 1
y1_raw = max(y0_raw + 1, min(raw_h, y1_raw)) if raw_h > 0 else 1
crop_w_raw = x1_raw - x0_raw
crop_h_raw = y1_raw - y0_raw
if crop_w_raw <= 0 or crop_h_raw <= 0:
raise RuntimeError(f"crop_box escalado inválido em {sample_key}: raw=({x0_raw},{y0_raw},{x1_raw},{y1_raw})")
if (crop_w_raw, crop_h_raw) != (crop_w_ref, crop_h_ref):
pred_crop_raw = cv2.resize(pred_crop_ref, (crop_w_raw, crop_h_raw), interpolation=cv2.INTER_NEAREST)
else:
pred_crop_raw = pred_crop_ref
composite = raw_mask_bgr.copy()
composite[y0_raw:y1_raw, x0_raw:x1_raw] = pred_crop_raw[:crop_h_raw, :crop_w_raw]
geom = {
"ref_shape": [int(ref_w), int(ref_h)],
"raw_shape": [int(raw_w), int(raw_h)],
"target_size": [int(model_w), int(model_h)],
"crop_box_ref": [int(x0_ref), int(y0_ref), int(x1_ref), int(y1_ref)],
"crop_box_raw": [int(x0_raw), int(y0_raw), int(x1_raw), int(y1_raw)],
}
return composite, geom
# ============================================================ # ============================================================
# Visualização/copiar casos de revisão # Visualização/copiar casos de revisão
# ============================================================ # ============================================================
@ -866,66 +1322,110 @@ def save_review_item(
chw: np.ndarray, chw: np.ndarray,
gt_sem: np.ndarray, gt_sem: np.ndarray,
pred_sem: np.ndarray, pred_sem: np.ndarray,
raw_source: dict,
review_group_root: Path, review_group_root: Path,
base_module, base_module,
semantic_cmap: Dict[int, Tuple[int, int, int]], semantic_cmap: Dict[int, Tuple[int, int, int]],
ignore_id: int, ignore_id: int,
input_channel_names: Sequence[str], input_channel_names: Sequence[str],
save_panels: bool, save_panels: bool,
) -> None: ) -> dict:
"""
Exporta exatamente o contrato simplificado para revisão humana:
raw_previews/
preview ORIGINAL em dataset/brutas (domínio real do sensor)
raw_masks/
máscara humana ORIGINAL no domínio real do sensor
predictions_raw/
candidata do modelo já convertida para o domínio RAW:
- parte do pred_sem no espaço final do modelo;
- desfaz apenas o caminho usado no normalize semantic GT
(resize final <- crop_box no espaço RGB/CAM_A);
- preserva a máscara humana fora da região válida do crop.
Assim a ferramenta humana trabalha sempre em UM único preview e DUAS masks,
todos no domínio final correto das brutas.
"""
group_dir = review_group_root / sample.group group_dir = review_group_root / sample.group
previews_dir = group_dir / "previews" raw_previews_dir = group_dir / "raw_previews"
masks_dir = group_dir / "masks" raw_masks_dir = group_dir / "raw_masks"
predictions_dir = group_dir / "predictions" predictions_raw_dir = group_dir / "predictions_raw"
final_masks_dir = group_dir / "final_masks"
panels_dir = group_dir / "panels" panels_dir = group_dir / "panels"
ensure_dir(previews_dir) for p in (raw_previews_dir, raw_masks_dir, predictions_raw_dir, final_masks_dir):
ensure_dir(masks_dir) ensure_dir(p)
ensure_dir(predictions_dir)
if save_panels: if save_panels:
ensure_dir(panels_dir) ensure_dir(panels_dir)
# Revisão humana usa somente PNG. Mesmo se o preview fonte for JPG, raw_preview_bgr = imread_raw_required(Path(raw_source["preview"]), cv2.IMREAD_COLOR)
# a saída é normalizada para <base>.png. raw_mask_native = imread_raw_required(Path(raw_source["mask"]), cv2.IMREAD_UNCHANGED)
preview_rgb = load_preview_rgb(sample, chw, base_module, input_channel_names) raw_mask_bgr = _ensure_bgr3(raw_mask_native)
cv2.imwrite(
str(previews_dir / f"{sample.base}.png"), raw_h, raw_w = raw_preview_bgr.shape[:2]
cv2.cvtColor(preview_rgb, cv2.COLOR_RGB2BGR), mask_h, mask_w = raw_mask_bgr.shape[:2]
) if (raw_h, raw_w) != (mask_h, mask_w):
raise RuntimeError(
f"Geometria RAW inválida em {sample.group}/{sample.base}: "
f"preview={raw_w}x{raw_h} mask={mask_w}x{mask_h}"
)
raw_preview_out = raw_previews_dir / f"{sample.base}.png"
raw_mask_out = raw_masks_dir / f"{sample.base}.png"
prediction_raw_out = predictions_raw_dir / f"{sample.base}.png"
write_png_required(raw_preview_out, raw_preview_bgr)
write_png_required(raw_mask_out, raw_mask_bgr)
gt_rgb = base_module.ids_to_rgb(gt_sem, semantic_cmap, ignore_id)
pred_rgb = base_module.ids_to_rgb(pred_sem, semantic_cmap, ignore_id) pred_rgb = base_module.ids_to_rgb(pred_sem, semantic_cmap, ignore_id)
pred_bgr = cv2.cvtColor(pred_rgb, cv2.COLOR_RGB2BGR)
cv2.imwrite( fusion_result, normalized_meta_path = load_fusion_result_for_sample(sample)
str(masks_dir / f"{sample.base}.png"), pred_raw_bgr, geom = compose_prediction_raw_from_fusion(
cv2.cvtColor(gt_rgb, cv2.COLOR_RGB2BGR), raw_mask_bgr=raw_mask_bgr,
) pred_bgr=pred_bgr,
cv2.imwrite( fusion_result=fusion_result,
str(predictions_dir / f"{sample.base}.png"), sample_key=f"{sample.group}/{sample.base}",
cv2.cvtColor(pred_rgb, cv2.COLOR_RGB2BGR),
) )
write_png_required(prediction_raw_out, pred_raw_bgr)
if save_panels: if save_panels:
# Normaliza tamanho do preview para GT/pred. raw_overlay_gt = cv2.addWeighted(raw_preview_bgr, 0.55, raw_mask_bgr, 0.45, 0.0)
h, w = gt_sem.shape[:2] raw_overlay_model = cv2.addWeighted(raw_preview_bgr, 0.55, pred_raw_bgr, 0.45, 0.0)
if preview_rgb.shape[:2] != (h, w):
preview_rgb = cv2.resize(preview_rgb, (w, h), interpolation=cv2.INTER_AREA)
diff = np.zeros_like(gt_rgb)
valid = gt_sem != ignore_id
bad = valid & (gt_sem != pred_sem)
diff[bad] = (255, 40, 40)
overlay_gt = base_module.overlay_rgb(preview_rgb, gt_rgb, 0.45)
overlay_pred = base_module.overlay_rgb(preview_rgb, pred_rgb, 0.45)
panels = [ panels = [
("Preview", preview_rgb, sample.base), ("RAW preview", cv2.cvtColor(raw_preview_bgr, cv2.COLOR_BGR2RGB), f"{raw_w}x{raw_h}"),
("GT semantic", overlay_gt, f"grupo={sample.group}"), ("RAW GT overlay", cv2.cvtColor(raw_overlay_gt, cv2.COLOR_BGR2RGB), "human/original"),
("Pred semantic", overlay_pred, f"score={float(row['review_score']):.3f}"), ("RAW model candidate", cv2.cvtColor(raw_overlay_model, cv2.COLOR_BGR2RGB), "model reprojected to raw"),
("GT != Pred", diff, str(row.get("review_reasons", ""))),
] ]
panel = base_module.compose_grid(panels, cols=2, max_width=1600) panel = base_module.compose_grid(panels, cols=2, max_width=1600)
cv2.imwrite(str(panels_dir / f"{sample.base}.png"), cv2.cvtColor(panel, cv2.COLOR_RGB2BGR)) write_png_required(panels_dir / f"{sample.base}.png", cv2.cvtColor(panel, cv2.COLOR_RGB2BGR))
return {
"group": str(sample.group),
"base": str(sample.base),
"raw_source_group": str(raw_source["group"]),
"raw_preview_source": str(raw_source["preview"]),
"raw_mask_source": str(raw_source["mask"]),
"normalized_meta_source": str(normalized_meta_path),
"raw_preview_output": str(raw_preview_out),
"raw_mask_output": str(raw_mask_out),
"prediction_raw_output": str(prediction_raw_out),
"raw_width": int(raw_w),
"raw_height": int(raw_h),
"model_width": int(pred_sem.shape[1]),
"model_height": int(pred_sem.shape[0]),
"ref_width": int(geom["ref_shape"][0]),
"ref_height": int(geom["ref_shape"][1]),
"target_size": list(geom["target_size"]),
"crop_box_ref": list(geom["crop_box_ref"]),
"crop_box_raw": list(geom["crop_box_raw"]),
"review_preview_domain": "raw_annotation_space",
"review_mask_domain": "raw_annotation_space",
"geometry_contract": "PREDICTION_RAW = HUMAN_RAW outside crop + MODEL prediction backprojected via normalize crop_box/resize inverse",
}
# ============================================================ # ============================================================
@ -966,6 +1466,14 @@ def main() -> None:
parser.add_argument("--test_script", default=None, help="Default: _9_test_multihead_v2.py ao lado deste script") parser.add_argument("--test_script", default=None, help="Default: _9_test_multihead_v2.py ao lado deste script")
parser.add_argument("--out_root", default="dataset/revisao") parser.add_argument("--out_root", default="dataset/revisao")
parser.add_argument(
"--raw_root",
default=None,
help=(
"Pasta dataset/brutas/group usada EXCLUSIVAMENTE como fonte de "
"raw_previews/raw_masks. Default: <config_dir>/dataset/brutas/group."
),
)
parser.add_argument("--run_name", default=None, help="Nome do relatório. Default: stem do checkpoint") parser.add_argument("--run_name", default=None, help="Nome do relatório. Default: stem do checkpoint")
parser.add_argument("--report_only", action="store_true", help="Não copia casos para dataset/revisao/group") parser.add_argument("--report_only", action="store_true", help="Não copia casos para dataset/revisao/group")
parser.add_argument( parser.add_argument(
@ -983,14 +1491,14 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--clear_review", "--clear_review",
action="store_true", action="store_true",
help="Limpa previews/masks/predictions/panels e PRESERVA final_masks.", help="Limpa artefatos regeneráveis RAW-SAFE/legados e PRESERVA final_masks.",
) )
parser.add_argument( parser.add_argument(
"--clear_review_all", "--clear_review_all",
action="store_true", action="store_true",
help="PERIGOSO: apaga toda a árvore group, inclusive final_masks.", help="PERIGOSO: apaga toda a árvore group, inclusive final_masks.",
) )
parser.add_argument("--save_panels", action="store_true", help="Salva painel preview/GT/pred/diff dos candidatos") parser.add_argument("--save_panels", action="store_true", help="Salva painel RAW GT + domínio alinhado/pred sem misturar geometrias")
# Critérios de revisão. São deliberadamente conservadores. # Critérios de revisão. São deliberadamente conservadores.
parser.add_argument("--high_confidence", type=float, default=0.85) parser.add_argument("--high_confidence", type=float, default=0.85)
@ -1112,6 +1620,48 @@ def main() -> None:
if args.max_samples > 0: if args.max_samples > 0:
samples = samples[: args.max_samples] samples = samples[: args.max_samples]
# --------------------------------------------------------
# Contrato RAW-SAFE para exportação física.
#
# As métricas continuam sendo calculadas no domínio normalizado/modelo.
# Já previews e masks para EDIÇÃO são buscados diretamente em BRUTAS.
# --------------------------------------------------------
raw_root: Optional[Path] = None
raw_source_map: Dict[Tuple[str, str], dict] = {}
raw_source_resolution: Dict[Tuple[str, str], str] = {}
if not args.report_only:
raw_root = (
resolve_path(args.raw_root, Path.cwd())
if args.raw_root
else (dataset_path / "brutas" / "group").resolve()
)
if raw_root is None or not raw_root.is_dir():
raise FileNotFoundError(
"RAW root obrigatório para revisão física RAW-SAFE não encontrado: "
f"{raw_root}"
)
raw_by_key, raw_by_base = index_raw_review_sources(raw_root)
fallback_count = 0
for sample in samples:
rec, mode = resolve_raw_review_source(
sample,
raw_by_key,
raw_by_base,
dataset_path,
)
key = (str(sample.group), str(sample.base))
raw_source_map[key] = rec
raw_source_resolution[key] = mode
if mode != "same_group":
fallback_count += 1
print(
f"[RAW-SAFE] raw_root={raw_root} | resolvidos={len(raw_source_map)}/{len(samples)} "
f"| fallback_base_unico={fallback_count}"
)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
threshold_from_model: Optional[float] = None threshold_from_model: Optional[float] = None
@ -1215,7 +1765,9 @@ def main() -> None:
n = len(samples) n = len(samples)
print("=" * 72) print("=" * 72)
print("DATASET REVIEW / MODEL MINING") print("DATASET REVIEW / MODEL MINING")
print(f"Root : {root}") print(f"Root model : {root}")
if not args.report_only:
print(f"Root RAW : {raw_root}")
print(f"Backend : {backend}") print(f"Backend : {backend}")
print(f"Model : {model_path}") print(f"Model : {model_path}")
if backend == "onnx": if backend == "onnx":
@ -1350,6 +1902,18 @@ def main() -> None:
"tensor_path": str(sample.tensor_path or ""), "tensor_path": str(sample.tensor_path or ""),
"preview_path": str(sample.preview_path or ""), "preview_path": str(sample.preview_path or ""),
"semantic_mask_path": str(sample.masks.get("semantic") or ""), "semantic_mask_path": str(sample.masks.get("semantic") or ""),
"raw_preview_path": (
str(raw_source_map.get((str(sample.group), str(sample.base)), {}).get("preview", ""))
if not args.report_only else ""
),
"raw_mask_path": (
str(raw_source_map.get((str(sample.group), str(sample.base)), {}).get("mask", ""))
if not args.report_only else ""
),
"raw_source_resolution": (
raw_source_resolution.get((str(sample.group), str(sample.base)), "")
if not args.report_only else ""
),
"inference_backend": backend, "inference_backend": backend,
"inference_provider": args.onnx_provider if backend == "onnx" else str(device), "inference_provider": args.onnx_provider if backend == "onnx" else str(device),
"inference_ms": float(t_inf), "inference_ms": float(t_inf),
@ -1462,8 +2026,12 @@ def main() -> None:
selected_keys = {(r["group"], r["base"]) for r in selected_rows} selected_keys = {(r["group"], r["base"]) for r in selected_rows}
# Copia apenas depois de finalizar ranking/caps. # Copia apenas depois de finalizar ranking/caps.
domain_manifest_rows: List[dict] = []
domain_manifest_path = review_group_root / "review_domain_manifest.csv"
domain_readme_path = review_group_root / "README_DOMAIN_CONTRACT.txt"
if not args.report_only: if not args.report_only:
print(f"\n[COPY] candidatos finais: {len(selected_rows)}") print(f"\n[COPY RAW-SAFE] candidatos finais: {len(selected_rows)}")
sample_by_key = {(s.group, s.base): s for s in samples} sample_by_key = {(s.group, s.base): s for s in samples}
for j, row in enumerate(selected_rows, 1): for j, row in enumerate(selected_rows, 1):
@ -1489,12 +2057,19 @@ def main() -> None:
pred_sem = preds["semantic"] pred_sem = preds["semantic"]
gt_sem = resize_ids(base.load_mask(sample.masks["semantic"]), pred_sem.shape[:2]) gt_sem = resize_ids(base.load_mask(sample.masks["semantic"]), pred_sem.shape[:2])
save_review_item( raw_source = raw_source_map.get((str(sample.group), str(sample.base)))
if raw_source is None:
raise RuntimeError(
f"Fonte RAW desapareceu durante exportação: {sample.group}/{sample.base}"
)
domain_row = save_review_item(
row=row, row=row,
sample=sample, sample=sample,
chw=chw, chw=chw,
gt_sem=gt_sem, gt_sem=gt_sem,
pred_sem=pred_sem, pred_sem=pred_sem,
raw_source=raw_source,
review_group_root=review_group_root, review_group_root=review_group_root,
base_module=base, base_module=base,
semantic_cmap=semantic_cmap, semantic_cmap=semantic_cmap,
@ -1502,6 +2077,10 @@ def main() -> None:
input_channel_names=input_channel_names, input_channel_names=input_channel_names,
save_panels=args.save_panels, save_panels=args.save_panels,
) )
domain_row["raw_source_resolution"] = raw_source_resolution.get(
(str(sample.group), str(sample.base)), ""
)
domain_manifest_rows.append(domain_row)
# Se veio do cache, removemos assim que foi exportado. Se foi reinferencia, # Se veio do cache, removemos assim que foi exportado. Se foi reinferencia,
# liberamos as referencias grandes imediatamente. # liberamos as referencias grandes imediatamente.
@ -1529,6 +2108,32 @@ def main() -> None:
sorted(grows, key=lambda r: int(r.get("review_rank_group", 999999))), sorted(grows, key=lambda r: int(r.get("review_rank_group", 999999))),
) )
write_csv(domain_manifest_path, domain_manifest_rows)
domain_readme_path.write_text(
"\n".join([
"REVIEW DOMAIN CONTRACT - RAW SAFE",
"",
"raw_previews/ + raw_masks/",
" -> espaco ORIGINAL de anotacao em dataset/brutas.",
" -> nenhum resize, crop, homografia ou warp e aplicado.",
"",
"predictions_raw/",
" -> candidata do modelo ja convertida para o espaco RAW.",
"",
"REGRA CRITICA:",
" NUNCA injete predictions/ alinhadas diretamente em brutas.",
" Esta versao ja faz a retroprojecao coerente com o normalize.",
"",
"Esta versao NAO cria previews/ e masks/ legados de proposito.",
"Um MaskReviewer antigo que espera previews/masks NAO deve ser usado neste output.",
"O reviewer RAW-safe deve editar/salvar final_masks no espaco RAW.",
"",
f"RAW root: {raw_root}",
f"Model root: {root}",
]),
encoding="utf-8",
)
# ======================================================== # ========================================================
# Relatórios globais # Relatórios globais
# ======================================================== # ========================================================
@ -1620,6 +2225,14 @@ def main() -> None:
"run_name": run_name, "run_name": run_name,
"config": str(config_path), "config": str(config_path),
"root": str(root), "root": str(root),
"raw_root": str(raw_root) if raw_root is not None else None,
"review_domain_contract": {
"raw_previews": "raw_annotation_space",
"raw_masks": "raw_annotation_space",
"predictions_raw": "raw_annotation_space",
"prediction_backprojection": "inverse_of_normalize_semantic_path_using_fusion.crop_box_plus_resize",
"cross_domain_resize_allowed": False,
},
"backend": backend, "backend": backend,
"model_path": str(model_path), "model_path": str(model_path),
"checkpoint": str(ckpt_path) if ckpt_path is not None else None, "checkpoint": str(ckpt_path) if ckpt_path is not None else None,
@ -1688,6 +2301,8 @@ def main() -> None:
"review_report": str(report_path), "review_report": str(report_path),
"review_candidates": str(candidates_path), "review_candidates": str(candidates_path),
"review_group_root": str(review_group_root), "review_group_root": str(review_group_root),
"review_domain_manifest": str(domain_manifest_path) if not args.report_only else None,
"review_domain_readme": str(domain_readme_path) if not args.report_only else None,
}, },
} }
@ -1707,7 +2322,9 @@ def main() -> None:
"- review_score serve apenas para RANKING de suspeitos.", "- review_score serve apenas para RANKING de suspeitos.",
"- suspicion_pct = review_score*100 e NAO e probabilidade de mascara errada.", "- suspicion_pct = review_score*100 e NAO e probabilidade de mascara errada.",
f"- export_mode={args.export_mode} min_suspicion_pct={args.min_suspicion_pct:.1f}", f"- export_mode={args.export_mode} min_suspicion_pct={args.min_suspicion_pct:.1f}",
"- Exportacao fisica usa somente PNG.", "- Exportacao fisica simplificada usa raw_previews + raw_masks + predictions_raw.",
"- predictions_raw ja volta no dominio RAW usando o inverso coerente do normalize semantic.",
"- NUNCA usar resize simples para converter entre o dominio final e o RAW.",
"- --clear_review PRESERVA final_masks; --clear_review_all apaga tudo.", "- --clear_review PRESERVA final_masks; --clear_review_all apaga tudo.",
"- Para comparar checkpoints, use --report_only e run_name diferentes.", "- Para comparar checkpoints, use --report_only e run_name diferentes.",
"", "",
@ -1740,7 +2357,9 @@ def main() -> None:
print(f"Candidates : {candidates_path}") print(f"Candidates : {candidates_path}")
print(f"Summary : {summary_path}") print(f"Summary : {summary_path}")
if not args.report_only: if not args.report_only:
print(f"Revisão física : {review_group_root}") print(f"Revisão física RAW-SAFE : {review_group_root}")
print(f"Domain manifest : {domain_manifest_path}")
print(f"Domain contract : {domain_readme_path}")
if directional: if directional:
print("\nConfusão semântica direcional global:") print("\nConfusão semântica direcional global:")

View File

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