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
# 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 ^
--out_dir audit_out_manual ^
--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
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>/
previews/<base>.png
masks/<base>.png # GT semantic colorido
predictions/<base>.png # predição semantic colorida
raw_previews/<base>.png # PREVIEW ORIGINAL de dataset/brutas, sem warp/resize
raw_masks/<base>.png # MASK ORIGINAL de dataset/brutas, sem warp/resize
predictions_raw/<base>.png # prediction já reprojetada para o domínio RAW
panels/<base>.png # opcional, --save_panels
final_masks/<base>.png # NÃO é tocado pelo auditor
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:
--export_mode flagged
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:
"""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():
review_group_root.mkdir(parents=True, exist_ok=True)
return
@ -171,7 +187,11 @@ def clear_generated_review(review_group_root: Path) -> None:
if not group_dir.is_dir():
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
if p.exists():
shutil.rmtree(p)
@ -180,6 +200,11 @@ def clear_generated_review(review_group_root: Path) -> None:
if order_csv.exists():
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):
if not script_path.is_file():
@ -611,6 +636,437 @@ def build_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
# ============================================================
@ -866,66 +1322,110 @@ def save_review_item(
chw: np.ndarray,
gt_sem: np.ndarray,
pred_sem: np.ndarray,
raw_source: dict,
review_group_root: Path,
base_module,
semantic_cmap: Dict[int, Tuple[int, int, int]],
ignore_id: int,
input_channel_names: Sequence[str],
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
previews_dir = group_dir / "previews"
masks_dir = group_dir / "masks"
predictions_dir = group_dir / "predictions"
raw_previews_dir = group_dir / "raw_previews"
raw_masks_dir = group_dir / "raw_masks"
predictions_raw_dir = group_dir / "predictions_raw"
final_masks_dir = group_dir / "final_masks"
panels_dir = group_dir / "panels"
ensure_dir(previews_dir)
ensure_dir(masks_dir)
ensure_dir(predictions_dir)
for p in (raw_previews_dir, raw_masks_dir, predictions_raw_dir, final_masks_dir):
ensure_dir(p)
if save_panels:
ensure_dir(panels_dir)
# Revisão humana usa somente PNG. Mesmo se o preview fonte for JPG,
# a saída é normalizada para <base>.png.
preview_rgb = load_preview_rgb(sample, chw, base_module, input_channel_names)
cv2.imwrite(
str(previews_dir / f"{sample.base}.png"),
cv2.cvtColor(preview_rgb, cv2.COLOR_RGB2BGR),
raw_preview_bgr = imread_raw_required(Path(raw_source["preview"]), cv2.IMREAD_COLOR)
raw_mask_native = imread_raw_required(Path(raw_source["mask"]), cv2.IMREAD_UNCHANGED)
raw_mask_bgr = _ensure_bgr3(raw_mask_native)
raw_h, raw_w = raw_preview_bgr.shape[:2]
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}"
)
gt_rgb = base_module.ids_to_rgb(gt_sem, semantic_cmap, ignore_id)
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)
pred_rgb = base_module.ids_to_rgb(pred_sem, semantic_cmap, ignore_id)
pred_bgr = cv2.cvtColor(pred_rgb, cv2.COLOR_RGB2BGR)
cv2.imwrite(
str(masks_dir / f"{sample.base}.png"),
cv2.cvtColor(gt_rgb, cv2.COLOR_RGB2BGR),
)
cv2.imwrite(
str(predictions_dir / f"{sample.base}.png"),
cv2.cvtColor(pred_rgb, cv2.COLOR_RGB2BGR),
fusion_result, normalized_meta_path = load_fusion_result_for_sample(sample)
pred_raw_bgr, geom = compose_prediction_raw_from_fusion(
raw_mask_bgr=raw_mask_bgr,
pred_bgr=pred_bgr,
fusion_result=fusion_result,
sample_key=f"{sample.group}/{sample.base}",
)
write_png_required(prediction_raw_out, pred_raw_bgr)
if save_panels:
# Normaliza tamanho do preview para GT/pred.
h, w = gt_sem.shape[:2]
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)
raw_overlay_gt = cv2.addWeighted(raw_preview_bgr, 0.55, raw_mask_bgr, 0.45, 0.0)
raw_overlay_model = cv2.addWeighted(raw_preview_bgr, 0.55, pred_raw_bgr, 0.45, 0.0)
panels = [
("Preview", preview_rgb, sample.base),
("GT semantic", overlay_gt, f"grupo={sample.group}"),
("Pred semantic", overlay_pred, f"score={float(row['review_score']):.3f}"),
("GT != Pred", diff, str(row.get("review_reasons", ""))),
("RAW preview", cv2.cvtColor(raw_preview_bgr, cv2.COLOR_BGR2RGB), f"{raw_w}x{raw_h}"),
("RAW GT overlay", cv2.cvtColor(raw_overlay_gt, cv2.COLOR_BGR2RGB), "human/original"),
("RAW model candidate", cv2.cvtColor(raw_overlay_model, cv2.COLOR_BGR2RGB), "model reprojected to raw"),
]
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("--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("--report_only", action="store_true", help="Não copia casos para dataset/revisao/group")
parser.add_argument(
@ -983,14 +1491,14 @@ def main() -> None:
parser.add_argument(
"--clear_review",
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(
"--clear_review_all",
action="store_true",
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.
parser.add_argument("--high_confidence", type=float, default=0.85)
@ -1112,6 +1620,48 @@ def main() -> None:
if args.max_samples > 0:
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")
threshold_from_model: Optional[float] = None
@ -1215,7 +1765,9 @@ def main() -> None:
n = len(samples)
print("=" * 72)
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"Model : {model_path}")
if backend == "onnx":
@ -1350,6 +1902,18 @@ def main() -> None:
"tensor_path": str(sample.tensor_path or ""),
"preview_path": str(sample.preview_path 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_provider": args.onnx_provider if backend == "onnx" else str(device),
"inference_ms": float(t_inf),
@ -1462,8 +2026,12 @@ def main() -> None:
selected_keys = {(r["group"], r["base"]) for r in selected_rows}
# 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:
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}
for j, row in enumerate(selected_rows, 1):
@ -1489,12 +2057,19 @@ def main() -> None:
pred_sem = preds["semantic"]
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,
sample=sample,
chw=chw,
gt_sem=gt_sem,
pred_sem=pred_sem,
raw_source=raw_source,
review_group_root=review_group_root,
base_module=base,
semantic_cmap=semantic_cmap,
@ -1502,6 +2077,10 @@ def main() -> None:
input_channel_names=input_channel_names,
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,
# liberamos as referencias grandes imediatamente.
@ -1529,6 +2108,32 @@ def main() -> None:
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
# ========================================================
@ -1620,6 +2225,14 @@ def main() -> None:
"run_name": run_name,
"config": str(config_path),
"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,
"model_path": str(model_path),
"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_candidates": str(candidates_path),
"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.",
"- 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}",
"- 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.",
"- Para comparar checkpoints, use --report_only e run_name diferentes.",
"",
@ -1740,7 +2357,9 @@ def main() -> None:
print(f"Candidates : {candidates_path}")
print(f"Summary : {summary_path}")
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:
print("\nConfusão semântica direcional global:")

View File

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