#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ audit_multispec_dataset_bulletproof.py Auditoria parruda para dataset multiespectral OAK-FCC-3 / OAK-FCC-3P. Objetivo: Avaliar se o tensor final usado para treino/inferência está saudável o bastante para campo, com rastreabilidade por amostra, por grupo, por classe e por canal. Suporta: 1) Dataset original RAW multi-câmera: group//metas/*.json group//bins/*_CAM_A.bin, *_CAM_B.bin, *_CAM_C.bin group//masks/*.png/.npy 2) Dataset normalizado tensor_npy: dataset//group//metas/*.json dataset//group//tensors/*.npy dataset//group//masks/*.npy/.png 3) Dataset final com payload único .bin/.raw, quando meta traz saved_payload_path. Gera: - audit_summary.json - audit_health.json - audit_warnings.json - audit_samples.csv - audit_by_group.csv - audit_by_class_channel.csv - audit_by_sample_class_feature.csv - audit_core_telemetry.csv - audit_manifest_readme.txt - visuals/*.png - fixed/group/*, opcionalmente, com amostras aprovadas Exemplos: # Auditoria visual rápida no dataset normalizado python -m utils.audit_multispec_dataset_bulletproof ^ --input_path dataset/1024x640/group ^ --out_dir audit_out ^ --save-visuals --visual-every 20 # Revisão manual e criação do fixed/group python -m utils.audit_multispec_dataset_bulletproof ^ --input_path dataset/1024x640/group ^ --out_dir audit_out_manual ^ --manual-review --build-fixed-dataset --save-rejected-previews # Auditoria do RAW original reconstruindo o tensor com RawProcessorCore python -m utils.audit_multispec_dataset_bulletproof ^ --input_path dataset/original/group ^ --out_dir audit_raw_out ^ --save-visuals --visual-every 10 """ from __future__ import annotations import argparse import csv import hashlib import json import math import shutil import sys import time import unicodedata from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple import cv2 import numpy as np try: from core.raw_processor_core import RawProcessorCore except Exception: RawProcessorCore = None # ============================================================ # Constantes do contrato atual # ============================================================ CHANNELS = ["R", "G", "B", "RE", "NIR"] DERIVED = [ "NDVI", "NDRE", "NIR_minus_RE", "NIR_over_RE", "NIR_over_R", "RE_over_R", "NIR_over_G", "RE_over_G", "G_minus_R", ] ALL_FEATURES = CHANNELS + DERIVED DEFAULT_CLASS_MAP = { 0: "chao", 1: "cana", 2: "erva", } DEFAULT_MASK_COLOR_MAP_RGB = { (128, 0, 0): 0, # chao (0, 0, 128): 1, # cana (0, 128, 0): 2, # erva } DEFAULT_VIS_PALETTE_BGR = { 0: (0, 0, 128), 1: (128, 0, 0), 2: (0, 128, 0), 255: (0, 0, 0), } EPS = 1e-6 # ============================================================ # Helpers gerais # ============================================================ def ensure_dir(path: Path | str) -> Path: p = Path(path) p.mkdir(parents=True, exist_ok=True) return p def load_json(path: Path) -> dict: with path.open("r", encoding="utf-8") as f: return json.load(f) def write_json(path: Path, data: Any): ensure_dir(path.parent) with path.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def write_csv(path: Path, rows: List[Dict[str, Any]]): ensure_dir(path.parent) if not rows: path.write_text("", encoding="utf-8") return keys: List[str] = [] seen = set() for row in rows: for k in row.keys(): if k not in seen: seen.add(k) keys.append(k) with path.open("w", encoding="utf-8", newline="") as f: w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore") w.writeheader() w.writerows(rows) def safe_float(x: Any, default: float = 0.0) -> float: try: if x is None: return default v = float(x) if math.isnan(v) or math.isinf(v): return default return v except Exception: return default def safe_int(x: Any, default: int = 0) -> int: try: if x is None: return default return int(x) except Exception: return default def cv_text(text: Any) -> str: s = str(text) s = unicodedata.normalize("NFKD", s) return s.encode("ascii", "ignore").decode("ascii") def parse_class_map(text: Optional[str]) -> Dict[int, str]: if not text: return dict(DEFAULT_CLASS_MAP) out: Dict[int, str] = {} for item in str(text).split(","): item = item.strip() if not item: continue if ":" in item: k, v = item.split(":", 1) elif "=" in item: k, v = item.split("=", 1) else: raise ValueError(f"Classe inválida em --class-map: {item}") out[int(k.strip())] = v.strip() return out def parse_csv_set(text: Optional[str]) -> set: if not text: return set() return {x.strip().lower() for x in str(text).split(",") if x.strip()} def sha1_short(path: Path, max_bytes: int = 1024 * 1024) -> str: try: h = hashlib.sha1() with path.open("rb") as f: h.update(f.read(max_bytes)) return h.hexdigest()[:12] except Exception: return "" def safe_rel(path: Path, root: Path) -> str: try: return str(path.resolve().relative_to(root.resolve())).replace("\\", "/") except Exception: return str(path).replace("\\", "/") def flatten_dict(d: Any, prefix: str = "", max_depth: int = 4) -> Dict[str, Any]: out: Dict[str, Any] = {} if max_depth <= 0 or not isinstance(d, dict): return out for k, v in d.items(): key = f"{prefix}{k}" if not prefix else f"{prefix}.{k}" if isinstance(v, dict): out.update(flatten_dict(v, key, max_depth - 1)) elif isinstance(v, (str, int, float, bool)) or v is None: out[key] = v elif isinstance(v, (list, tuple)): if len(v) <= 8 and all(isinstance(x, (str, int, float, bool)) or x is None for x in v): out[key] = json.dumps(list(v), ensure_ascii=False) else: out[key] = f"list[{len(v)}]" else: out[key] = str(type(v).__name__) return out # ============================================================ # Visualização # ============================================================ def normalize_to_u8(x: np.ndarray, p_low: float = 1.0, p_high: float = 99.0) -> np.ndarray: arr = x.astype(np.float32, copy=False) finite = np.isfinite(arr) if not np.any(finite): return np.zeros(arr.shape, dtype=np.uint8) vals = arr[finite] lo = np.percentile(vals, p_low) hi = np.percentile(vals, p_high) if hi <= lo + EPS: hi = lo + 1.0 y = (arr - lo) / (hi - lo) return np.clip(y * 255.0, 0, 255).astype(np.uint8) def float01_to_u8(x: np.ndarray) -> np.ndarray: return np.clip(x.astype(np.float32) * 255.0, 0, 255).astype(np.uint8) def rgb_from_tensor(tensor: np.ndarray, stretch: bool = False) -> np.ndarray: rgb = np.transpose(tensor[:3], (1, 2, 0)).astype(np.float32) if stretch: rgb_u8 = np.dstack([normalize_to_u8(rgb[:, :, i]) for i in range(3)]) else: rgb_u8 = float01_to_u8(rgb) return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR) def apply_colormap_gray(x: np.ndarray, stretch: bool = True) -> np.ndarray: u8 = normalize_to_u8(x) if stretch else float01_to_u8(x) return cv2.applyColorMap(u8, cv2.COLORMAP_VIRIDIS) def put_label(img: np.ndarray, title: str, subtitle: str = "") -> np.ndarray: out = img.copy() title = cv_text(title) subtitle = cv_text(subtitle) header_h = 62 if subtitle else 38 cv2.rectangle(out, (0, 0), (out.shape[1], header_h), (0, 0, 0), -1) cv2.putText(out, title[:90], (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.63, (0, 255, 255), 2, cv2.LINE_AA) if subtitle: cv2.putText(out, subtitle[:130], (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.43, (255, 255, 255), 1, cv2.LINE_AA) return out def make_grid(panels: List[Tuple[str, np.ndarray, str]], panel_w: int = 380, cols: int = 3) -> np.ndarray: rendered: List[np.ndarray] = [] for title, img, subtitle in panels: if img.ndim == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) scale = panel_w / max(1, img.shape[1]) panel_h = max(1, int(img.shape[0] * scale)) small = cv2.resize(img, (panel_w, panel_h), interpolation=cv2.INTER_AREA) rendered.append(put_label(small, title, subtitle)) if not rendered: return np.zeros((240, 480, 3), dtype=np.uint8) max_h = max(x.shape[0] for x in rendered) padded = [] for img in rendered: if img.shape[0] < max_h: pad = np.zeros((max_h - img.shape[0], img.shape[1], 3), dtype=np.uint8) img = np.vstack([img, pad]) padded.append(img) gap_w = np.full((max_h, 12, 3), 25, dtype=np.uint8) row_w = cols * panel_w + (cols - 1) * 12 gap_h = np.full((12, row_w, 3), 25, dtype=np.uint8) rows = [] for i in range(0, len(padded), cols): row_imgs = padded[i:i + cols] while len(row_imgs) < cols: row_imgs.append(np.zeros_like(padded[0])) row = row_imgs[0] for img in row_imgs[1:]: row = np.hstack([row, gap_w, img]) rows.append(row) canvas = rows[0] for r in rows[1:]: canvas = np.vstack([canvas, gap_h, r]) return canvas # ============================================================ # Dataset discovery / leitura # ============================================================ def is_dataset_root(path: Path) -> bool: # original RAW: metas + bins if (path / "metas").is_dir() and (path / "bins").is_dir(): return True # normalizado novo: metas + tensors if (path / "metas").is_dir() and (path / "tensors").is_dir(): return True # dataset com metas + masks pode ser parcialmente auditável, mas precisa payload no meta if (path / "metas").is_dir() and ((path / "masks").is_dir() or (path / "previews").is_dir()): return True return False def find_dataset_roots(path: Path) -> List[Path]: p = path.resolve() candidates: List[Path] = [] if p.is_file(): candidates.extend([p.parent, p.parent.parent]) else: candidates.extend([p, p.parent]) roots: List[Path] = [] for c in candidates: root = c.parent if c.name.lower() in ("metas", "bins", "masks", "previews", "tensors") else c if is_dataset_root(root): roots.append(root) search_base = p if p.is_dir() else p.parent if not roots and search_base.exists(): for metas_dir in search_base.rglob("metas"): root = metas_dir.parent if is_dataset_root(root): roots.append(root) unique: List[Path] = [] seen = set() for r in roots: rr = r.resolve() if rr not in seen: seen.add(rr) unique.append(rr) if not unique: raise FileNotFoundError( f"Não consegui detectar dataset_root a partir de {path}. " "Esperado root/metas + root/bins ou root/tensors." ) return unique def list_meta_files(dataset_root: Path) -> List[Path]: metas = sorted((dataset_root / "metas").glob("*.json")) if not metas: raise RuntimeError(f"Nenhum .json encontrado em {dataset_root / 'metas'}") return metas def resolve_mask_path(dataset_root: Path, meta_path: Path, meta: Optional[dict] = None) -> Optional[Path]: meta = meta or {} candidates: List[Path] = [] # Caminho explícito do normalizador novo. for key in ("saved_mask_path", "mask_path"): val = meta.get(key) if val: p = Path(str(val)) candidates.extend([dataset_root / p, meta_path.parent / p, Path.cwd() / p]) head_masks = meta.get("head_masks", {}) if isinstance(meta.get("head_masks"), dict) else {} sem = head_masks.get("semantic", {}) if isinstance(head_masks.get("semantic"), dict) else {} if sem.get("path"): p = Path(str(sem.get("path"))) candidates.extend([dataset_root / p, meta_path.parent / p, Path.cwd() / p]) real_stem = meta_path.stem masks_dir = dataset_root / "masks" for ext in (".npy", ".png", ".tif", ".tiff"): candidates.append(masks_dir / f"{real_stem}{ext}") for suffix in ("_mask", "_gt", "_label", "_labels", "_seg"): for ext in (".npy", ".png", ".tif", ".tiff"): candidates.append(masks_dir / f"{real_stem}{suffix}{ext}") if masks_dir.is_dir(): candidates.extend(sorted(masks_dir.glob(f"{real_stem}*.*"))) for p in candidates: p = Path(p) if p.exists() and p.suffix.lower() in (".npy", ".png", ".tif", ".tiff"): return p return None def resolve_tensor_npy_path(meta_path: Path, dataset_root: Path, meta: dict) -> Optional[Path]: candidates: List[Path] = [] for key in ("saved_tensor_path", "tensor_path"): val = meta.get(key) if val: p = Path(str(val)) candidates.extend([dataset_root / p, meta_path.parent / p, Path.cwd() / p]) candidates.append(dataset_root / "tensors" / f"{meta_path.stem}.npy") for p in candidates: if p.exists() and p.suffix.lower() == ".npy": return p return None def resolve_tensor_payload(meta_path: Path, dataset_root: Path, meta: dict) -> Optional[Path]: stem = meta_path.stem candidates: List[Path] = [] bins = dataset_root / "bins" val = meta.get("saved_payload_path") if val: p = Path(str(val)) candidates.extend([bins / p.name, dataset_root / p, meta_path.parent / p, Path.cwd() / p]) candidates.extend([ bins / f"{stem}.raw", bins / f"{stem}.bin", bins / f"{stem}_multispec.raw", bins / f"{stem}_multispec.bin", bins / f"{stem}_offline_multispec.raw", bins / f"{stem}_offline_multispec.bin", ]) for c in candidates: if c.exists(): return c return None def resolve_camera_payloads(meta_path: Path, dataset_root: Path, meta: dict) -> Dict[str, Path]: stem = meta_path.stem bins = dataset_root / "bins" paths: Dict[str, Path] = {} saved_payload_paths = meta.get("saved_payload_paths", {}) or {} if not isinstance(saved_payload_paths, dict) or not saved_payload_paths: # Fallback pelo padrão de nome. for cam_id in ("CAM_A", "CAM_B", "CAM_C"): for ext in (".bin", ".raw"): p = bins / f"{stem}_{cam_id}{ext}" if p.exists(): paths[cam_id] = p break if paths: return paths raise FileNotFoundError(f"saved_payload_paths ausente e bins por câmera não encontrados para {meta_path.name}") for cam_id, fname in saved_payload_paths.items(): fp = Path(str(fname)) candidates = [ bins / fp.name, meta_path.parent / fp, dataset_root / fp, Path.cwd() / fp, bins / f"{stem}_{cam_id}.bin", bins / f"{stem}_{cam_id}.raw", bins / f"{stem}_{str(cam_id).lower()}.bin", bins / f"{stem}_{str(cam_id).lower()}.raw", ] found = None for c in candidates: if Path(c).exists(): found = Path(c) break if found is None: raise FileNotFoundError(f"Payload bruto não encontrado para {cam_id} em {meta_path.name}: {fname}") paths[str(cam_id)] = found return paths def resolve_module_params_path(meta_path: Path, dataset_root: Path, meta: dict) -> Optional[str]: candidates: List[Path] = [] for key in ("camera_params_json", "module_params_json"): val = meta.get(key) if val: p = Path(str(val)) if p.is_absolute(): candidates.append(p) else: candidates.extend([Path.cwd() / p, meta_path.parent / p, dataset_root / p, dataset_root.parent / p, dataset_root.parent.parent / p]) candidates.append(Path.cwd() / "calibration" / "module_params.json") for c in candidates: if c.exists(): return str(c) return None @dataclass class TensorLoadResult: tensor: np.ndarray meta: dict payload_path: Path source_kind: str core_telemetry: Dict[str, Any] = field(default_factory=dict) def get_raw_processor_core(core_cache: Dict[Tuple[int, int, str, str], Any], sensor_width: int, sensor_height: int, bayer: str, calib_path: str): key = (int(sensor_width), int(sensor_height), str(bayer).upper(), str(Path(calib_path).resolve())) if key not in core_cache: if RawProcessorCore is None: raise RuntimeError("RawProcessorCore não importado. Rode a partir da raiz do projeto e confira core/raw_processor_core.py.") core_cache[key] = RawProcessorCore( sensor_width=int(sensor_width), sensor_height=int(sensor_height), bayer_pattern=str(bayer).upper(), calibration_json_path=str(calib_path), ) return core_cache[key] def build_processing_meta_for_core(meta: dict) -> dict: stream_meta = dict(meta.get("stream_meta", {}) or {}) stream_meta["frame_type"] = "RAW_BRUTO" if "camera_info" not in stream_meta and isinstance(meta.get("camera_info"), dict): stream_meta["camera_info"] = meta.get("camera_info") for key in ("actual_camera_controls", "startup_camera_controls", "camera_controls", "radiometric_last_result"): if meta.get(key) is not None: stream_meta[key] = meta.get(key) return stream_meta def capture_core_telemetry(core: Any, normalized_meta: Optional[dict] = None) -> Dict[str, Any]: out: Dict[str, Any] = {} if core is not None: for name, attr in ( ("radiometric_normalization", "last_radiometric_normalization_result"), ("patch_normalization", "last_patch_normalization_result"), ("frame_quality", "last_frame_quality_result"), ("fusion", "last_fusion_result"), ("decode_perf", "last_decode_perf"), ): val = getattr(core, attr, None) if val is not None: try: json.dumps(val, default=str) out[name] = val except Exception: out[name] = str(val) if normalized_meta: processing = normalized_meta.get("processing", {}) if isinstance(normalized_meta.get("processing"), dict) else {} # Meta normalizado antigo/novo já pode carregar essas telemetrias. for src_key, dst_key in ( ("radiometric_normalization_result", "radiometric_normalization"), ("patch_normalization_result", "patch_normalization"), ("frame_quality", "frame_quality"), ("fusion_result", "fusion"), ): if src_key in processing and dst_key not in out: out[dst_key] = processing.get(src_key) return out def build_multispec_from_raw_native_multi(meta_path: Path, dataset_root: Path, meta: dict, core_cache: Dict[Tuple[int, int, str, str], Any]) -> TensorLoadResult: saved_dtypes = meta.get("saved_payload_dtypes", {}) or {} saved_shapes = meta.get("saved_payload_shapes", {}) or {} cam_paths = resolve_camera_payloads(meta_path, dataset_root, meta) frame: Dict[str, np.ndarray] = {} for cam_id, payload_path in cam_paths.items(): dtype = saved_dtypes.get(cam_id) shape = saved_shapes.get(cam_id) if dtype is None or shape is None: raise RuntimeError(f"Faltam saved_payload_dtypes/shapes para {cam_id} em {meta_path.name}") frame[cam_id] = np.fromfile(str(payload_path), dtype=np.dtype(dtype)).reshape(tuple(shape)) sensor_width = int(meta.get("sensor_width", 1280)) sensor_height = int(meta.get("sensor_height", 800)) bayer = str(meta.get("bayer_pattern", "BGGR")).upper() calib_path = resolve_module_params_path(meta_path, dataset_root, meta) if calib_path is None: raise FileNotFoundError(f"module_params.json não encontrado para {meta_path.name}") core = get_raw_processor_core(core_cache, sensor_width, sensor_height, bayer, calib_path) processing_meta = build_processing_meta_for_core(meta) tensor = core.build_infer_tensor_from_stream(frame, processing_meta, 5) if tensor is None: raise RuntimeError(f"RawProcessorCore retornou None para {meta_path.name}") tensor = np.asarray(tensor, dtype=np.float32) if tensor.ndim != 3: raise RuntimeError(f"Tensor reconstruído inválido: {meta_path.name} shape={tensor.shape}") if tensor.shape[0] != 5 and tensor.shape[-1] == 5: tensor = np.transpose(tensor, (2, 0, 1)) if tensor.shape[0] < 5: raise RuntimeError(f"Tensor precisa de 5 canais, recebido {tensor.shape}") first_payload = next(iter(cam_paths.values())) return TensorLoadResult( tensor=np.ascontiguousarray(tensor[:5]), meta=meta, payload_path=first_payload, source_kind="raw_native_multi_reconstructed", core_telemetry=capture_core_telemetry(core), ) def load_multispec_tensor(meta_path: Path, dataset_root: Path, core_cache: Dict[Tuple[int, int, str, str], Any]) -> TensorLoadResult: meta = load_json(meta_path) saved_type = str(meta.get("saved_payload_type") or "").lower() if saved_type == "raw_native_multi" or "saved_payload_paths" in meta: return build_multispec_from_raw_native_multi(meta_path, dataset_root, meta, core_cache) if saved_type == "tensor_npy" or meta.get("saved_tensor_path") or (dataset_root / "tensors" / f"{meta_path.stem}.npy").exists(): p = resolve_tensor_npy_path(meta_path, dataset_root, meta) if p is None: raise FileNotFoundError(f"Tensor .npy não encontrado para {meta_path.name}") arr = np.load(str(p)).astype(np.float32, copy=False) if arr.ndim != 3: raise RuntimeError(f"Tensor NPY inválido: {p} shape={arr.shape}") if arr.shape[0] != 5 and arr.shape[-1] == 5: arr = np.transpose(arr, (2, 0, 1)) if arr.shape[0] < 5: raise RuntimeError(f"Tensor NPY precisa de 5 canais: {p} shape={arr.shape}") return TensorLoadResult( tensor=np.ascontiguousarray(arr[:5]), meta=meta, payload_path=p, source_kind="tensor_npy", core_telemetry=capture_core_telemetry(None, normalized_meta=meta), ) payload_path = resolve_tensor_payload(meta_path, dataset_root, meta) if payload_path is None: raise FileNotFoundError(f"Payload tensor final não encontrado para {meta_path.name}") dtype = meta.get("saved_payload_dtype", "float32") shape = meta.get("saved_payload_shape") or meta.get("tensor_shape") or meta.get("shape") if shape is None: raise RuntimeError(f"Shape do tensor não encontrado em {meta_path.name}") arr = np.fromfile(str(payload_path), dtype=np.dtype(dtype)).reshape(tuple(shape)).astype(np.float32, copy=False) if arr.ndim != 3: raise RuntimeError(f"Tensor inválido em {payload_path}: shape={arr.shape}") if arr.shape[0] != 5 and arr.shape[-1] == 5: arr = np.transpose(arr, (2, 0, 1)) if arr.shape[0] < 5: raise RuntimeError(f"Tensor precisa de 5 canais [R,G,B,RE,NIR], recebido shape={arr.shape}") return TensorLoadResult( tensor=np.ascontiguousarray(arr[:5]), meta=meta, payload_path=payload_path, source_kind="tensor_binary_payload", core_telemetry=capture_core_telemetry(None, normalized_meta=meta), ) # ============================================================ # Máscaras # ============================================================ def decode_color_mask(mask_img: np.ndarray, ignore_index: int) -> np.ndarray: if mask_img.ndim == 2: return mask_img.astype(np.int32, copy=False) bgr = mask_img[:, :, :3] out = np.full(mask_img.shape[:2], ignore_index, dtype=np.int32) for rgb_color, cls_id in DEFAULT_MASK_COLOR_MAP_RGB.items(): r, g, b = rgb_color bgr_color = np.array([b, g, r], dtype=np.uint8) hit = np.all(bgr == bgr_color, axis=2) out[hit] = int(cls_id) # fallback caso algum pipeline já entregue RGB for rgb_color, cls_id in DEFAULT_MASK_COLOR_MAP_RGB.items(): rgb_arr = np.array(rgb_color, dtype=np.uint8) hit = np.all(bgr == rgb_arr, axis=2) out[(out == ignore_index) & hit] = int(cls_id) return out def load_mask(mask_path: Optional[Path], target_hw: Tuple[int, int], ignore_index: int) -> Optional[np.ndarray]: if mask_path is None: return None if mask_path.suffix.lower() == ".npy": mask = np.load(str(mask_path)) if mask.ndim == 3: mask = decode_color_mask(mask.astype(np.uint8), ignore_index) else: mask_img = cv2.imread(str(mask_path), cv2.IMREAD_UNCHANGED) if mask_img is None: raise RuntimeError(f"Falha ao ler máscara: {mask_path}") mask = decode_color_mask(mask_img, ignore_index) mask = mask.astype(np.int32, copy=False) h, w = target_hw if mask.shape[:2] != (h, w): mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST) return mask def mask_unique_summary(mask: Optional[np.ndarray], class_map: Dict[int, str], max_items: int = 20) -> str: if mask is None: return "none" vals, counts = np.unique(mask, return_counts=True) parts = [] for v, c in zip(vals[:max_items], counts[:max_items]): vi = int(v) name = class_map.get(vi, "ignore" if vi == 255 else "unk") parts.append(f"{vi}:{name}:{int(c)}") if len(vals) > max_items: parts.append("...") return ",".join(parts) def colorize_mask(mask: Optional[np.ndarray], class_map: Dict[int, str], target_hw: Tuple[int, int]) -> np.ndarray: h, w = target_hw out = np.zeros((h, w, 3), dtype=np.uint8) if mask is None: return out palette = dict(DEFAULT_VIS_PALETTE_BGR) for cls_id in np.unique(mask): ci = int(cls_id) if ci in palette: out[mask == ci] = palette[ci] elif ci in class_map: rng = np.random.default_rng(ci) out[mask == ci] = rng.integers(40, 220, size=3) return out def mask_edges_on_rgb(rgb_bgr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarray: out = rgb_bgr.copy() if mask is None: return out m = mask.astype(np.uint8) edges = cv2.Canny(m, 0, 1) out[edges > 0] = (0, 255, 255) return out # ============================================================ # Features / estatísticas # ============================================================ def compute_feature_maps(tensor: np.ndarray) -> Dict[str, np.ndarray]: r, g, b, re, nir = [tensor[i].astype(np.float32, copy=False) for i in range(5)] features = { "R": r, "G": g, "B": b, "RE": re, "NIR": nir, "NDVI": (nir - r) / (nir + r + EPS), "NDRE": (nir - re) / (nir + re + EPS), "NIR_minus_RE": nir - re, "NIR_over_RE": nir / (re + EPS), "NIR_over_R": nir / (r + EPS), "RE_over_R": re / (r + EPS), "NIR_over_G": nir / (g + EPS), "RE_over_G": re / (g + EPS), "G_minus_R": g - r, } for k in list(features.keys()): features[k] = np.nan_to_num(features[k], nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32) return features def calc_stats(values: np.ndarray, raw01: bool = False) -> Dict[str, float]: v = values.astype(np.float32, copy=False) v = v[np.isfinite(v)] if v.size == 0: return {k: 0.0 for k in ( "count", "mean", "std", "min", "p01", "p05", "p25", "p50", "p75", "p95", "p99", "max", "iqr", "p95_p05", "dark_pct", "sat_pct", "over_1_pct", "under_0_pct", "nan_pct" )} p = np.percentile(v, [1, 5, 25, 50, 75, 95, 99]) out = { "count": int(v.size), "mean": float(np.mean(v)), "std": float(np.std(v)), "min": float(np.min(v)), "p01": float(p[0]), "p05": float(p[1]), "p25": float(p[2]), "p50": float(p[3]), "p75": float(p[4]), "p95": float(p[5]), "p99": float(p[6]), "max": float(np.max(v)), "iqr": float(p[4] - p[2]), "p95_p05": float(p[5] - p[1]), "dark_pct": 0.0, "sat_pct": 0.0, "over_1_pct": 0.0, "under_0_pct": 0.0, "nan_pct": 0.0, } if raw01: out["dark_pct"] = float(np.mean(v <= 0.01) * 100.0) out["sat_pct"] = float(np.mean(v >= 0.99) * 100.0) out["over_1_pct"] = float(np.mean(v > 1.0) * 100.0) out["under_0_pct"] = float(np.mean(v < 0.0) * 100.0) return out @dataclass class RunningFeatureStats: values: Dict[str, List[float]] = field(default_factory=lambda: {f: [] for f in ALL_FEATURES}) counts: Dict[str, int] = field(default_factory=lambda: {f: 0 for f in ALL_FEATURES}) def add(self, feature_name: str, values: np.ndarray, max_samples: int = 25000): v = values.astype(np.float32, copy=False) v = v[np.isfinite(v)] if v.size == 0: return self.counts[feature_name] += int(v.size) if v.size > max_samples: idx = np.random.choice(v.size, size=max_samples, replace=False) v = v[idx] self.values[feature_name].extend(v.tolist()) def summarize(self) -> Dict[str, Dict[str, float]]: out = {} for f, vals in self.values.items(): arr = np.asarray(vals, dtype=np.float32) st = calc_stats(arr, raw01=(f in CHANNELS)) st["total_pixels_seen"] = int(self.counts.get(f, 0)) st["sampled_values"] = int(arr.size) out[f] = st return out # ============================================================ # Alinhamento / geometria # ============================================================ def gradient_mag(x: np.ndarray) -> np.ndarray: u8 = normalize_to_u8(x) gx = cv2.Sobel(u8, cv2.CV_32F, 1, 0, ksize=3) gy = cv2.Sobel(u8, cv2.CV_32F, 0, 1, ksize=3) return cv2.magnitude(gx, gy).astype(np.float32) def estimate_shift_phase(a: np.ndarray, b: np.ndarray) -> Tuple[float, float, float]: aa = normalize_to_u8(a).astype(np.float32) bb = normalize_to_u8(b).astype(np.float32) try: (dx, dy), response = cv2.phaseCorrelate(aa, bb) return float(dx), float(dy), float(response) except Exception: return 0.0, 0.0, 0.0 def edge_agreement(a: np.ndarray, b: np.ndarray) -> Dict[str, float]: ga = gradient_mag(a) gb = gradient_mag(b) va = ga.reshape(-1) vb = gb.reshape(-1) if np.std(va) < EPS or np.std(vb) < EPS: corr = 0.0 else: corr = float(np.corrcoef(va, vb)[0, 1]) dx, dy, resp = estimate_shift_phase(ga, gb) return { "edge_corr": corr, "phase_dx": dx, "phase_dy": dy, "phase_mag": float(math.hypot(dx, dy)), "phase_response": resp, } def make_edge_overlay(tensor: np.ndarray) -> np.ndarray: r, g, b, re, nir = [tensor[i] for i in range(5)] rgb_gray = (0.299 * r + 0.587 * g + 0.114 * b).astype(np.float32) e_rgb = normalize_to_u8(gradient_mag(rgb_gray), 5, 99) e_re = normalize_to_u8(gradient_mag(re), 5, 99) e_nir = normalize_to_u8(gradient_mag(nir), 5, 99) overlay = np.zeros((tensor.shape[1], tensor.shape[2], 3), dtype=np.uint8) overlay[:, :, 1] = e_rgb # verde overlay[:, :, 2] = e_re # vermelho overlay[:, :, 0] = e_nir # azul return overlay # ============================================================ # Core telemetry extraction # ============================================================ def get_nested(d: Any, path: str, default=None): cur = d for part in path.split("."): if not isinstance(cur, dict) or part not in cur: return default cur = cur[part] return cur def extract_core_metrics(core_telemetry: Dict[str, Any]) -> Dict[str, Any]: row: Dict[str, Any] = {} rad = core_telemetry.get("radiometric_normalization") or {} patch = core_telemetry.get("patch_normalization") or {} quality = core_telemetry.get("frame_quality") or {} fusion = core_telemetry.get("fusion") or {} if isinstance(rad, dict): row["radnorm_present"] = True row["radnorm_enabled"] = bool(rad.get("enabled", False)) row["radnorm_applied"] = bool(rad.get("applied", False)) row["radnorm_warning_count"] = len(rad.get("warnings", []) or []) row["radnorm_warnings"] = " ; ".join([str(x) for x in (rad.get("warnings", []) or [])]) summary = rad.get("summary", {}) if isinstance(rad.get("summary"), dict) else {} row["radnorm_scale_min"] = safe_float(summary.get("scale_min"), 0.0) row["radnorm_scale_max"] = safe_float(summary.get("scale_max"), 0.0) row["radnorm_scale_mean"] = safe_float(summary.get("scale_mean"), 0.0) by_role = rad.get("by_role", {}) if isinstance(rad.get("by_role"), dict) else {} for role in ("rgb", "re", "nir"): rr = by_role.get(role, {}) if isinstance(by_role.get(role), dict) else {} row[f"radnorm_{role}_scale"] = safe_float(rr.get("scale_applied"), 0.0) row[f"radnorm_{role}_actual_factor"] = safe_float(rr.get("actual_factor"), 0.0) row[f"radnorm_{role}_reference_factor"] = safe_float(rr.get("reference_factor"), 0.0) else: row["radnorm_present"] = False if isinstance(patch, dict): row["patchnorm_present"] = True row["patchnorm_enabled"] = bool(patch.get("enabled", False)) row["patchnorm_applied"] = bool(patch.get("applied", False)) row["patchnorm_warning_count"] = len(patch.get("warnings", []) or []) row["patchnorm_warnings"] = " ; ".join([str(x) for x in (patch.get("warnings", []) or [])]) summ = patch.get("summary", {}) if isinstance(patch.get("summary"), dict) else {} row["patchnorm_valid_channel_count"] = safe_int(summ.get("valid_channel_count"), 0) row["patchnorm_max_white_sat_pct"] = safe_float(summ.get("max_white_sat_pct"), 0.0) row["patchnorm_max_would_clip_pct"] = safe_float(summ.get("max_would_clip_pct"), 0.0) row["patchnorm_scale_min_applied"] = safe_float(summ.get("scale_min_applied"), 0.0) row["patchnorm_scale_max_applied"] = safe_float(summ.get("scale_max_applied"), 0.0) else: row["patchnorm_present"] = False if isinstance(quality, dict): row["core_quality_present"] = True row["core_quality_status"] = quality.get("status", "") row["core_usable_for_training"] = bool(quality.get("usable_for_training", True)) row["core_quality_reasons"] = " ; ".join([str(x) for x in (quality.get("reasons", []) or [])]) metrics = quality.get("metrics", {}) if isinstance(quality.get("metrics"), dict) else {} row["core_max_tensor_sat_pct"] = safe_float(metrics.get("max_tensor_sat_pct"), 0.0) row["core_max_tensor_dark_pct"] = safe_float(metrics.get("max_tensor_dark_pct"), 0.0) row["core_max_tensor_over_1_pct"] = safe_float(metrics.get("max_tensor_over_1_pct"), 0.0) row["core_max_tensor_under_0_pct"] = safe_float(metrics.get("max_tensor_under_0_pct"), 0.0) else: row["core_quality_present"] = False if isinstance(fusion, dict): row["fusion_present"] = True row["fusion_ref_shape"] = json.dumps(fusion.get("ref_shape"), ensure_ascii=False) row["fusion_output_shape"] = json.dumps(fusion.get("output_shape"), ensure_ascii=False) row["fusion_crop_box"] = json.dumps(fusion.get("crop_box"), ensure_ascii=False) row["fusion_crop_applied"] = bool(fusion.get("crop_applied", False)) row["fusion_direct_fast"] = bool(fusion.get("direct_fusion_fast", False)) profiles = fusion.get("homography_profiles_used", {}) if isinstance(profiles, dict): for role in ("re", "nir"): pr = profiles.get(role, {}) if isinstance(profiles.get(role), dict) else {} row[f"homography_{role}_profile"] = pr.get("profile", "") row[f"homography_{role}_calib_size"] = json.dumps(pr.get("calib_size"), ensure_ascii=False) perf = fusion.get("perf", {}) if isinstance(fusion.get("perf"), dict) else {} row["fusion_total_ms"] = safe_float(perf.get("total_ms"), 0.0) row["fusion_radnorm_ms"] = safe_float(perf.get("radnorm_ms"), 0.0) row["fusion_flat_ms"] = safe_float(perf.get("flat_ms"), 0.0) row["fusion_spatial_direct_ms"] = safe_float(perf.get("spatial_direct_ms"), 0.0) else: row["fusion_present"] = False return row # ============================================================ # Saúde por amostra / gates # ============================================================ @dataclass class HealthResult: score: float status: str approved: bool warnings: List[str] errors: List[str] notes: List[str] def evaluate_sample_health( row: Dict[str, Any], class_map: Dict[int, str], args, ) -> HealthResult: score = 100.0 warnings: List[str] = [] errors: List[str] = [] notes: List[str] = [] def warn(msg: str, penalty: float): nonlocal score warnings.append(msg) score -= penalty def err(msg: str, penalty: float): nonlocal score errors.append(msg) score -= penalty if not bool(row.get("tensor_valid", True)): err("tensor_invalid", 60) if not bool(row.get("mask_found", False)): err("missing_mask", 35) elif safe_float(row.get("mask_valid_pct"), 0.0) < args.min_valid_mask_pct: err(f"mask_valid_pct_low:{safe_float(row.get('mask_valid_pct')):.2f}%", 25) for ch in CHANNELS: sat = safe_float(row.get(f"{ch}_sat_pct"), 0.0) dark = safe_float(row.get(f"{ch}_dark_pct"), 0.0) dyn = safe_float(row.get(f"{ch}_p95_p05"), 0.0) mean = safe_float(row.get(f"{ch}_mean"), 0.0) over = safe_float(row.get(f"{ch}_over_1_pct"), 0.0) under = safe_float(row.get(f"{ch}_under_0_pct"), 0.0) if sat >= args.bad_sat_pct: err(f"{ch}:sat_bad:{sat:.2f}%", 18) elif sat >= args.warn_sat_pct: warn(f"{ch}:sat_warn:{sat:.2f}%", 5) if dark >= args.bad_dark_pct: err(f"{ch}:dark_bad:{dark:.2f}%", 18) elif dark >= args.warn_dark_pct: warn(f"{ch}:dark_warn:{dark:.2f}%", 5) if dyn <= args.bad_low_dynamic: err(f"{ch}:dynamic_bad:{dyn:.4f}", 14) elif dyn <= args.warn_low_dynamic: warn(f"{ch}:dynamic_warn:{dyn:.4f}", 4) if mean <= args.bad_mean_low or mean >= args.bad_mean_high: warn(f"{ch}:mean_extreme:{mean:.4f}", 4) if over > 0.01: warn(f"{ch}:over_1:{over:.3f}%", 4) if under > 0.01: warn(f"{ch}:under_0:{under:.3f}%", 4) # Alinhamento: alerta, não juiz absoluto. Canais espectrais podem ter textura diferente. for role in ("re", "nir"): mag = safe_float(row.get(f"{role}_phase_mag"), 0.0) corr = safe_float(row.get(f"{role}_edge_corr"), 0.0) resp = safe_float(row.get(f"{role}_phase_response"), 0.0) if mag >= args.bad_shift_px and resp >= args.min_phase_response_for_shift_gate: err(f"{role}:shift_bad:{mag:.2f}px resp={resp:.3f}", 16) elif mag >= args.warn_shift_px and resp >= args.min_phase_response_for_shift_gate: warn(f"{role}:shift_warn:{mag:.2f}px resp={resp:.3f}", 5) if corr < args.bad_edge_corr: warn(f"{role}:edge_corr_low:{corr:.3f}", 4) elif corr < args.warn_edge_corr: notes.append(f"{role}:edge_corr_warn:{corr:.3f}") # Core telemetry gates. if row.get("radnorm_present") is True: if bool(row.get("radnorm_enabled")) and not bool(row.get("radnorm_applied")): if args.require_radnorm_applied: err("radnorm_enabled_not_applied", 25) else: warn("radnorm_enabled_not_applied", 8) if safe_int(row.get("radnorm_warning_count"), 0) > 0: warn(f"radnorm_warnings:{row.get('radnorm_warnings', '')}", 6) for role in ("rgb", "re", "nir"): s = safe_float(row.get(f"radnorm_{role}_scale"), 0.0) if s > 0: if s < args.radnorm_scale_min_ok or s > args.radnorm_scale_max_ok: warn(f"radnorm_{role}_scale_out:{s:.3f}", 6) if row.get("patchnorm_present") is True: if bool(row.get("patchnorm_enabled")) and not bool(row.get("patchnorm_applied")): warn("patchnorm_enabled_not_applied", 6) if safe_int(row.get("patchnorm_warning_count"), 0) > 0: warn(f"patchnorm_warnings:{row.get('patchnorm_warnings', '')}", 5) if safe_float(row.get("patchnorm_max_would_clip_pct"), 0.0) >= args.bad_patch_clip_pct: err(f"patchnorm_clip_bad:{safe_float(row.get('patchnorm_max_would_clip_pct')):.2f}%", 15) if row.get("core_quality_present") is True: if str(row.get("core_quality_status", "")).lower() == "bad": err(f"core_quality_bad:{row.get('core_quality_reasons','')}", 35) elif str(row.get("core_quality_status", "")).lower() == "warning": warn(f"core_quality_warning:{row.get('core_quality_reasons','')}", 6) if row.get("core_usable_for_training") is False: err("core_not_usable_for_training", 35) # Tamanho mínimo de classes relevantes, quando existir no grupo/nome. total_px = max(1, safe_int(row.get("H"), 0) * safe_int(row.get("W"), 0)) group = str(row.get("group", "")).lower() if args.clean_min_target_pct > 0: if "cana" in group: pct = 100.0 * safe_float(row.get("pixels_cana"), 0.0) / total_px if pct < args.clean_min_target_pct * 100.0: warn(f"cana_pixels_low:{pct:.4f}%", 4) if "erva" in group: pct = 100.0 * safe_float(row.get("pixels_erva"), 0.0) / total_px if pct < args.clean_min_target_pct * 100.0: warn(f"erva_pixels_low:{pct:.4f}%", 4) score = float(max(0.0, min(100.0, score))) if errors: status = "bad" elif score < args.health_warning_score or warnings: status = "warning" else: status = "good" approved = status != "bad" if args.reject_warnings: approved = status == "good" return HealthResult(score=score, status=status, approved=approved, warnings=warnings, errors=errors, notes=notes) # ============================================================ # Visuais por amostra # ============================================================ def make_sample_visual( sample_name: str, tensor: np.ndarray, mask: Optional[np.ndarray], class_map: Dict[int, str], sample_stats: Dict[str, Any], health: Optional[HealthResult] = None, ) -> np.ndarray: features = compute_feature_maps(tensor) h, w = tensor.shape[1], tensor.shape[2] rgb = rgb_from_tensor(tensor, stretch=False) rgb_stretch = rgb_from_tensor(tensor, stretch=True) re = apply_colormap_gray(features["RE"], stretch=True) nir = apply_colormap_gray(features["NIR"], stretch=True) ndvi = apply_colormap_gray(features["NDVI"], stretch=True) ndre = apply_colormap_gray(features["NDRE"], stretch=True) diff = apply_colormap_gray(features["NIR_minus_RE"], stretch=True) ratio = apply_colormap_gray(features["NIR_over_RE"], stretch=True) edge = make_edge_overlay(tensor) mask_bgr = colorize_mask(mask, class_map, (h, w)) overlay_mask = rgb.copy() if mask is not None: overlay_mask = cv2.addWeighted(rgb, 0.65, mask_bgr, 0.35, 0) mask_edges = mask_edges_on_rgb(rgb, mask) align = sample_stats.get("alignment", {}) re_align = align.get("RGBgray_vs_RE", {}) nir_align = align.get("RGBgray_vs_NIR", {}) score_txt = "" if health is not None: score_txt = f"health={health.status} score={health.score:.1f}" panels = [ ("RGB tensor", rgb, f"{sample_name} | {score_txt}"), ("RGB stretch", rgb_stretch, "visual p1-p99"), ("GT mask", mask_bgr, f"unique={mask_unique_summary(mask, class_map)}"), ("Mask overlay", overlay_mask, "GT sobre RGB"), ("Mask edges", mask_edges, "bordas GT sobre RGB"), ("RE", re, "canal 3 | stretch p1-p99"), ("NIR", nir, "canal 4 | stretch p1-p99"), ("NDVI", ndvi, "(NIR-R)/(NIR+R)"), ("NDRE", ndre, "(NIR-RE)/(NIR+RE)"), ("NIR - RE", diff, "diferença direta"), ("NIR / RE", ratio, "razão com eps"), ( "Edge overlay", edge, f"G=RGB R=RE B=NIR | REcorr={safe_float(re_align.get('edge_corr')):.3f} NIRcorr={safe_float(nir_align.get('edge_corr')):.3f}", ), ] return make_grid(panels, panel_w=380, cols=3) # ============================================================ # Fixed dataset e revisão manual # ============================================================ def resolve_fixed_out_root(dataset_roots: List[Path], args) -> Path: if args.fixed_out_root: return Path(args.fixed_out_root).resolve() first = dataset_roots[0].resolve() if first.parent.name == "group": # dataset/original/group/chao -> dataset/fixed/group dataset_base = first.parent.parent.parent if first.parent.parent.name in ("original", "1024x640", "640x400") else first.parent.parent return dataset_base / "fixed" / "group" return first.parent / "fixed" / "group" def resolve_rejected_preview_root(fixed_root: Path) -> Path: return fixed_root.parent / "rejected_previews" if fixed_root.name == "group" else fixed_root / "_rejected_previews" def safe_copy_file(src: Path, dst: Path): if not src or not Path(src).exists(): return ensure_dir(dst.parent) shutil.copy2(str(src), str(dst)) def copy_sample_to_fixed(row: Dict[str, Any], fixed_root: Path, copy_previews: bool): dataset_root = Path(str(row["dataset_root"])) group = str(row["group"]) real_stem = str(row["real_stem"]) meta_path = Path(str(row["meta_path"])) dst_group = fixed_root / group safe_copy_file(meta_path, dst_group / "metas" / meta_path.name) mask_path = Path(str(row.get("mask_path", ""))) if str(mask_path) and mask_path.exists(): safe_copy_file(mask_path, dst_group / "masks" / mask_path.name) payload_path = Path(str(row.get("payload_path", ""))) if str(payload_path) and payload_path.exists(): if payload_path.suffix.lower() == ".npy" or "tensors" in payload_path.parts: safe_copy_file(payload_path, dst_group / "tensors" / payload_path.name) else: safe_copy_file(payload_path, dst_group / "bins" / payload_path.name) # Copia todos payloads RAW multi do meta, se existirem. try: meta = load_json(meta_path) saved_payload_paths = meta.get("saved_payload_paths", {}) or {} if isinstance(saved_payload_paths, dict): for _, fname in saved_payload_paths.items(): src = dataset_root / "bins" / Path(str(fname)).name safe_copy_file(src, dst_group / "bins" / src.name) saved_tensor_path = meta.get("saved_tensor_path") if saved_tensor_path: src = dataset_root / Path(str(saved_tensor_path)) if src.exists(): safe_copy_file(src, dst_group / "tensors" / src.name) except Exception: pass if copy_previews: for ext in (".png", ".jpg", ".jpeg", ".webp"): src = dataset_root / "previews" / f"{real_stem}{ext}" if src.exists(): safe_copy_file(src, dst_group / "previews" / src.name) def find_original_preview(dataset_root: Path, real_stem: str) -> Optional[Path]: previews_dir = dataset_root / "previews" for ext in (".png", ".jpg", ".jpeg", ".webp"): p = previews_dir / f"{real_stem}{ext}" if p.exists(): return p if previews_dir.is_dir(): matches: List[Path] = [] for ext in (".png", ".jpg", ".jpeg", ".webp"): matches.extend(previews_dir.glob(f"{real_stem}*{ext}")) if matches: return matches[0] return None def resize_preview_max_width(img: np.ndarray, max_width: int) -> np.ndarray: if img is None or img.size == 0 or max_width <= 0 or img.shape[1] <= max_width: return img scale = max_width / img.shape[1] return cv2.resize(img, (int(img.shape[1] * scale), int(img.shape[0] * scale)), interpolation=cv2.INTER_AREA) def draw_rejected_header(img: np.ndarray, row: Dict[str, Any]) -> np.ndarray: out = img.copy() h, w = out.shape[:2] header_h = 116 canvas = np.zeros((h + header_h, w, 3), dtype=np.uint8) canvas[:header_h, :] = (20, 20, 20) canvas[header_h:, :] = out line1 = cv_text(str(row.get("sample", "")))[:140] line2 = f"status={row.get('health_status','')} score={safe_float(row.get('health_score')):.1f} approved={row.get('approved_for_training','')}" line3 = cv_text(str(row.get("reject_reasons", row.get("health_errors", ""))))[:170] line4 = cv_text(str(row.get("health_warnings", "")))[:170] cv2.putText(canvas, line1, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.58, (0, 255, 255), 2, cv2.LINE_AA) cv2.putText(canvas, line2, (10, 52), cv2.FONT_HERSHEY_SIMPLEX, 0.50, (255, 255, 255), 1, cv2.LINE_AA) cv2.putText(canvas, line3, (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.43, (120, 220, 255), 1, cv2.LINE_AA) cv2.putText(canvas, line4, (10, 105), cv2.FONT_HERSHEY_SIMPLEX, 0.40, (160, 160, 255), 1, cv2.LINE_AA) return canvas def save_rejected_preview(row: Dict[str, Any], fixed_root: Path, args, tensor=None, mask=None, class_map=None, audit_panel=None): rejected_root = resolve_rejected_preview_root(fixed_root) group = str(row.get("group", "unknown")) dataset_root = Path(str(row.get("dataset_root", "."))) real_stem = str(row.get("real_stem", "sample")) dst_dir = ensure_dir(rejected_root / group) reason_tag = str(row.get("health_status", "rejected")) dst_path = dst_dir / f"{row.get('sample', real_stem)}__{reason_tag}.png" img = None mode = str(args.rejected_preview_source) if mode in ("auto", "audit_panel") and audit_panel is not None: img = audit_panel if img is None and mode in ("auto", "preview"): p = find_original_preview(dataset_root, real_stem) if p is not None: img = cv2.imread(str(p), cv2.IMREAD_COLOR) if img is None and tensor is not None and mode in ("auto", "rgb_tensor", "audit_panel"): img = rgb_from_tensor(tensor, stretch=False) if mask is not None and class_map is not None: mask_bgr = colorize_mask(mask, class_map, (tensor.shape[1], tensor.shape[2])) img = cv2.addWeighted(img, 0.70, mask_bgr, 0.30, 0) if img is None: img = np.zeros((360, 640, 3), dtype=np.uint8) img = resize_preview_max_width(img, int(args.rejected_preview_max_width)) img = draw_rejected_header(img, row) cv2.imwrite(str(dst_path), img) def manual_fit_to_width(img: np.ndarray, max_width: int) -> np.ndarray: if max_width <= 0 or img.shape[1] <= max_width: return img scale = max_width / img.shape[1] return cv2.resize(img, (int(img.shape[1] * scale), int(img.shape[0] * scale)), interpolation=cv2.INTER_AREA) def draw_manual_review_bar(canvas: np.ndarray, row: Dict[str, Any], idx: int, total: int) -> np.ndarray: h, w = canvas.shape[:2] bar_h = 112 out = np.zeros((h + bar_h, w, 3), dtype=np.uint8) out[:bar_h, :] = (18, 18, 18) out[bar_h:, :] = canvas line1 = f"[{idx}/{total}] group={row.get('group')} | status={row.get('health_status')} score={safe_float(row.get('health_score')):.1f} | {row.get('sample')}" line2 = "A/ENTER aprova | R rejeita | S pula | Q/ESC finaliza | 1 alinhamento | 2 mascara | 3 saturacao | 4 escuro | 5 classe | 6 duplicado" line3 = cv_text(str(row.get("health_errors", "")))[:180] line4 = cv_text(str(row.get("health_warnings", "")))[:180] cv2.putText(out, cv_text(line1)[:180], (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.56, (0, 255, 255), 2, cv2.LINE_AA) cv2.putText(out, cv_text(line2), (10, 53), cv2.FONT_HERSHEY_SIMPLEX, 0.49, (255, 255, 255), 1, cv2.LINE_AA) if line3: cv2.putText(out, line3, (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.42, (120, 220, 255), 1, cv2.LINE_AA) if line4: cv2.putText(out, line4, (10, 104), cv2.FONT_HERSHEY_SIMPLEX, 0.38, (160, 160, 255), 1, cv2.LINE_AA) return out def manual_review_decision(canvas: np.ndarray, row: Dict[str, Any], idx: int, total: int, args) -> Tuple[str, str]: window_name = str(args.manual_window_name) view = draw_manual_review_bar(canvas, row, idx, total) view = manual_fit_to_width(view, int(args.manual_window_width)) cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) cv2.imshow(window_name, view) reason_map = { ord("1"): "manual_reject_alignment", ord("2"): "manual_reject_mask", ord("3"): "manual_reject_saturation", ord("4"): "manual_reject_dark_dynamic", ord("5"): "manual_reject_wrong_class", ord("6"): "manual_reject_duplicate_or_bad_frame", } while True: key = cv2.waitKey(0) & 0xFF if key in (ord("a"), ord("A"), 13, 32): return "approved", "manual_approved" if key in (ord("r"), ord("R"), 8, 127): return "rejected", "manual_rejected" if key in reason_map: return "rejected", reason_map[key] if key in (ord("s"), ord("S")): return "skipped", "manual_skipped" if key in (ord("q"), ord("Q"), 27): return "quit", "manual_quit" # ============================================================ # Diagnóstico global # ============================================================ def summarize_by_group(sample_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: groups: Dict[str, List[Dict[str, Any]]] = {} for r in sample_rows: groups.setdefault(str(r.get("group", "unknown")), []).append(r) rows = [] for g, items in sorted(groups.items()): n = len(items) approved = sum(1 for r in items if bool(r.get("approved_for_training"))) bad = sum(1 for r in items if r.get("health_status") == "bad") warning = sum(1 for r in items if r.get("health_status") == "warning") row = { "group": g, "samples": n, "approved": approved, "approval_pct": 100.0 * approved / max(1, n), "bad": bad, "warning": warning, "mean_health_score": float(np.mean([safe_float(r.get("health_score")) for r in items])) if items else 0.0, } for cls_name in ("chao", "cana", "erva"): row[f"pixels_{cls_name}"] = int(sum(safe_int(r.get(f"pixels_{cls_name}"), 0) for r in items)) for ch in CHANNELS: row[f"{ch}_sat_pct_mean"] = float(np.mean([safe_float(r.get(f"{ch}_sat_pct")) for r in items])) row[f"{ch}_dark_pct_mean"] = float(np.mean([safe_float(r.get(f"{ch}_dark_pct")) for r in items])) row[f"{ch}_dyn_mean"] = float(np.mean([safe_float(r.get(f"{ch}_p95_p05")) for r in items])) rows.append(row) return rows def build_separability(class_summary: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: separability: Dict[str, Any] = {} class_items = list(class_summary.items()) for feature in ALL_FEATURES: vals = [] for cls_id, item in class_items: st = item.get("features", {}).get(feature, {}) vals.append((item.get("class_name", str(cls_id)), safe_float(st.get("mean")), safe_float(st.get("std")))) rows = [] for i in range(len(vals)): for j in range(i + 1, len(vals)): a_name, a_mean, a_std = vals[i] b_name, b_mean, b_std = vals[j] pooled = math.sqrt((a_std * a_std + b_std * b_std) / 2.0) + EPS d = abs(a_mean - b_mean) / pooled rows.append({"pair": f"{a_name}_vs_{b_name}", "effect_size_d": float(d), "mean_a": a_mean, "mean_b": b_mean}) separability[feature] = rows ranking = [] for feature, rows in separability.items(): if rows: ranking.append({"feature": feature, "avg_effect_size_d": float(np.mean([r["effect_size_d"] for r in rows]))}) ranking.sort(key=lambda x: x["avg_effect_size_d"], reverse=True) return separability, ranking def build_global_health(sample_rows: List[Dict[str, Any]], global_summary: Dict[str, Any], class_summary: Dict[str, Any], warnings: List[Dict[str, Any]], args) -> Dict[str, Any]: n = len(sample_rows) if n <= 0: return {"status": "bad", "score": 0.0, "verdict": "Nenhuma amostra processada."} approved = sum(1 for r in sample_rows if bool(r.get("approved_for_training"))) bad = sum(1 for r in sample_rows if r.get("health_status") == "bad") warning = sum(1 for r in sample_rows if r.get("health_status") == "warning") mean_score = float(np.mean([safe_float(r.get("health_score")) for r in sample_rows])) approval_pct = 100.0 * approved / n bad_pct = 100.0 * bad / n warning_pct = 100.0 * warning / n issues = [] positives = [] score = mean_score if approval_pct < args.dataset_min_approval_pct: issues.append(f"approval_pct abaixo do mínimo: {approval_pct:.1f}% < {args.dataset_min_approval_pct:.1f}%") score -= 15 else: positives.append(f"approval_pct bom: {approval_pct:.1f}%") if bad_pct > args.dataset_max_bad_pct: issues.append(f"bad_pct alto: {bad_pct:.1f}% > {args.dataset_max_bad_pct:.1f}%") score -= 15 if len(warnings) > 0: positives.append(f"auditoria gerou rastreabilidade com {len(warnings)} avisos/exceções") for ch in CHANNELS: st = global_summary.get(ch, {}) dyn = safe_float(st.get("p95_p05"), 0.0) sat = safe_float(st.get("sat_pct"), 0.0) dark = safe_float(st.get("dark_pct"), 0.0) if dyn < args.warn_low_dynamic: issues.append(f"{ch}: dinâmica global baixa {dyn:.4f}") score -= 4 if sat > args.warn_sat_pct: issues.append(f"{ch}: saturação global {sat:.2f}%") score -= 4 if dark > args.warn_dark_pct: issues.append(f"{ch}: dark global {dark:.2f}%") score -= 4 separability, ranking = build_separability(class_summary) if ranking: top = ranking[0] if top["avg_effect_size_d"] >= args.min_top_feature_effect_size: positives.append(f"separabilidade espectral útil: top={top['feature']} d≈{top['avg_effect_size_d']:.2f}") else: issues.append(f"separabilidade espectral fraca: top={top['feature']} d≈{top['avg_effect_size_d']:.2f}") score -= 8 score = float(max(0.0, min(100.0, score))) if bad_pct > args.dataset_max_bad_pct or approval_pct < args.dataset_min_approval_pct: status = "bad" verdict = "Dataset NÃO está pronto para campo/treino oficial sem limpeza ou revisão." elif issues or score < args.dataset_good_score: status = "warning" verdict = "Dataset utilizável com cautela; recomenda-se revisar avisos e rejeitados antes do treino oficial." else: status = "good" verdict = "Dataset saudável para treino/campo dentro dos critérios configurados." return { "status": status, "score": score, "verdict": verdict, "samples_total": n, "samples_approved": approved, "approval_pct": approval_pct, "bad_pct": bad_pct, "warning_pct": warning_pct, "mean_sample_health_score": mean_score, "issues": issues, "positives": positives, "feature_separability": separability, "feature_separability_ranking": ranking, } # ============================================================ # Auditoria principal # ============================================================ def audit_dataset(args): np.random.seed(args.seed) t_start = time.time() dataset_roots = find_dataset_roots(Path(args.input_path)) groups_except = parse_csv_set(args.groups_except) if groups_except: dataset_roots = [r for r in dataset_roots if r.name.lower() not in groups_except] out_dir = ensure_dir(args.out_dir) visuals_dir = ensure_dir(out_dir / "visuals") class_map = parse_class_map(args.class_map) entries: List[Tuple[Path, Path]] = [] for root in dataset_roots: for meta_path in list_meta_files(root): entries.append((root, meta_path)) if args.manual_start_index and args.manual_start_index > 1: entries = entries[int(args.manual_start_index) - 1:] if args.limit and args.limit > 0: entries = entries[:args.limit] global_acc = RunningFeatureStats() class_acc: Dict[int, RunningFeatureStats] = {cls: RunningFeatureStats() for cls in class_map.keys()} sample_rows: List[Dict[str, Any]] = [] by_sample_class_feature_rows: List[Dict[str, Any]] = [] core_rows: List[Dict[str, Any]] = [] warnings: List[Dict[str, Any]] = [] core_cache: Dict[Tuple[int, int, str, str], Any] = {} print(f"[INFO] dataset_roots={len(dataset_roots)}") for r in dataset_roots: print(f" - {r}") print(f"[INFO] amostras={len(entries)}") print(f"[INFO] out_dir={out_dir}") if args.manual_review: print("[MANUAL] A/ENTER aprova | R rejeita | S pula | Q sai | 1..6 rejeita com motivo") manual_stop = False for idx, (dataset_root, meta_path) in enumerate(entries, start=1): group_name = dataset_root.name stem = f"{group_name}__{meta_path.stem}" canvas = None try: load_result = load_multispec_tensor(meta_path, dataset_root, core_cache) tensor = load_result.tensor meta = load_result.meta payload_path = load_result.payload_path core_telemetry = load_result.core_telemetry h, w = int(tensor.shape[1]), int(tensor.shape[2]) features = compute_feature_maps(tensor) mask_path = resolve_mask_path(dataset_root, meta_path, meta) mask = load_mask(mask_path, (h, w), args.ignore_index) # Tensor base health. tensor_valid = tensor.ndim == 3 and tensor.shape[0] == 5 and np.all(np.isfinite(tensor)) tensor_nan_pct = float(np.mean(~np.isfinite(tensor)) * 100.0) sample_feature_stats: Dict[str, Dict[str, float]] = {} for fname, fmap in features.items(): st = calc_stats(fmap.reshape(-1), raw01=(fname in CHANNELS)) sample_feature_stats[fname] = st global_acc.add(fname, fmap.reshape(-1), max_samples=args.max_pixels_per_feature) class_pixel_counts: Dict[str, int] = {name: 0 for name in class_map.values()} mask_valid_pct = 0.0 if mask is not None: valid_mask = mask != args.ignore_index mask_valid_pct = float(np.mean(valid_mask) * 100.0) for cls_id, cls_name in class_map.items(): cm = (mask == cls_id) & valid_mask n = int(np.sum(cm)) class_pixel_counts[cls_name] = n if n < args.min_class_pixels: continue for fname, fmap in features.items(): vals = fmap[cm] class_acc[cls_id].add(fname, vals, max_samples=args.max_pixels_per_feature) st = calc_stats(vals, raw01=(fname in CHANNELS)) by_sample_class_feature_rows.append({ "sample": stem, "group": group_name, "class_id": cls_id, "class_name": cls_name, "feature": fname, **st, }) else: warnings.append({"sample": stem, "type": "missing_mask", "message": "Máscara ausente."}) r, g, b, re, nir = [tensor[i] for i in range(5)] rgb_gray = (0.299 * r + 0.587 * g + 0.114 * b).astype(np.float32) alignment = { "RGBgray_vs_RE": edge_agreement(rgb_gray, re), "RGBgray_vs_NIR": edge_agreement(rgb_gray, nir), "RE_vs_NIR": edge_agreement(re, nir), } core_metrics = extract_core_metrics(core_telemetry) row: Dict[str, Any] = { "idx": idx, "sample": stem, "group": group_name, "real_stem": meta_path.stem, "dataset_root": str(dataset_root), "meta_path": str(meta_path), "payload_path": str(payload_path), "payload_sha1_head": sha1_short(payload_path), "source_kind": load_result.source_kind, "mask_path": str(mask_path) if mask_path else "", "mask_found": bool(mask_path is not None), "mask_shape": str(mask.shape) if mask is not None else "", "mask_unique": mask_unique_summary(mask, class_map), "mask_valid_pct": mask_valid_pct, "H": h, "W": w, "tensor_shape": str(tensor.shape), "tensor_dtype": str(tensor.dtype), "tensor_valid": bool(tensor_valid), "tensor_nan_pct": tensor_nan_pct, "meta_ts": meta.get("ts") or get_nested(meta, "source_capture_meta.ts", ""), "sync_ok": get_nested(meta, "stream_meta.sync_ok", get_nested(meta, "source_capture_meta.stream_meta.sync_ok", "")), "sync_dt_ms": get_nested(meta, "stream_meta.sync_dt_ms", get_nested(meta, "source_capture_meta.stream_meta.sync_dt_ms", "")), "camera_params_json": meta.get("camera_params_json", ""), "schema": meta.get("schema", ""), } row.update(core_metrics) row["re_edge_corr"] = alignment["RGBgray_vs_RE"]["edge_corr"] row["nir_edge_corr"] = alignment["RGBgray_vs_NIR"]["edge_corr"] row["renir_edge_corr"] = alignment["RE_vs_NIR"]["edge_corr"] for key, al in (("re", alignment["RGBgray_vs_RE"]), ("nir", alignment["RGBgray_vs_NIR"]), ("renir", alignment["RE_vs_NIR"])): row[f"{key}_phase_dx"] = al["phase_dx"] row[f"{key}_phase_dy"] = al["phase_dy"] row[f"{key}_phase_mag"] = al["phase_mag"] row[f"{key}_phase_response"] = al["phase_response"] for fname in ALL_FEATURES: st = sample_feature_stats[fname] for k in ("mean", "std", "min", "p01", "p05", "p50", "p95", "p99", "max", "p95_p05", "iqr"): row[f"{fname}_{k}"] = st.get(k, 0.0) if fname in CHANNELS: for k in ("dark_pct", "sat_pct", "over_1_pct", "under_0_pct"): row[f"{fname}_{k}"] = st.get(k, 0.0) for cls_name, count in class_pixel_counts.items(): row[f"pixels_{cls_name}"] = count row[f"pct_{cls_name}"] = 100.0 * count / max(1, h * w) health = evaluate_sample_health(row, class_map, args) row["health_score"] = health.score row["health_status"] = health.status row["approved_for_training"] = bool(health.approved) row["health_warnings"] = " ; ".join(health.warnings) row["health_errors"] = " ; ".join(health.errors) row["health_notes"] = " ; ".join(health.notes) row["warnings_count"] = len(health.warnings) row["errors_count"] = len(health.errors) if health.warnings or health.errors: warnings.append({ "sample": stem, "type": "sample_health", "status": health.status, "score": health.score, "warnings": health.warnings, "errors": health.errors, }) sample_stats_for_visual = {"alignment": alignment, "features": sample_feature_stats} should_save_visual = args.save_visuals and ( args.visual_every <= 1 or idx % args.visual_every == 0 or health.status != "good" ) if should_save_visual or args.manual_review: canvas = make_sample_visual(stem, tensor, mask, class_map, sample_stats_for_visual, health=health) if should_save_visual and canvas is not None: cv2.imwrite(str(visuals_dir / f"{idx:05d}_{stem}.png"), canvas) if args.manual_review and canvas is not None: decision, reason = manual_review_decision(canvas, row, idx, len(entries), args) if decision == "quit": manual_stop = True row["manual_decision"] = "skipped" row["manual_reason"] = reason else: row["manual_decision"] = decision row["manual_reason"] = reason if decision == "approved": row["approved_for_training"] = True elif decision == "rejected": row["approved_for_training"] = False row["health_status"] = "bad" row["reject_reasons"] = reason print(f"[MANUAL] {idx}/{len(entries)} | {stem} | decisão={row.get('manual_decision')} | motivo={row.get('manual_reason')}") if args.save_rejected_previews and not bool(row.get("approved_for_training")): fixed_root_tmp = resolve_fixed_out_root(dataset_roots, args) row.setdefault("reject_reasons", row.get("health_errors") or row.get("health_warnings") or "rejected") save_rejected_preview(row, fixed_root_tmp, args, tensor=tensor, mask=mask, class_map=class_map, audit_panel=canvas) sample_rows.append(row) core_row = { "idx": idx, "sample": stem, "group": group_name, "source_kind": load_result.source_kind, **core_metrics, } core_rows.append(core_row) if idx % args.print_every == 0 or idx == len(entries): print(f"[OK] {idx}/{len(entries)} | {stem} | health={row['health_status']} score={row['health_score']:.1f}") if manual_stop: break except Exception as e: msg = str(e) warnings.append({"sample": stem, "type": "exception", "message": msg}) print(f"[ERRO] {idx}/{len(entries)} | {stem}: {msg}") if args.stop_on_error: raise if args.manual_review: cv2.destroyAllWindows() global_summary = global_acc.summarize() class_summary = { str(cls_id): {"class_name": class_map[cls_id], "features": acc.summarize()} for cls_id, acc in class_acc.items() } by_group_rows = summarize_by_group(sample_rows) global_health = build_global_health(sample_rows, global_summary, class_summary, warnings, args) summary = { "schema": "multispec_dataset_audit_bulletproof_v1", "created_at_unix": time.time(), "elapsed_s": time.time() - t_start, "input_path": str(args.input_path), "out_dir": str(out_dir), "dataset_roots": [str(r) for r in dataset_roots], "samples_processed": len(sample_rows), "channels": CHANNELS, "derived_features": DERIVED, "class_map": {str(k): v for k, v in class_map.items()}, "thresholds": vars(args), "global_health": global_health, "global_summary": global_summary, "class_summary": class_summary, } write_json(out_dir / "audit_summary.json", summary) write_json(out_dir / "audit_health.json", global_health) write_json(out_dir / "audit_warnings.json", warnings) write_csv(out_dir / "audit_samples.csv", sample_rows) write_csv(out_dir / "audit_by_group.csv", by_group_rows) write_csv(out_dir / "audit_by_sample_class_feature.csv", by_sample_class_feature_rows) write_csv(out_dir / "audit_core_telemetry.csv", core_rows) class_feature_rows: List[Dict[str, Any]] = [] for cls_id, item in class_summary.items(): for feature, st in item["features"].items(): class_feature_rows.append({"class_id": cls_id, "class_name": item["class_name"], "feature": feature, **st}) write_csv(out_dir / "audit_by_class_channel.csv", class_feature_rows) readme = f"""Auditoria Multiespectral - Bulletproof Veredito: {global_health.get('status')} | score={safe_float(global_health.get('score')):.1f} {global_health.get('verdict')} Arquivos principais: - audit_summary.json: resumo completo, thresholds e estatísticas globais. - audit_health.json: veredito direto do dataset. - audit_samples.csv: saúde e métricas por amostra. - audit_by_group.csv: resumo por grupo. - audit_by_class_channel.csv: estatísticas por classe e feature. - audit_by_sample_class_feature.csv: estatísticas por amostra/classe/feature. - audit_core_telemetry.csv: telemetria extraída do RawProcessorCore/meta normalizado. - audit_warnings.json: avisos e exceções detalhados. - visuals/: painéis visuais de amostras selecionadas ou com problemas. Observação: A métrica de phase correlation entre RGB/RE/NIR é tratada como alerta, não verdade absoluta. Canais espectrais podem ter textura diferente do RGB mesmo quando a homografia está boa. """ (out_dir / "audit_manifest_readme.txt").write_text(readme, encoding="utf-8") if args.build_fixed_dataset: fixed_root = resolve_fixed_out_root(dataset_roots, args) ensure_dir(fixed_root) approved_rows = [r for r in sample_rows if bool(r.get("approved_for_training"))] rejected_rows = [r for r in sample_rows if not bool(r.get("approved_for_training"))] for r in approved_rows: copy_sample_to_fixed(r, fixed_root, copy_previews=args.clean_copy_previews) write_csv(fixed_root / "fixed_manifest.csv", sample_rows) write_csv(fixed_root / "fixed_approved.csv", approved_rows) write_csv(fixed_root / "fixed_rejected.csv", rejected_rows) write_json(fixed_root / "fixed_summary.json", { "schema": "multispec_fixed_dataset_bulletproof_v1", "fixed_root": str(fixed_root), "samples_total": len(sample_rows), "samples_approved": len(approved_rows), "samples_rejected": len(rejected_rows), "approval_pct": 100.0 * len(approved_rows) / max(1, len(sample_rows)), "global_health": global_health, }) print(f"[FIXED] fixed/group: {fixed_root} | aprovadas={len(approved_rows)} rejeitadas={len(rejected_rows)}") print("\n========== AUDITORIA FINALIZADA ==========") print(f"Amostras processadas : {len(sample_rows)}") print(f"Warnings/exceções : {len(warnings)}") print(f"Status dataset : {global_health.get('status')} | score={safe_float(global_health.get('score')):.1f}") print(f"Veredito : {global_health.get('verdict')}") print(f"Resumo : {out_dir / 'audit_summary.json'}") print(f"CSV amostras : {out_dir / 'audit_samples.csv'}") print(f"CSV grupos : {out_dir / 'audit_by_group.csv'}") print(f"Visuais : {visuals_dir}") print("==========================================\n") # ============================================================ # CLI # ============================================================ def main(): parser = argparse.ArgumentParser(description="Auditoria parruda do tensor multiespectral [R,G,B,RE,NIR].") parser.add_argument("--input_path", required=True) parser.add_argument("--out_dir", default="audit_multispec_out") parser.add_argument("--class-map", default="0:chao,1:cana,2:erva") parser.add_argument("--ignore-index", type=int, default=255) parser.add_argument("--limit", type=int, default=0) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--stop-on-error", action="store_true") parser.add_argument("--print-every", type=int, default=10) parser.add_argument("--groups-except", default="") # Visual/manual parser.add_argument("--save-visuals", action="store_true") parser.add_argument("--visual-every", type=int, default=10) parser.add_argument("--manual-review", action="store_true") parser.add_argument("--manual-window-width", type=int, default=1500) parser.add_argument("--manual-window-name", default="Audit Multispec - Revisao Manual") parser.add_argument("--manual-start-index", type=int, default=1) # Stats/performance parser.add_argument("--min-class-pixels", type=int, default=50) parser.add_argument("--max-pixels-per-feature", type=int, default=25000) # Thresholds amostra parser.add_argument("--warn-sat-pct", type=float, default=1.0) parser.add_argument("--bad-sat-pct", type=float, default=5.0) parser.add_argument("--warn-dark-pct", type=float, default=35.0) parser.add_argument("--bad-dark-pct", type=float, default=75.0) parser.add_argument("--warn-low-dynamic", type=float, default=0.03) parser.add_argument("--bad-low-dynamic", type=float, default=0.015) parser.add_argument("--bad-mean-low", type=float, default=0.01) parser.add_argument("--bad-mean-high", type=float, default=0.99) parser.add_argument("--warn-shift-px", type=float, default=3.0) parser.add_argument("--bad-shift-px", type=float, default=22.0) parser.add_argument("--warn-edge-corr", type=float, default=0.08) parser.add_argument("--bad-edge-corr", type=float, default=0.03) parser.add_argument("--min-phase-response-for-shift-gate", type=float, default=0.05) parser.add_argument("--min-valid-mask-pct", type=float, default=95.0) parser.add_argument("--bad-patch-clip-pct", type=float, default=5.0) parser.add_argument("--health-warning-score", type=float, default=85.0) parser.add_argument("--reject-warnings", action="store_true") # Radiometria parser.add_argument("--require-radnorm-applied", action="store_true") parser.add_argument("--radnorm-scale-min-ok", type=float, default=0.15) parser.add_argument("--radnorm-scale-max-ok", type=float, default=3.0) # Dataset global parser.add_argument("--dataset-min-approval-pct", type=float, default=85.0) parser.add_argument("--dataset-max-bad-pct", type=float, default=10.0) parser.add_argument("--dataset-good-score", type=float, default=88.0) parser.add_argument("--min-top-feature-effect-size", type=float, default=0.25) # Fixed dataset parser.add_argument("--build-fixed-dataset", action="store_true") parser.add_argument("--fixed-out-root", default="") parser.add_argument("--clean-copy-previews", action="store_true") parser.add_argument("--clean-min-target-pct", type=float, default=0.0025) parser.add_argument("--save-rejected-previews", action="store_true") parser.add_argument("--rejected-preview-source", default="auto", choices=["auto", "preview", "audit_panel", "rgb_tensor"]) parser.add_argument("--rejected-preview-max-width", type=int, default=900) args = parser.parse_args() audit_dataset(args) if __name__ == "__main__": main()