2026-06-03 10:37:15 +00:00
|
|
|
#!/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
|
2026-06-03 10:37:15 +00:00
|
|
|
import hashlib
|
2026-05-26 11:01:47 +00:00
|
|
|
import json
|
|
|
|
|
import math
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-06-03 10:37:15 +00:00
|
|
|
# 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 = {
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-06-03 10:37:15 +00:00
|
|
|
# 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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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):
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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()
|
2026-06-03 10:37:15 +00:00
|
|
|
return {x.strip().lower() for x in str(text).split(",") if x.strip()}
|
2026-05-26 11:01:47 +00:00
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
# ============================================================
|
|
|
|
|
# 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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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]))
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-06-03 10:37:15 +00:00
|
|
|
# Dataset discovery / leitura
|
2026-05-26 11:01:47 +00:00
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_dataset_root(path: Path) -> bool:
|
2026-06-03 10:37:15 +00:00
|
|
|
# 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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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}. "
|
2026-06-03 10:37:15 +00:00
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +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"
|
2026-06-03 10:37:15 +00:00
|
|
|
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"):
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
2026-06-03 10:37:15 +00:00
|
|
|
return None
|
|
|
|
|
|
2026-05-26 11:01:47 +00:00
|
|
|
|
2026-06-03 10:37:15 +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
|
2026-06-03 10:37:15 +00:00
|
|
|
candidates: List[Path] = []
|
2026-05-26 11:01:47 +00:00
|
|
|
bins = dataset_root / "bins"
|
2026-06-03 10:37:15 +00:00
|
|
|
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"
|
2026-06-03 10:37:15 +00:00
|
|
|
paths: Dict[str, Path] = {}
|
2026-05-26 11:01:47 +00:00
|
|
|
saved_payload_paths = meta.get("saved_payload_paths", {}) or {}
|
2026-06-03 10:37:15 +00:00
|
|
|
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():
|
2026-06-03 10:37:15 +00:00
|
|
|
fp = Path(str(fname))
|
2026-05-26 11:01:47 +00:00
|
|
|
candidates = [
|
|
|
|
|
bins / fp.name,
|
2026-06-03 10:37:15 +00:00
|
|
|
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",
|
2026-06-03 10:37:15 +00:00
|
|
|
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}")
|
2026-06-03 10:37:15 +00:00
|
|
|
paths[str(cam_id)] = found
|
2026-05-26 11:01:47 +00:00
|
|
|
return paths
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
return None
|
2026-05-26 11:01:47 +00:00
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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),
|
2026-06-03 10:37:15 +00:00
|
|
|
bayer_pattern=str(bayer).upper(),
|
2026-05-26 11:01:47 +00:00
|
|
|
calibration_json_path=str(calib_path),
|
|
|
|
|
)
|
|
|
|
|
return core_cache[key]
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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)
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
frame: Dict[str, np.ndarray] = {}
|
2026-05-26 11:01:47 +00:00
|
|
|
for cam_id, payload_path in cam_paths.items():
|
2026-06-03 10:37:15 +00:00
|
|
|
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))
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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()))
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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")
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
# ============================================================
|
|
|
|
|
# Máscaras
|
|
|
|
|
# ============================================================
|
2026-05-26 11:01:47 +00:00
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
# 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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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]):
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
# ============================================================
|
2026-06-03 10:37:15 +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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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,
|
2026-06-03 10:37:15 +00:00
|
|
|
"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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-06-03 10:37:15 +00:00
|
|
|
# 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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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,
|
2026-06-03 10:37:15 +00:00
|
|
|
"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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-06-03 10:37:15 +00:00
|
|
|
# Core telemetry extraction
|
2026-05-26 11:01:47 +00:00
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
return row
|
2026-05-26 11:01:47 +00:00
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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],
|
2026-06-03 10:37:15 +00:00
|
|
|
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", {})
|
2026-06-03 10:37:15 +00:00
|
|
|
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 = [
|
2026-06-03 10:37:15 +00:00
|
|
|
("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,
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
),
|
|
|
|
|
]
|
2026-06-03 10:37:15 +00:00
|
|
|
return make_grid(panels, panel_w=380, cols=3)
|
2026-05-26 11:01:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-06-03 10:37:15 +00:00
|
|
|
# Fixed dataset e revisão manual
|
2026-05-26 11:01:47 +00:00
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
safe_copy_file(meta_path, dst_group / "metas" / meta_path.name)
|
2026-05-26 11:01:47 +00:00
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +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
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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]
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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"))
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
2026-06-03 10:37:15 +00:00
|
|
|
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]
|
2026-06-03 10:37:15 +00:00
|
|
|
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]
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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):
|
2026-06-03 10:37:15 +00:00
|
|
|
return "approved", "manual_approved"
|
2026-05-26 11:01:47 +00:00
|
|
|
if key in (ord("r"), ord("R"), 8, 127):
|
2026-06-03 10:37:15 +00:00
|
|
|
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")):
|
2026-06-03 10:37:15 +00:00
|
|
|
return "skipped", "manual_skipped"
|
2026-05-26 11:01:47 +00:00
|
|
|
if key in (ord("q"), ord("Q"), 27):
|
2026-06-03 10:37:15 +00:00
|
|
|
return "quit", "manual_quit"
|
2026-05-26 11:01:47 +00:00
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
# ============================================================
|
|
|
|
|
# Diagnóstico global
|
|
|
|
|
# ============================================================
|
2026-05-26 11:01:47 +00:00
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
|
2026-06-03 10:37:15 +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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
# ============================================================
|
|
|
|
|
# Auditoria principal
|
|
|
|
|
# ============================================================
|
2026-05-26 11:01:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def audit_dataset(args):
|
|
|
|
|
np.random.seed(args.seed)
|
2026-06-03 10:37:15 +00:00
|
|
|
t_start = time.time()
|
2026-05-26 11:01:47 +00:00
|
|
|
|
|
|
|
|
dataset_roots = find_dataset_roots(Path(args.input_path))
|
2026-06-03 10:37:15 +00:00
|
|
|
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()}
|
2026-06-03 10:37:15 +00:00
|
|
|
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)}")
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
print("[MANUAL] A/ENTER aprova | R rejeita | S pula | Q sai | 1..6 rejeita com motivo")
|
2026-05-26 11:01:47 +00:00
|
|
|
|
2026-06-03 10:37:15 +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}"
|
2026-06-03 10:37:15 +00:00
|
|
|
canvas = None
|
2026-05-26 11:01:47 +00:00
|
|
|
try:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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():
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
st = calc_stats(vals, raw01=(fname in CHANNELS))
|
|
|
|
|
by_sample_class_feature_rows.append({
|
2026-05-26 11:01:47 +00:00
|
|
|
"sample": stem,
|
2026-06-03 10:37:15 +00:00
|
|
|
"group": group_name,
|
2026-05-26 11:01:47 +00:00
|
|
|
"class_id": cls_id,
|
|
|
|
|
"class_name": cls_name,
|
|
|
|
|
"feature": fname,
|
2026-06-03 10:37:15 +00:00
|
|
|
**st,
|
2026-05-26 11:01:47 +00:00
|
|
|
})
|
|
|
|
|
else:
|
2026-06-03 10:37:15 +00:00
|
|
|
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),
|
|
|
|
|
}
|
2026-06-03 10:37:15 +00:00
|
|
|
core_metrics = extract_core_metrics(core_telemetry)
|
2026-05-26 11:01:47 +00:00
|
|
|
|
2026-06-03 10:37:15 +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),
|
2026-06-03 10:37:15 +00:00
|
|
|
"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 "",
|
2026-06-03 10:37:15 +00:00
|
|
|
"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,
|
2026-06-03 10:37:15 +00:00
|
|
|
"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
|
|
|
}
|
2026-06-03 10:37:15 +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]
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
2026-06-03 10:37:15 +00:00
|
|
|
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
|
|
|
|
2026-06-03 10:37:15 +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 (
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
decision, reason = manual_review_decision(canvas, row, idx, len(entries), args)
|
2026-05-26 11:01:47 +00:00
|
|
|
if decision == "quit":
|
2026-06-03 10:37:15 +00:00
|
|
|
manual_stop = True
|
2026-05-26 11:01:47 +00:00
|
|
|
row["manual_decision"] = "skipped"
|
2026-06-03 10:37:15 +00:00
|
|
|
row["manual_reason"] = reason
|
2026-05-26 11:01:47 +00:00
|
|
|
else:
|
|
|
|
|
row["manual_decision"] = decision
|
2026-06-03 10:37:15 +00:00
|
|
|
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):
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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 = {
|
2026-06-03 10:37:15 +00:00
|
|
|
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()
|
|
|
|
|
}
|
2026-06-03 10:37:15 +00:00
|
|
|
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 = {
|
2026-06-03 10:37:15 +00:00
|
|
|
"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()},
|
2026-06-03 10:37:15 +00:00
|
|
|
"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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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)
|
2026-06-03 10:37:15 +00:00
|
|
|
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:
|
2026-06-03 10:37:15 +00:00
|
|
|
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)}")
|
2026-06-03 10:37:15 +00:00
|
|
|
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'}")
|
2026-06-03 10:37:15 +00:00
|
|
|
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():
|
2026-06-03 10:37:15 +00:00
|
|
|
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")
|
2026-06-03 10:37:15 +00:00
|
|
|
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()
|