diff --git a/Python/OAK/datasets/oak-fcc-3/audit/audit_dataset_manual.py b/Python/OAK/datasets/oak-fcc-3/audit/audit_dataset_manual.py index af226bddd..d39d00f90 100644 --- a/Python/OAK/datasets/oak-fcc-3/audit/audit_dataset_manual.py +++ b/Python/OAK/datasets/oak-fcc-3/audit/audit_dataset_manual.py @@ -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 diff --git a/Python/OAK/datasets/oak-fcc-3/audit/review_dataset_multihead_v2.py b/Python/OAK/datasets/oak-fcc-3/audit/review_dataset_multihead_v2.py index 35c714220..169e1e7be 100644 --- a/Python/OAK/datasets/oak-fcc-3/audit/review_dataset_multihead_v2.py +++ b/Python/OAK/datasets/oak-fcc-3/audit/review_dataset_multihead_v2.py @@ -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// - previews/.png - masks/.png # GT semantic colorido - predictions/.png # predição semantic colorida + raw_previews/.png # PREVIEW ORIGINAL de dataset/brutas, sem warp/resize + raw_masks/.png # MASK ORIGINAL de dataset/brutas, sem warp/resize + predictions_raw/.png # prediction já reprojetada para o domínio RAW panels/.png # opcional, --save_panels final_masks/.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/. + + 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//tensors/.npy + # ↓ + # dataset/960x600/group//metas/.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 .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}" + ) + + 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_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: /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:") diff --git a/Python/OAK/datasets/oak-fcc-3/config.json b/Python/OAK/datasets/oak-fcc-3/config.json index 918d412b4..83c070913 100644 --- a/Python/OAK/datasets/oak-fcc-3/config.json +++ b/Python/OAK/datasets/oak-fcc-3/config.json @@ -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",