agrobot_base/Python/OAK/datasets/oak-fcc-3/utils/audit_dataset_manual.py

1945 lines
79 KiB
Python
Raw Normal View History

#!/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/<grupo>/metas/*.json
group/<grupo>/bins/*_CAM_A.bin, *_CAM_B.bin, *_CAM_C.bin
group/<grupo>/masks/*.png/.npy
2) Dataset normalizado tensor_npy:
dataset/<WxH>/group/<grupo>/metas/*.json
dataset/<WxH>/group/<grupo>/tensors/*.npy
dataset/<WxH>/group/<grupo>/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
2026-05-26 11:01:47 +00:00
import argparse
import csv
import hashlib
2026-05-26 11:01:47 +00:00
import json
import math
import shutil
import sys
import time
2026-05-26 11:01:47 +00:00
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
2026-05-26 11:01:47 +00:00
import cv2
import numpy as np
try:
from core.raw_processor_core import RawProcessorCore
except Exception:
RawProcessorCore = None
# ============================================================
# Constantes do contrato atual
2026-05-26 11:01:47 +00:00
# ============================================================
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),
2026-05-26 11:01:47 +00:00
255: (0, 0, 0),
}
EPS = 1e-6
# ============================================================
# Helpers gerais
2026-05-26 11:01:47 +00:00
# ============================================================
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:
2026-05-26 11:01:47 +00:00
return json.load(f)
def write_json(path: Path, data: Any):
ensure_dir(path.parent)
with path.open("w", encoding="utf-8") as f:
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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")
2026-05-26 11:01:47 +00:00
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(","):
2026-05-26 11:01:47 +00:00
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()}
2026-05-26 11:01:47 +00:00
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__)
2026-05-26 11:01:47 +00:00
return out
# ============================================================
# Visualização
# ============================================================
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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)])
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
if subtitle:
cv2.putText(out, subtitle[:130], (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.43, (255, 255, 255), 1, cv2.LINE_AA)
2026-05-26 11:01:47 +00:00
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] = []
2026-05-26 11:01:47 +00:00
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])
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
rows = []
2026-05-26 11:01:47 +00:00
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])
2026-05-26 11:01:47 +00:00
rows.append(row)
canvas = rows[0]
for r in rows[1:]:
canvas = np.vstack([canvas, gap_h, r])
return canvas
# ============================================================
# Dataset discovery / leitura
2026-05-26 11:01:47 +00:00
# ============================================================
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
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
if not unique:
raise FileNotFoundError(
f"Não consegui detectar dataset_root a partir de {path}. "
"Esperado root/metas + root/bins ou root/tensors."
2026-05-26 11:01:47 +00:00
)
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] = []
2026-05-26 11:01:47 +00:00
# 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])
2026-05-26 11:01:47 +00:00
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])
2026-05-26 11:01:47 +00:00
real_stem = meta_path.stem
masks_dir = dataset_root / "masks"
for ext in (".npy", ".png", ".tif", ".tiff"):
2026-05-26 11:01:47 +00:00
candidates.append(masks_dir / f"{real_stem}{ext}")
for suffix in ("_mask", "_gt", "_label", "_labels", "_seg"):
for ext in (".npy", ".png", ".tif", ".tiff"):
2026-05-26 11:01:47 +00:00
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"):
2026-05-26 11:01:47 +00:00
return p
return None
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
return None
def resolve_tensor_payload(meta_path: Path, dataset_root: Path, meta: dict) -> Optional[Path]:
stem = meta_path.stem
candidates: List[Path] = []
2026-05-26 11:01:47 +00:00
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])
2026-05-26 11:01:47 +00:00
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] = {}
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
for cam_id, fname in saved_payload_paths.items():
fp = Path(str(fname))
2026-05-26 11:01:47 +00:00
candidates = [
bins / fp.name,
meta_path.parent / fp,
dataset_root / fp,
Path.cwd() / fp,
2026-05-26 11:01:47 +00:00
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",
2026-05-26 11:01:47 +00:00
]
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
2026-05-26 11:01:47 +00:00
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")
2026-05-26 11:01:47 +00:00
for c in candidates:
if c.exists():
return str(c)
return None
2026-05-26 11:01:47 +00:00
@dataclass
class TensorLoadResult:
tensor: np.ndarray
meta: dict
payload_path: Path
source_kind: str
core_telemetry: Dict[str, Any] = field(default_factory=dict)
2026-05-26 11:01:47 +00:00
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()))
2026-05-26 11:01:47 +00:00
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.")
2026-05-26 11:01:47 +00:00
core_cache[key] = RawProcessorCore(
sensor_width=int(sensor_width),
sensor_height=int(sensor_height),
bayer_pattern=str(bayer).upper(),
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
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:
2026-05-26 11:01:47 +00:00
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] = {}
2026-05-26 11:01:47 +00:00
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))
2026-05-26 11:01:47 +00:00
sensor_width = int(meta.get("sensor_width", 1280))
sensor_height = int(meta.get("sensor_height", 800))
bayer = str(meta.get("bayer_pattern", "BGGR")).upper()
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
core = get_raw_processor_core(core_cache, sensor_width, sensor_height, bayer, calib_path)
processing_meta = build_processing_meta_for_core(meta)
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
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),
)
2026-05-26 11:01:47 +00:00
def load_multispec_tensor(meta_path: Path, dataset_root: Path, core_cache: Dict[Tuple[int, int, str, str], Any]) -> TensorLoadResult:
2026-05-26 11:01:47 +00:00
meta = load_json(meta_path)
saved_type = str(meta.get("saved_payload_type") or "").lower()
2026-05-26 11:01:47 +00:00
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),
)
2026-05-26 11:01:47 +00:00
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")
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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),
)
2026-05-26 11:01:47 +00:00
# ============================================================
# Máscaras
# ============================================================
2026-05-26 11:01:47 +00:00
def decode_color_mask(mask_img: np.ndarray, ignore_index: int) -> np.ndarray:
2026-05-26 11:01:47 +00:00
if mask_img.ndim == 2:
return mask_img.astype(np.int32, copy=False)
bgr = mask_img[:, :, :3]
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
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:
2026-05-26 11:01:47 +00:00
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)}")
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
# ============================================================
# Features / estatísticas
2026-05-26 11:01:47 +00:00
# ============================================================
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"
)}
2026-05-26 11:01:47 +00:00
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,
2026-05-26 11:01:47 +00:00
}
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)
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
return out
# ============================================================
# Alinhamento / geometria
2026-05-26 11:01:47 +00:00
# ============================================================
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)
2026-05-26 11:01:47 +00:00
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)),
2026-05-26 11:01:47 +00:00
"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
2026-05-26 11:01:47 +00:00
return overlay
# ============================================================
# Core telemetry extraction
2026-05-26 11:01:47 +00:00
# ============================================================
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
2026-05-26 11:01:47 +00:00
return row
2026-05-26 11:01:47 +00:00
# ============================================================
# 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
# ============================================================
2026-05-26 11:01:47 +00:00
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,
2026-05-26 11:01:47 +00:00
) -> 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}"
2026-05-26 11:01:47 +00:00
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"),
2026-05-26 11:01:47 +00:00
("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}",
2026-05-26 11:01:47 +00:00
),
]
return make_grid(panels, panel_w=380, cols=3)
2026-05-26 11:01:47 +00:00
# ============================================================
# Fixed dataset e revisão manual
2026-05-26 11:01:47 +00:00
# ============================================================
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"
2026-05-26 11:01:47 +00:00
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"
2026-05-26 11:01:47 +00:00
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))
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
safe_copy_file(meta_path, dst_group / "metas" / meta_path.name)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
# 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
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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:
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
def draw_rejected_header(img: np.ndarray, row: Dict[str, Any]) -> np.ndarray:
2026-05-26 11:01:47 +00:00
out = img.copy()
h, w = out.shape[:2]
header_h = 116
2026-05-26 11:01:47 +00:00
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]
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
return canvas
def save_rejected_preview(row: Dict[str, Any], fixed_root: Path, args, tensor=None, mask=None, class_map=None, audit_panel=None):
2026-05-26 11:01:47 +00:00
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"))
2026-05-26 11:01:47 +00:00
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"
2026-05-26 11:01:47 +00:00
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"):
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
return out
def manual_review_decision(canvas: np.ndarray, row: Dict[str, Any], idx: int, total: int, args) -> Tuple[str, str]:
2026-05-26 11:01:47 +00:00
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",
}
2026-05-26 11:01:47 +00:00
while True:
key = cv2.waitKey(0) & 0xFF
if key in (ord("a"), ord("A"), 13, 32):
return "approved", "manual_approved"
2026-05-26 11:01:47 +00:00
if key in (ord("r"), ord("R"), 8, 127):
return "rejected", "manual_rejected"
if key in reason_map:
return "rejected", reason_map[key]
2026-05-26 11:01:47 +00:00
if key in (ord("s"), ord("S")):
return "skipped", "manual_skipped"
2026-05-26 11:01:47 +00:00
if key in (ord("q"), ord("Q"), 27):
return "quit", "manual_quit"
2026-05-26 11:01:47 +00:00
# ============================================================
# Diagnóstico global
# ============================================================
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
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")
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
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."
2026-05-26 11:01:47 +00:00
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,
2026-05-26 11:01:47 +00:00
}
# ============================================================
# Auditoria principal
# ============================================================
2026-05-26 11:01:47 +00:00
def audit_dataset(args):
np.random.seed(args.seed)
t_start = time.time()
2026-05-26 11:01:47 +00:00
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]
2026-05-26 11:01:47 +00:00
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]] = []
2026-05-26 11:01:47 +00:00
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}")
2026-05-26 11:01:47 +00:00
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")
2026-05-26 11:01:47 +00:00
manual_stop = False
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
h, w = int(tensor.shape[1]), int(tensor.shape[2])
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
# 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]] = {}
2026-05-26 11:01:47 +00:00
for fname, fmap in features.items():
st = calc_stats(fmap.reshape(-1), raw01=(fname in CHANNELS))
sample_feature_stats[fname] = st
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
if mask is not None:
valid_mask = mask != args.ignore_index
mask_valid_pct = float(np.mean(valid_mask) * 100.0)
2026-05-26 11:01:47 +00:00
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({
2026-05-26 11:01:47 +00:00
"sample": stem,
"group": group_name,
2026-05-26 11:01:47 +00:00
"class_id": cls_id,
"class_name": cls_name,
"feature": fname,
**st,
2026-05-26 11:01:47 +00:00
})
else:
warnings.append({"sample": stem, "type": "missing_mask", "message": "Máscara ausente."})
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
row: Dict[str, Any] = {
2026-05-26 11:01:47 +00:00
"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,
2026-05-26 11:01:47 +00:00
"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,
2026-05-26 11:01:47 +00:00
"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", ""),
2026-05-26 11:01:47 +00:00
}
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"]
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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,
})
2026-05-26 11:01:47 +00:00
sample_stats_for_visual = {"alignment": alignment, "features": sample_feature_stats}
2026-05-26 11:01:47 +00:00
should_save_visual = args.save_visuals and (
args.visual_every <= 1 or idx % args.visual_every == 0 or health.status != "good"
2026-05-26 11:01:47 +00:00
)
if should_save_visual or args.manual_review:
canvas = make_sample_visual(stem, tensor, mask, class_map, sample_stats_for_visual, health=health)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
if decision == "quit":
manual_stop = True
2026-05-26 11:01:47 +00:00
row["manual_decision"] = "skipped"
row["manual_reason"] = reason
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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
2026-05-26 11:01:47 +00:00
except Exception as e:
msg = str(e)
warnings.append({"sample": stem, "type": "exception", "message": msg})
2026-05-26 11:01:47 +00:00
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()}
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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),
2026-05-26 11:01:47 +00:00
"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,
2026-05-26 11:01:47 +00:00
"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)
2026-05-26 11:01:47 +00:00
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")
2026-05-26 11:01:47 +00:00
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)}")
2026-05-26 11:01:47 +00:00
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')}")
2026-05-26 11:01:47 +00:00
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'}")
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
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)
2026-05-26 11:01:47 +00:00
args = parser.parse_args()
audit_dataset(args)
if __name__ == "__main__":
main()