From c591e53d67260961123be7c2c0e3c42033d0e45c Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Wed, 3 Jun 2026 07:37:15 -0300 Subject: [PATCH] ajustes no sistema de treinamento multiespectral e perfil de homografia --- .gitignore | 3 +- .../camera_worker/camera_multispectral.py | 338 +++ .../oak_fcc3_core/raw_processor_core.py | 212 +- .../oak-fcc-3/_1_weeds_pair_sorter.py | 826 ++++-- Python/OAK/datasets/oak-fcc-3/_6_normalize.py | 1 + .../oak-fcc-3/_6b_copy_paste_tensor_aug.py | 1429 ++++++++++ Python/OAK/datasets/oak-fcc-3/_7_split.py | 1248 ++++++--- .../datasets/oak-fcc-3/_8_train_multihead.py | 124 +- .../calibration/convert_dataset_to_depthai.py | 257 ++ .../calibration/filter_depthai_dataset.py | 307 ++ .../oak-fcc-3/calibration/module_params.json | 213 +- Python/OAK/datasets/oak-fcc-3/config.json | 6 +- .../oak-fcc-3/core/raw_processor_core.py | 212 +- .../oak-fcc-3/depth_calibration_multi.py | 663 ++++- .../oak-fcc-3/utils/audit_dataset_manual.py | 2459 ++++++++--------- 15 files changed, 6195 insertions(+), 2103 deletions(-) create mode 100644 Python/OAK/datasets/oak-fcc-3/_6b_copy_paste_tensor_aug.py create mode 100644 Python/OAK/datasets/oak-fcc-3/calibration/convert_dataset_to_depthai.py create mode 100644 Python/OAK/datasets/oak-fcc-3/calibration/filter_depthai_dataset.py diff --git a/.gitignore b/.gitignore index 9b3a21680..ae935a783 100644 --- a/.gitignore +++ b/.gitignore @@ -69,9 +69,10 @@ Python/OAK/datasets/oak-fcc-3/backup/ Python/OAK/datasets/oak-fcc-3/dataset/ Python/OAK/datasets/oak-fcc-3/audit_multispec_out/ Python/OAK/datasets/oak-fcc-3/depth_probe_out/ -Python/OAK/datasets/oak-fcc-3/calibration/multicam_charuco_calib_out/debug/ +Python/OAK/datasets/oak-fcc-3/calibration/multicam_charuco_calib_out/ Python/OAK/datasets/oak-fcc-3/calibration/stereo_charuco_calib_out/debug/ Python/OAK/datasets/oak-fcc-3/calibration/stereo_dataset/ +Python/OAK/datasets/oak-fcc-3/calibration/dataset_homography/ Python/OAK/datasets/oak-fcc-3/.cache/ Python/OAK/datasets/gal5000/dataset/ Python/OAK/datasets/gal5000/backup/ diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py index f316d2a08..7631ade0f 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py @@ -1,7 +1,9 @@ +import json import os import time import threading from pathlib import Path +from datetime import datetime import cv2 import numpy as np @@ -1045,3 +1047,339 @@ class CameraMultispectral: return out + + # ============================================================ + # Salvamento científico / pós-processamento + # ============================================================ + + def _ts_name(self) -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + + def requisitar_bundle_raw_multispec(self, force: bool = True, max_age_s: float = None): + """ + Retorna um bundle científico RAW_BRUTO completo. + + Retorno: + bundle, resultado + + bundle = { + "raw_frame": { + "CAM_A": np.ndarray RAW10 packed, + "CAM_B": np.ndarray RAW10 packed, + "CAM_C": np.ndarray RAW10 packed, + }, + "raw_meta": dict, + "preview_bgr": np.ndarray BGR uint8 ou None, + "preview_method": str, + } + + Este método não salva nada em disco. + Ele apenas coleta e organiza o pacote bruto. + """ + + try: + agora = time.perf_counter() + + if max_age_s is None: + max_age_s = self._cache_max_age_s + + with self._lock: + cache_ok = ( + self.ultimo_raw_multi is not None + and self.ultimo_meta is not None + and self.timestamp_ultimo_raw_multi is not None + and (agora - self.timestamp_ultimo_raw_multi) < max_age_s + ) + + if cache_ok and not force: + raw_frame = { + cam_id: arr.copy() + for cam_id, arr in self.ultimo_raw_multi.items() + } + + raw_meta = dict(self.ultimo_meta) + + preview_bgr, preview_method = self._build_preview_raw_multispec( + raw_frame=raw_frame, + raw_meta=raw_meta, + ) + + bundle = { + "raw_frame": raw_frame, + "raw_meta": raw_meta, + "preview_bgr": preview_bgr, + "preview_method": preview_method, + } + + return bundle, dict(self._ultimo_resultado_raw) + + if self.client is None: + raise RuntimeError("OakFcc3Client não inicializado") + + t0 = time.perf_counter() + + raw_frame, raw_meta = self.client.get_next_raw_frame( + timeout=self.timeout_s + ) + + dur = time.perf_counter() - t0 + + if not isinstance(raw_frame, dict) or not raw_frame: + raise RuntimeError( + f"RAW_BRUTO inválido. Esperado dict por câmera, veio {type(raw_frame)}" + ) + + raw_meta = dict(raw_meta or {}) + + frame_type = str(raw_meta.get("frame_type", "")).upper() + if frame_type and frame_type != "RAW_BRUTO": + raise RuntimeError( + f"Bundle científico esperado em RAW_BRUTO, mas veio frame_type={frame_type}" + ) + + required = {"CAM_A", "CAM_B", "CAM_C"} + presentes = set(raw_frame.keys()) + faltando = sorted(required - presentes) + + if faltando: + raise RuntimeError( + f"RAW_BRUTO incompleto. Faltando câmeras: {faltando}. Presentes: {sorted(presentes)}" + ) + + raw_frame_copy = { + cam_id: arr.copy() + for cam_id, arr in raw_frame.items() + } + + preview_bgr, preview_method = self._build_preview_raw_multispec( + raw_frame=raw_frame_copy, + raw_meta=raw_meta, + ) + + resultado = { + "erro": None, + "duracao": dur, + "frame_valido": True, + "cameras": list(raw_frame_copy.keys()), + "sync_ok": bool(raw_meta.get("sync_ok", True)), + "sync_dt_ms": float(raw_meta.get("sync_dt_ms", 0.0) or 0.0), + "frame_id": raw_meta.get("frame_id"), + "preview_method": preview_method, + } + + with self._lock: + self.ultimo_raw_multi = raw_frame_copy + self.ultimo_meta = raw_meta + self.timestamp_ultimo_raw_multi = agora + self._ultimo_resultado_raw = resultado + + bundle = { + "raw_frame": raw_frame_copy, + "raw_meta": raw_meta, + "preview_bgr": preview_bgr, + "preview_method": preview_method, + } + + return bundle, resultado + + except Exception as e: + resultado = { + "erro": str(e), + "duracao": 0.0, + "frame_valido": False, + } + + with self._lock: + self._ultimo_resultado_raw = resultado + + self.mostrar_log( + f"[CameraMultispectral] Erro ao requisitar bundle RAW multispec: {e}" + ) + + if self._is_erro_fatal_depthai(e): + self._falha_fatal_depthai(e) + + return None, resultado + + def _build_preview_raw_multispec(self, raw_frame: dict, raw_meta: dict): + """ + Gera preview visual para acompanhar o bundle RAW_BRUTO. + + Preferência: + 1) build_save_preview_from_cam_a(), igual ao capture atual. + 2) build_preview_from_raw_payload(), fallback. + 3) None. + """ + + if self.client is None: + return None, "client_indisponivel" + + try: + preview = self.client.build_save_preview_from_cam_a( + packed_raw_by_camera=raw_frame, + meta_stream=raw_meta, + sensor_width=self.width, + sensor_height=self.height, + bayer_pattern="BGGR", + ) + + if preview is not None: + return preview, "cam_a_reconstructed_raw10" + + except Exception as e: + self.mostrar_log( + f"[CameraMultispectral] Falha ao gerar preview CAM_A RAW10: {e}" + ) + + try: + preview, _, preview_source_id = self.client.build_preview_from_raw_payload( + frame=raw_frame, + meta=raw_meta, + ) + + if preview is not None: + return preview, f"raw_payload_preview_{preview_source_id}" + + except Exception as e: + self.mostrar_log( + f"[CameraMultispectral] Falha no fallback de preview RAW: {e}" + ) + + return None, "preview_indisponivel" + + def salvar_bundle_raw_multispec( + self, + pasta: str, + nome: str = "", + nota: str = "operacao", + extra_meta: dict = None, + ): + """ + Salva pacote RAW_BRUTO multiespectral no mesmo espírito do capture de dataset. + + Saída: + .png + .json + _CAM_A.bin + _CAM_B.bin + _CAM_C.bin + + Retorna: + list[str] com os caminhos salvos. + """ + + try: + os.makedirs(pasta, exist_ok=True) + + nome_base = nome.strip() if nome else self._ts_name() + + bundle, resultado = self.requisitar_bundle_raw_multispec( + force=True, + max_age_s=0.0, + ) + + if not resultado.get("frame_valido", False): + raise RuntimeError( + resultado.get("erro") or "Bundle RAW multispec inválido" + ) + + raw_frame = bundle["raw_frame"] + raw_meta = bundle["raw_meta"] + preview_bgr = bundle.get("preview_bgr") + preview_method = bundle.get("preview_method") + + caminhos = [] + + payload_files = {} + payload_shapes = {} + payload_dtypes = {} + + for cam_id, arr in raw_frame.items(): + if arr is None: + continue + + caminho_bin = os.path.join( + pasta, + f"{nome_base}_{cam_id}.bin" + ) + + arr.tofile(caminho_bin) + + payload_files[cam_id] = os.path.basename(caminho_bin) + payload_shapes[cam_id] = list(arr.shape) + payload_dtypes[cam_id] = str(arr.dtype) + + caminhos.append(caminho_bin) + + if not payload_files: + raise RuntimeError("Nenhum payload RAW foi salvo.") + + caminho_preview = None + + if preview_bgr is not None and hasattr(preview_bgr, "size") and preview_bgr.size > 0: + caminho_preview = os.path.join( + pasta, + f"{nome_base}.png" + ) + + cv2.imwrite(caminho_preview, preview_bgr) + caminhos.append(caminho_preview) + + meta_save = { + "ts": datetime.now().isoformat(timespec="milliseconds"), + "source": "operacao_robo", + "note": nota, + "camera_model": self.modelo, + "mx_id": self.mx_id, + "module_calibration_json": self.module_calibration_json, + + "sensor_width": self.width, + "sensor_height": self.height, + "bayer_pattern": "BGGR", + "fps_target": self.fps, + + "frame_type": "RAW_BRUTO", + "capture_mode_requested": "TRIPLE", + "capture_mode_effective": "TRIPLE", + "raw_policy": "require_triple", + + "saved_payload_type": "raw_native_multi", + "saved_payload_paths": payload_files, + "saved_payload_shapes": payload_shapes, + "saved_payload_dtypes": payload_dtypes, + + "saved_preview_path": os.path.basename(caminho_preview) if caminho_preview else None, + "saved_preview_method": preview_method, + + "stream_meta": raw_meta, + "actual_camera_controls": self.client.get_current_camera_controls() if self.client else None, + "radiometric_last_result": self.client.get_radiometric_last_result() if self.client else None, + + "resultado": resultado, + } + + if extra_meta: + meta_save["extra"] = extra_meta + + caminho_json = os.path.join( + pasta, + f"{nome_base}.json" + ) + + with open(caminho_json, "w", encoding="utf-8") as f: + json.dump(meta_save, f, ensure_ascii=False, indent=2) + + caminhos.append(caminho_json) + + return caminhos + + except Exception as e: + self.mostrar_log( + f"[CameraMultispectral] Erro ao salvar bundle RAW multispec: {e}" + ) + + if self._is_erro_fatal_depthai(e): + self._falha_fatal_depthai(e) + + return [] + diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py index 439adfca7..24cceafb5 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/oak_fcc3_core/raw_processor_core.py @@ -1090,17 +1090,7 @@ class RawProcessorCore: warped_mask = self._affine_image(mask, dx, dy, theta_deg) elif mode == "homography": - H = cfg.get("homographies", {}).get(f"{role}_to_rgb") - - if H is None: - raise RuntimeError( - f"fusion_config.alignment_mode='homography', " - f"mas homografia '{role}_to_rgb' está ausente. " - f"Isso deixaria o canal {role.upper()} sem alinhamento." - ) - - calib_size = cfg.get("homography_calibration_size", None) - + H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) H = self._scale_homography_to_runtime( H, calib_size=calib_size, @@ -3611,22 +3601,30 @@ class RawProcessorCore: def _direct_fusion_get_role_homography_fast(self, role, meta, ref_size): """ Retorna H_role_to_rgb escalada para o espaço da referência RGB. + + Suporta: + - contrato antigo: fusion_config.homographies.re_to_rgb/nir_to_rgb + - contrato novo: fusion_config.homography_profiles..homographies.* """ role = str(role).lower() - fusion = getattr(self, "fusion_config", {}) or {} - homographies = fusion.get("homographies", {}) or {} - key = f"{role}_to_rgb" - H = homographies.get(key) + H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) - if H is None: - # Fallbacks para contratos diferentes. - H = homographies.get(role) + ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) - if H is None: - raise RuntimeError(f"Homografia ausente para role={role}. Esperado fusion_config.homographies.{key}") + H_scaled = self._scale_homography_to_runtime( + H, + calib_size=calib_size, + runtime_size=(ref_w, ref_h), + ) - return self._direct_fusion_scale_homography_for_ref_fast(H, meta, ref_size) + if H_scaled is None or H_scaled.shape != (3, 3): + raise RuntimeError( + f"Homografia inválida para role={role}, profile={profile_name}: " + f"shape={None if H_scaled is None else H_scaled.shape}" + ) + + return H_scaled.astype(np.float32) def _direct_fusion_resize_spec_to_ref_if_needed_fast(self, img, ref_size): """ @@ -3770,6 +3768,7 @@ class RawProcessorCore: "geometry_cache_hit": bool(geom.get("prepare_cache_hit", False)), "geometry_cache_hits": int(geom.get("cache_hits", 0)), "geometry_cache_misses": int(geom.get("cache_misses", 0)), + "homography_profiles_used": geom.get("homography_profiles_used", {}), } tensor = np.empty((int(channels_expected), target_h, target_w), dtype=np.float32) @@ -3962,34 +3961,38 @@ class RawProcessorCore: """ Chave simples e estável para cache da geometria. - A geometria depende de: - - tamanho do RGB de referência + Considera: + - tamanho do RGB/ref - target final - roles presentes - - crop_valid_common / resize_after_crop - - homografias e calibration_size - - Para evitar custo de serializar o JSON todo por frame, usamos uma versão - simples. Se você editar module_params em runtime, chame - clear_direct_fusion_geometry_cache(). + - crop/resize + - homografia efetivamente selecionada por perfil + - calibration_size efetivo por role """ ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) target_w, target_h = int(target_size[0]), int(target_size[1]) fusion = getattr(self, "fusion_config", {}) or {} - homographies = fusion.get("homographies", {}) or {} - - # Pequena assinatura numérica das homografias. - def h_sig(key): - H = homographies.get(key) - if H is None: - return None - arr = np.asarray(H, dtype=np.float32).reshape(-1) - # arredonda para evitar ruído float/json, mas detecta mudança real. - return tuple(np.round(arr, 8).tolist()) - roles = tuple(sorted([str(r).lower() for r in role_to_cam.keys()])) + def h_sig_for_role(role): + role = str(role).lower() + + if role not in role_to_cam: + return None + + try: + H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) + except Exception: + return None + + arr = np.asarray(H, dtype=np.float32).reshape(-1) + return ( + str(profile_name), + tuple(calib_size or []), + tuple(np.round(arr, 8).tolist()), + ) + return ( ref_w, ref_h, @@ -3998,9 +4001,8 @@ class RawProcessorCore: roles, bool(fusion.get("crop_valid_common", False)), bool(fusion.get("resize_after_crop", False)), - tuple(fusion.get("homography_calibration_size") or fusion.get("calibration_size") or []), - h_sig("re_to_rgb"), - h_sig("nir_to_rgb"), + h_sig_for_role("re"), + h_sig_for_role("nir"), ) def clear_direct_fusion_geometry_cache(self): @@ -4053,8 +4055,14 @@ class RawProcessorCore: # Homografias escaladas para runtime. # ------------------------------------------------------------ H_role_to_rgb = {} + homography_profiles_used = {} for role in ("re", "nir"): if role in role_to_cam: + H_raw, calib_size, profile_name = self._resolve_homography_entry_for_role(role) + homography_profiles_used[role] = { + "profile": profile_name, + "calib_size": list(calib_size) if calib_size is not None else None, + } H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast(role, meta, ref_size) # ------------------------------------------------------------ @@ -4103,6 +4111,7 @@ class RawProcessorCore: "prepare_cache_hit": False, "cache_hits": int(self._direct_fusion_geometry_cache_hits), "cache_misses": int(self._direct_fusion_geometry_cache_misses), + "homography_profiles_used": homography_profiles_used, } # Cache pequeno: normalmente só uma geometria. Se mudar resolução/config, @@ -4376,6 +4385,120 @@ class RawProcessorCore: tensor[int(channel_index)] = out + def _resolve_homography_profile_name_for_role(self, role: str) -> str: + """ + Resolve qual perfil de homografia usar para uma role. + + Prioridade: + 1) fusion_config.homography_profile_by_role[role] + 2) fusion_config.homography_profile + 3) "default" + """ + role = str(role).lower() + fusion = getattr(self, "fusion_config", {}) or {} + + by_role = fusion.get("homography_profile_by_role", {}) or {} + if isinstance(by_role, dict): + selected = by_role.get(role) + if selected: + return str(selected).lower() + + selected = fusion.get("homography_profile", None) + if selected: + return str(selected).lower() + + return "default" + + def _resolve_homography_entry_for_role(self, role: str): + """ + Resolve a homografia no contrato novo ou antigo. + + Contrato novo: + fusion_config.homography_profiles..homographies._to_rgb + + Contrato antigo: + fusion_config.homographies._to_rgb + + Retorna: + H, calib_size, profile_name + """ + role = str(role).lower() + fusion = getattr(self, "fusion_config", {}) or {} + + key = f"{role}_to_rgb" + + selected_profile = self._resolve_homography_profile_name_for_role(role) + + # ------------------------------------------------------------ + # Futuro: auto por profundidade. + # Por enquanto, cai em media/default de forma explícita. + # ------------------------------------------------------------ + if selected_profile == "auto": + profiles = fusion.get("homography_profiles", {}) or {} + if "media" in profiles: + selected_profile = "media" + elif "default" in profiles: + selected_profile = "default" + else: + selected_profile = "" + + # ------------------------------------------------------------ + # Contrato novo: homography_profiles + # ------------------------------------------------------------ + profiles = fusion.get("homography_profiles", {}) or {} + if isinstance(profiles, dict) and selected_profile: + profile = profiles.get(selected_profile) + + if profile is None: + # tolera nomes com caixa diferente + for name, item in profiles.items(): + if str(name).lower() == selected_profile: + profile = item + selected_profile = str(name) + break + + if isinstance(profile, dict): + profile_homographies = profile.get("homographies", {}) or {} + H = profile_homographies.get(key) + + if H is None: + # fallback curto: "re" ou "nir" + H = profile_homographies.get(role) + + if H is not None: + calib_size = ( + profile.get("homography_calibration_size") + or profile.get("calibration_size") + or fusion.get("homography_calibration_size") + or fusion.get("calibration_size") + or None + ) + return H, calib_size, selected_profile + + # ------------------------------------------------------------ + # Contrato antigo: homographies direto + # ------------------------------------------------------------ + homographies = fusion.get("homographies", {}) or {} + H = homographies.get(key) + + if H is None: + H = homographies.get(role) + + if H is not None: + calib_size = ( + fusion.get("homography_calibration_size") + or fusion.get("calibration_size") + or None + ) + return H, calib_size, "legacy" + + raise RuntimeError( + f"Homografia ausente para role={role}. " + f"Procurei profile='{selected_profile}' em " + f"fusion_config.homography_profiles.*.homographies.{key} " + f"e fallback fusion_config.homographies.{key}" + ) + def _raw10_rgb_linear_demosaic_to_rgb_float01_fast( @@ -4806,3 +4929,6 @@ class RawProcessorCore: self._flatfield_runtime_cache[key] = gain_tensor return gain_tensor + + + \ No newline at end of file diff --git a/Python/OAK/datasets/oak-fcc-3/_1_weeds_pair_sorter.py b/Python/OAK/datasets/oak-fcc-3/_1_weeds_pair_sorter.py index a2f81a999..c94cd3f0f 100644 --- a/Python/OAK/datasets/oak-fcc-3/_1_weeds_pair_sorter.py +++ b/Python/OAK/datasets/oak-fcc-3/_1_weeds_pair_sorter.py @@ -1,20 +1,21 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -sample_bundle_sorter.py +weed_pair_sorter.py ------------------------------------------------- -Classificador manual para o formato de dataset do módulo multiespectral. +Classificador manual para amostras multiespectrais do Agrobotics/OAK-FFC-3. -Cada amostra é formada por: - .png - .json - _cam0.bin - _cam1.bin - _cam2.bin # opcional +Agora, além do preview RGB salvo em .png, ele pode mostrar também os previews +reconstruídos diretamente dos .bin das 3 câmeras: + + CAM_A = RGB + CAM_B = RE + CAM_C = NIR Fluxo: - Entrada: uma ou mais pastas-raiz contendo amostras em subpastas. -- O script mostra o preview (.png). +- O script mostra o preview salvo (.png). +- Se a opção estiver marcada, reconstrói CAM_A/CAM_B/CAM_C direto dos .bin. - Você usa teclas 1..9/0 para enviar o conjunto da amostra para uma label. Estrutura de saída: @@ -26,33 +27,68 @@ Estrutura de saída: masks/ Regras: -- PNG vai para previews/ +- PNG/JPG vai para previews/ - JSON vai para metas/ - BINs vão para bins/ - masks/ é criada vazia + +Dependências: + pip install pillow numpy +Opcional, mas recomendado para o RGB RAW Bayer ficar colorido: + pip install opencv-python """ import argparse import csv import json +import math +import re import shutil import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional, Tuple import tkinter as tk -from tkinter import messagebox, filedialog +from tkinter import filedialog, messagebox try: - from PIL import Image, ImageTk + import numpy as np +except Exception: + print("ERROR: numpy is required. Install with: pip install numpy", file=sys.stderr) + raise + +try: + from PIL import Image, ImageDraw, ImageFont, ImageTk except Exception: print("ERROR: Pillow (PIL) is required. Install with: pip install pillow", file=sys.stderr) raise -PREVIEW_EXT = ".png" +try: + import cv2 # type: ignore +except Exception: + cv2 = None + + +PREVIEW_EXTS = (".png", ".jpg", ".jpeg") META_EXT = ".json" +CAMERA_ROLE = { + "CAM_A": "RGB", + "CAM_B": "RE", + "CAM_C": "NIR", +} +ROLE_TO_CAMERA = { + "RGB": "CAM_A", + "RE": "CAM_B", + "NIR": "CAM_C", +} +OLD_CAM_TO_CAMERA = { + 0: "CAM_A", + 1: "CAM_B", + 2: "CAM_C", +} +CAM_ORDER = {"CAM_A": 0, "CAM_B": 1, "CAM_C": 2} @dataclass @@ -62,54 +98,146 @@ class SampleBundle: meta_path: Path bin_paths: List[Path] sample_id: str + bin_by_camera: Dict[str, Path] = field(default_factory=dict) +# ============================================================ +# Utilidades de arquivo/meta +# ============================================================ + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def safe_load_json(path: Path) -> dict: + try: + return load_json(path) + except Exception: + return {} + + +def unique_file(p: Path) -> Path: + if not p.exists(): + return p + + stem = p.stem + suffix = p.suffix + k = 1 + while True: + cand = p.with_name(f"{stem}__{k}{suffix}") + if not cand.exists(): + return cand + k += 1 + + +def build_bin_map_from_meta(meta_path: Path, available_bins: List[Path]) -> Dict[str, Path]: + """ + Mapeia CAM_A/CAM_B/CAM_C para os .bin correspondentes. + + Prioridade: + 1. saved_payload_paths do JSON. + 2. Nomes dos arquivos: *_CAM_A.bin, CAM_A.bin, *_rgb.bin, *_re.bin, *_nir.bin. + 3. Formato antigo: *_cam0.bin, *_cam1.bin, *_cam2.bin. + 4. Ordem da lista, como fallback: 0=A, 1=B, 2=C. + """ + meta = safe_load_json(meta_path) + by_name = {p.name.lower(): p for p in available_bins} + result: Dict[str, Path] = {} + + saved_paths = meta.get("saved_payload_paths") or {} + if isinstance(saved_paths, dict): + for cam_id, fname in saved_paths.items(): + cam = str(cam_id).upper() + if cam not in CAMERA_ROLE: + continue + name = Path(str(fname)).name.lower() + if name in by_name: + result[cam] = by_name[name] + else: + # tenta relativo ao meta e também por nome absoluto, sem explodir + candidates = [meta_path.parent / str(fname), meta_path.parent / Path(str(fname)).name] + for c in candidates: + if c.exists(): + result[cam] = c + break + + old_cam_re = re.compile(r"_cam(?P\d+)\.bin$", re.IGNORECASE) + oak_cam_re = re.compile(r"(?:^|_)(?PCAM_[ABC])\.bin$", re.IGNORECASE) + role_re = re.compile(r"_(?Prgb|re|nir)\.bin$", re.IGNORECASE) + + for p in available_bins: + if p in result.values(): + continue + + m = oak_cam_re.search(p.name) + if m: + result.setdefault(m.group("cam").upper(), p) + continue + + m = role_re.search(p.name) + if m: + role = m.group("role").upper() + cam = ROLE_TO_CAMERA.get(role) + if cam: + result.setdefault(cam, p) + continue + + m = old_cam_re.search(p.name) + if m: + cam_idx = int(m.group("cam")) + cam = OLD_CAM_TO_CAMERA.get(cam_idx) + if cam: + result.setdefault(cam, p) + continue + + for i, p in enumerate(sorted(available_bins, key=lambda x: x.name.lower())): + cam = OLD_CAM_TO_CAMERA.get(i) + if cam: + result.setdefault(cam, p) + + return {k: result[k] for k in sorted(result, key=lambda c: CAM_ORDER.get(c, 99))} + + +# ============================================================ +# Busca de amostras +# ============================================================ + def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]: """ Procura amostras em formatos antigos e atuais. - Suporta formato antigo Pi: - .png - .json - _cam0.bin - _cam1.bin - _cam2.bin - - Suporta formato OAK-FFC-3: - preview.png - meta.json - CAM_A.bin / CAM_B.bin / CAM_C.bin - - Também suporta: - _CAM_A.bin - _CAM_B.bin - _CAM_C.bin - - _rgb.bin - _nir.bin - _re.bin + Suporta: + - .png + .json + _cam0.bin / _cam1.bin / _cam2.bin + - preview.png + meta.json + CAM_A.bin / CAM_B.bin / CAM_C.bin + - .png + .json + _CAM_A.bin / _CAM_B.bin / _CAM_C.bin + - .png + .json + _rgb.bin / _re.bin / _nir.bin + - layout dataset: root/previews, root/metas, root/bins """ - import re - if not folder.is_dir(): return [] bundles: List[SampleBundle] = [] + + # Caso dataset já organizado em previews/metas/bins + dataset_bundles = find_dataset_layout_bundles(folder) + if dataset_bundles: + bundles.extend(dataset_bundles) + candidate_dirs = [folder] candidate_dirs.extend([p for p in folder.rglob("*") if p.is_dir()]) - # Padrões aceitos old_cam_re = re.compile(r"^(?P.+)_cam(?P\d+)\.bin$", re.IGNORECASE) oak_cam_re = re.compile(r"^(?:(?P.+)_)?(?PCAM_[ABC])\.bin$", re.IGNORECASE) - role_re = re.compile(r"^(?P.+)_(?Prgb|nir|re)\.bin$", re.IGNORECASE) + role_re = re.compile(r"^(?P.+)_(?Prgb|re|nir)\.bin$", re.IGNORECASE) role_order = { "CAM_A": 0, "CAM_B": 1, "CAM_C": 2, "RGB": 0, - "NIR": 1, - "RE": 2, + "RE": 1, + "NIR": 2, } generic_preview_names = {"preview.png", "preview.jpg", "preview.jpeg"} @@ -123,45 +251,35 @@ def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]: if not files: continue - pngs = {} - jsons = {} - bins_by_base = {} + pngs: Dict[str, Path] = {} + jsons: Dict[str, Path] = {} + bins_by_base: Dict[str, List[Tuple[int, Path]]] = {} generic_preview = None generic_meta = None - generic_bins = [] + generic_bins: List[Tuple[int, Path]] = [] for p in files: name_lower = p.name.lower() suffix = p.suffix.lower() - # ----------------------------- - # Preview - # ----------------------------- - if suffix in (".png", ".jpg", ".jpeg"): + if suffix in PREVIEW_EXTS: if name_lower in generic_preview_names: generic_preview = p else: pngs[p.stem] = p continue - # ----------------------------- - # Meta - # ----------------------------- - if suffix == ".json": + if suffix == META_EXT: if name_lower in generic_meta_names: generic_meta = p else: jsons[p.stem] = p continue - # ----------------------------- - # BIN - # ----------------------------- if suffix != ".bin": continue - # Antigo: _cam0.bin m = old_cam_re.match(p.name) if m: base = m.group("base") @@ -169,7 +287,6 @@ def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]: bins_by_base.setdefault(base, []).append((cam_idx, p)) continue - # Novo: _CAM_A.bin ou CAM_A.bin m = oak_cam_re.match(p.name) if m: base = m.group("base") or d.name @@ -179,7 +296,6 @@ def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]: generic_bins.append((order, p)) continue - # Alternativo: _rgb.bin / _nir.bin / _re.bin m = role_re.match(p.name) if m: base = m.group("base") @@ -188,38 +304,27 @@ def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]: bins_by_base.setdefault(base, []).append((order, p)) continue - # ------------------------------------------------- - # Caso 1: formato OAK em pasta de amostra: - # preview.png + meta.json + CAM_A/B/C.bin - # ------------------------------------------------- if generic_preview and generic_meta and generic_bins: sorted_bins = [p for _, p in sorted(generic_bins, key=lambda x: x[0])] - sample_id = d.name - + bin_by_camera = build_bin_map_from_meta(generic_meta, sorted_bins) bundles.append( SampleBundle( sample_dir=d, preview_path=generic_preview, meta_path=generic_meta, bin_paths=sorted_bins, - sample_id=sample_id, + sample_id=d.name, + bin_by_camera=bin_by_camera, ) ) continue - # ------------------------------------------------- - # Caso 2: formato com base comum: - # sample_x.png/json + sample_x_CAM_A.bin - # ou sample_x.png/json + sample_x_cam0.bin - # ------------------------------------------------- valid_bases = sorted(set(pngs.keys()) & set(jsons.keys()) & set(bins_by_base.keys())) - for base in valid_bases: sorted_bins = [p for _, p in sorted(bins_by_base[base], key=lambda x: x[0])] - if not sorted_bins: continue - + bin_by_camera = build_bin_map_from_meta(jsons[base], sorted_bins) bundles.append( SampleBundle( sample_dir=d, @@ -227,20 +332,16 @@ def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]: meta_path=jsons[base], bin_paths=sorted_bins, sample_id=base, + bin_by_camera=bin_by_camera, ) ) - # ------------------------------------------------- - # Caso 3: meta/preview genéricos, mas bins com base: - # preview.png + meta.json + sample_x_CAM_A.bin... - # ------------------------------------------------- if generic_preview and generic_meta and bins_by_base and not generic_bins: for base, bin_items in sorted(bins_by_base.items()): sorted_bins = [p for _, p in sorted(bin_items, key=lambda x: x[0])] - if not sorted_bins: continue - + bin_by_camera = build_bin_map_from_meta(generic_meta, sorted_bins) bundles.append( SampleBundle( sample_dir=d, @@ -248,38 +349,405 @@ def find_sample_bundles_in_folder(folder: Path) -> List[SampleBundle]: meta_path=generic_meta, bin_paths=sorted_bins, sample_id=base, + bin_by_camera=bin_by_camera, ) ) - bundles.sort(key=lambda x: (str(x.sample_dir), x.sample_id)) - return bundles + # Remove duplicados sem esmagar amostras com mesmo nome em pastas diferentes + dedup: Dict[str, SampleBundle] = {} + for b in bundles: + key = str((b.meta_path.resolve(), tuple(p.resolve() for p in b.bin_paths))) + dedup.setdefault(key, b) + + out = list(dedup.values()) + out.sort(key=lambda x: (str(x.sample_dir), x.sample_id)) + return out + + +def find_dataset_layout_bundles(root: Path) -> List[SampleBundle]: + """ + Detecta layout: + root/metas/*.json + root/previews/*.png + root/bins/*.bin + Também aceita quando o usuário aponta para root/metas, root/previews ou root/bins. + """ + candidates = [] + p = root.resolve() + candidates.append(p) + candidates.append(p.parent) + + if p.name.lower() in ("metas", "metadata", "jsons", "previews", "bins", "masks"): + candidates.insert(0, p.parent) + + for cand in candidates: + metas = cand / "metas" + previews = cand / "previews" + bins = cand / "bins" + if not (metas.is_dir() and previews.is_dir() and bins.is_dir()): + continue + + bundles: List[SampleBundle] = [] + for meta_path in sorted(metas.glob("*.json")): + base = meta_path.stem + preview_path = None + for ext in PREVIEW_EXTS: + test = previews / f"{base}{ext}" + if test.exists(): + preview_path = test + break + if preview_path is None: + continue + + meta = safe_load_json(meta_path) + bin_paths: List[Path] = [] + saved_paths = meta.get("saved_payload_paths") or {} + if isinstance(saved_paths, dict): + for cam in ("CAM_A", "CAM_B", "CAM_C"): + fname = saved_paths.get(cam) + if not fname: + continue + candidates_bin = [ + bins / Path(str(fname)).name, + meta_path.parent / str(fname), + cand / str(fname), + bins / f"{base}_{cam}.bin", + ] + for c in candidates_bin: + if c.exists(): + bin_paths.append(c) + break + else: + bin_paths.extend(sorted(bins.glob(f"{base}_*.bin"))) + + if not bin_paths: + bin_paths.extend(sorted(bins.glob(f"{base}*.bin"))) + + if not bin_paths: + continue + + bin_by_camera = build_bin_map_from_meta(meta_path, bin_paths) + bundles.append( + SampleBundle( + sample_dir=cand, + preview_path=preview_path, + meta_path=meta_path, + bin_paths=sorted(set(bin_paths), key=lambda x: x.name.lower()), + sample_id=base, + bin_by_camera=bin_by_camera, + ) + ) + return bundles + + return [] def collect_all_bundles(folders: List[Path]) -> List[SampleBundle]: all_bundles: List[SampleBundle] = [] - seen_dirs = set() - + seen = set() for folder in folders: for bundle in find_sample_bundles_in_folder(folder): - key = str((bundle.sample_dir / bundle.sample_id).resolve()) - if key in seen_dirs: + key = str((bundle.meta_path.resolve(), tuple(p.resolve() for p in bundle.bin_paths))) + if key in seen: continue - seen_dirs.add(key) + seen.add(key) all_bundles.append(bundle) - all_bundles.sort(key=lambda x: (str(x.sample_dir), x.sample_id)) return all_bundles +# ============================================================ +# Reconstrução de preview diretamente do .bin +# ============================================================ + +def load_camera_payload(meta: dict, payload_path: Path, cam_id: str) -> np.ndarray: + saved_dtypes = meta.get("saved_payload_dtypes") or {} + saved_shapes = meta.get("saved_payload_shapes") or {} + + dtype = saved_dtypes.get(cam_id) + shape = saved_shapes.get(cam_id) + + camera_info = ((meta.get("stream_meta") or {}).get("camera_info") or {}).get(cam_id, {}) or {} + if dtype is None: + dtype = camera_info.get("dtype", "uint8") + if shape is None: + shape = camera_info.get("shape") + + if dtype is None: + dtype = "uint8" + + raw = np.fromfile(str(payload_path), dtype=np.dtype(dtype)) + + if shape is not None: + try: + return raw.reshape(tuple(int(x) for x in shape)) + except Exception as e: + raise RuntimeError(f"não consegui aplicar shape={shape} em {payload_path.name}: {e}") + + # Fallback para RAW10 packed usando sensor_width/height do meta. + sensor_w = int(meta.get("sensor_width", 1280) or 1280) + sensor_h = int(meta.get("sensor_height", 800) or 800) + packed_w = int(math.ceil(sensor_w * 5 / 4)) + expected = sensor_h * packed_w + if raw.size == expected: + return raw.reshape((sensor_h, packed_w)) + + # Fallback quadrado/linear para não quebrar a GUI inteira. + side = int(math.sqrt(raw.size)) + if side * side == raw.size: + return raw.reshape((side, side)) + + raise RuntimeError(f"não consegui inferir dimensão para {payload_path.name} | size={raw.size}") + + +def unpack_raw10_packed(packed: np.ndarray, sensor_width: Optional[int] = None) -> np.ndarray: + """ + Converte RAW10 packed MIPI/OAK: + 5 bytes -> 4 pixels de 10 bits. + Entrada esperada: [H, packed_width] uint8. + Saída: [H, W] uint16, valores 0..1023. + """ + if packed.ndim == 3 and packed.shape[-1] == 1: + packed = packed[:, :, 0] + if packed.ndim != 2: + raise RuntimeError(f"RAW10 packed esperado 2D, recebido shape={packed.shape}") + + h, packed_w = packed.shape + usable_w = (packed_w // 5) * 5 + if usable_w <= 0: + raise RuntimeError(f"packed_width inválido: {packed_w}") + + data = packed[:, :usable_w].astype(np.uint16).reshape(h, usable_w // 5, 5) + b0 = data[:, :, 0] + b1 = data[:, :, 1] + b2 = data[:, :, 2] + b3 = data[:, :, 3] + b4 = data[:, :, 4] + + out = np.empty((h, (usable_w // 5) * 4), dtype=np.uint16) + out[:, 0::4] = (b0 << 2) | (b4 & 0x03) + out[:, 1::4] = (b1 << 2) | ((b4 >> 2) & 0x03) + out[:, 2::4] = (b2 << 2) | ((b4 >> 4) & 0x03) + out[:, 3::4] = (b3 << 2) | ((b4 >> 6) & 0x03) + + if sensor_width is not None and 0 < sensor_width <= out.shape[1]: + out = out[:, :sensor_width] + + return out + + +def robust_to_uint8(img: np.ndarray, bit_depth: int = 10, percentile_clip: bool = True) -> np.ndarray: + arr = img.astype(np.float32) + if percentile_clip: + lo, hi = np.percentile(arr, [1.0, 99.5]) + if hi > lo: + arr = (arr - lo) / (hi - lo) + else: + maxv = float((1 << bit_depth) - 1) + arr = arr / maxv + else: + maxv = float((1 << bit_depth) - 1) + arr = arr / maxv + return np.clip(arr * 255.0, 0, 255).astype(np.uint8) + + +def bayer_to_rgb(raw16: np.ndarray, bayer_pattern: str, bit_depth: int) -> Image.Image: + raw8 = robust_to_uint8(raw16, bit_depth=bit_depth) + + if cv2 is not None: + pattern = (bayer_pattern or "BGGR").upper() + code_map = { + "BGGR": cv2.COLOR_BAYER_BG2RGB, + "RGGB": cv2.COLOR_BAYER_RG2RGB, + "GBRG": cv2.COLOR_BAYER_GB2RGB, + "GRBG": cv2.COLOR_BAYER_GR2RGB, + } + code = code_map.get(pattern, cv2.COLOR_BAYER_BG2RGB) + rgb = cv2.cvtColor(raw8, code) + return Image.fromarray(rgb) + + # Sem OpenCV: fallback em grayscale RGB. Melhor mostrar algo do que ficar cego. + return Image.fromarray(raw8, mode="L").convert("RGB") + + +def mono_to_rgb(raw16_or_u8: np.ndarray, bit_depth: int = 10) -> Image.Image: + if raw16_or_u8.dtype == np.uint8 and raw16_or_u8.ndim == 2: + gray = raw16_or_u8 + else: + gray = robust_to_uint8(raw16_or_u8, bit_depth=bit_depth) + return Image.fromarray(gray, mode="L").convert("RGB") + + +def build_bin_preview_image(bundle: SampleBundle, cam_id: str) -> Tuple[Image.Image, str]: + if cam_id not in bundle.bin_by_camera: + raise RuntimeError(f"{cam_id} não encontrado nos .bin desta amostra") + + payload_path = bundle.bin_by_camera[cam_id] + meta = load_json(bundle.meta_path) + arr = load_camera_payload(meta, payload_path, cam_id) + + stream_meta = meta.get("stream_meta") or {} + camera_info = (stream_meta.get("camera_info") or {}).get(cam_id, {}) or {} + + role = str(camera_info.get("role") or CAMERA_ROLE.get(cam_id, cam_id)).upper() + interface = str(camera_info.get("interface") or "").upper() + raw_format = str(camera_info.get("raw_format") or "").upper() + bit_depth = int(camera_info.get("bit_depth", 10) or 10) + sensor_width = int(camera_info.get("width") or meta.get("sensor_width", 1280) or 1280) + bayer = str(camera_info.get("bayer_pattern") or meta.get("bayer_pattern", "BGGR")) + + # USB/RGB pronto em HWC + if arr.ndim == 3 and arr.shape[-1] == 3 and arr.dtype == np.uint8: + img = Image.fromarray(arr[:, :, ::-1] if interface == "USB_BGR" else arr).convert("RGB") + return img, f"{cam_id}={CAMERA_ROLE.get(cam_id)} | RGB nativo | {arr.dtype} {tuple(arr.shape)}" + + # RAW10 packed OAK: formato do seu JSON de exemplo: [800,1600] uint8 para sensor 1280x800. + is_raw10 = raw_format == "RAW10_PACKED" or (arr.dtype == np.uint8 and arr.ndim == 2 and arr.shape[1] >= sensor_width * 5 // 4) + if is_raw10: + raw16 = unpack_raw10_packed(arr, sensor_width=sensor_width) + if cam_id == "CAM_A": + img = bayer_to_rgb(raw16, bayer_pattern=bayer, bit_depth=bit_depth) + else: + img = mono_to_rgb(raw16, bit_depth=bit_depth) + return img, f"{cam_id}={CAMERA_ROLE.get(cam_id)} | RAW10 direto do .bin | {arr.dtype} {tuple(arr.shape)} -> {tuple(raw16.shape)}" + + # Float tensor/canal normalizado + if arr.ndim == 2: + if np.issubdtype(arr.dtype, np.floating): + gray = np.clip(arr.astype(np.float32) * 255.0, 0, 255).astype(np.uint8) + img = Image.fromarray(gray, mode="L").convert("RGB") + else: + img = mono_to_rgb(arr, bit_depth=bit_depth) + return img, f"{cam_id}={CAMERA_ROLE.get(cam_id)} | mono direto do .bin | {arr.dtype} {tuple(arr.shape)}" + + # CHW RGB ou multispec + if arr.ndim == 3 and arr.shape[0] in (3, 5): + chw = arr.astype(np.float32) + if cam_id == "CAM_A" and arr.shape[0] >= 3: + rgb = np.transpose(chw[:3], (1, 2, 0)) + if np.issubdtype(arr.dtype, np.floating): + rgb8 = np.clip(rgb * 255.0, 0, 255).astype(np.uint8) + else: + rgb8 = robust_to_uint8(rgb, bit_depth=bit_depth) + img = Image.fromarray(rgb8).convert("RGB") + return img, f"{cam_id}=RGB | CHW direto do .bin | {arr.dtype} {tuple(arr.shape)}" + + raise RuntimeError(f"formato não suportado para {cam_id}: dtype={arr.dtype} shape={arr.shape}") + + +def add_label_to_image(img: Image.Image, title: str, subtitle: str = "") -> Image.Image: + out = img.convert("RGB").copy() + draw = ImageDraw.Draw(out) + try: + font_title = ImageFont.truetype("arial.ttf", 22) + font_sub = ImageFont.truetype("arial.ttf", 15) + except Exception: + font_title = ImageFont.load_default() + font_sub = ImageFont.load_default() + + # caixa translúcida fake: retângulo sólido escuro para legibilidade + max_w = out.size[0] + box_h = 58 if subtitle else 34 + draw.rectangle((0, 0, max_w, box_h), fill=(0, 0, 0)) + draw.text((10, 7), title, fill=(255, 230, 80), font=font_title) + if subtitle: + short = subtitle if len(subtitle) <= 115 else subtitle[:112] + "..." + draw.text((10, 35), short, fill=(235, 235, 235), font=font_sub) + return out + + +def resize_keep_height(img: Image.Image, target_h: int) -> Image.Image: + w, h = img.size + if h <= 0: + return img + new_w = max(1, int(w * (target_h / h))) + return img.resize((new_w, target_h), Image.BILINEAR) + + +def compose_preview_grid(panels: List[Tuple[str, Image.Image, str]], display_height: int, max_width: int = 1800) -> Image.Image: + """ + Monta uma grade responsiva em PIL. + - 1 painel: só ele. + - 2 painéis: 2 colunas. + - 3/4 painéis: 2x2. + """ + if not panels: + raise RuntimeError("nenhum painel para compor") + + n = len(panels) + cols = 1 if n == 1 else 2 + panel_h = display_height if cols == 1 else max(180, display_height // 2) + + labeled = [] + for title, img, subtitle in panels: + labeled.append(add_label_to_image(resize_keep_height(img, panel_h), title, subtitle)) + + # Uniformiza largura por coluna para grid bonito. + rows = math.ceil(n / cols) + gap = 10 + bg = (28, 28, 28) + + col_widths = [0] * cols + row_heights = [0] * rows + for i, img in enumerate(labeled): + r = i // cols + c = i % cols + col_widths[c] = max(col_widths[c], img.size[0]) + row_heights[r] = max(row_heights[r], img.size[1]) + + canvas_w = sum(col_widths) + gap * (cols - 1) + canvas_h = sum(row_heights) + gap * (rows - 1) + canvas = Image.new("RGB", (canvas_w, canvas_h), bg) + + y = 0 + for r in range(rows): + x = 0 + for c in range(cols): + i = r * cols + c + if i < len(labeled): + img = labeled[i] + canvas.paste(img, (x, y)) + x += col_widths[c] + gap + y += row_heights[r] + gap + + if canvas.size[0] > max_width: + scale = max_width / canvas.size[0] + canvas = canvas.resize((int(canvas.size[0] * scale), int(canvas.size[1] * scale)), Image.BILINEAR) + + return canvas + + +def build_display_image(bundle: SampleBundle, show_bin_previews: bool, display_height: int) -> Tuple[ImageTk.PhotoImage, str]: + panels: List[Tuple[str, Image.Image, str]] = [] + + saved = Image.open(bundle.preview_path).convert("RGB") + panels.append(("Preview salvo RGB", saved, bundle.preview_path.name)) + + status_extra = "" + if show_bin_previews: + errors = [] + for cam_id in ("CAM_A", "CAM_B", "CAM_C"): + try: + img, desc = build_bin_preview_image(bundle, cam_id) + role = CAMERA_ROLE.get(cam_id, cam_id) + panels.append((f"{cam_id} - {role} (.bin)", img, desc)) + except Exception as e: + errors.append(f"{cam_id}: {e}") + + if errors: + status_extra = " | avisos: " + " ; ".join(errors[:2]) + if len(errors) > 2: + status_extra += f" ; +{len(errors)-2}" + + composed = compose_preview_grid(panels, display_height=display_height) + return ImageTk.PhotoImage(composed), status_extra + + +# ============================================================ +# Log e app +# ============================================================ + class ActionLogger: - """ - Loga ações em: - - CSV: sorting_log.csv - - JSONL: sorting_log.jsonl - - O resume usa sample_dir como chave. - """ - def __init__(self, out_root: Path): self.out_root = out_root self.log_csv = out_root / "sorting_log.csv" @@ -334,7 +802,7 @@ class ActionLogger: label or "", sample_dir_str, str(preview_path.resolve()), - str(meta_path.resolve()), + meta_key_str, json.dumps(bin_paths_str, ensure_ascii=False), str(dest_sample_dir.resolve()) if dest_sample_dir else "", ]) @@ -346,13 +814,13 @@ class ActionLogger: "label": label, "sample_dir": sample_dir_str, "preview_path": str(preview_path.resolve()), - "meta_path": str(meta_path.resolve()), + "meta_path": meta_key_str, "bin_paths": bin_paths_str, "dest_sample_dir": str(dest_sample_dir.resolve()) if dest_sample_dir else None, } f.write(json.dumps(rec, ensure_ascii=False) + "\n") - self.seen.add(str(meta_path.resolve())) + self.seen.add(meta_key_str) class SampleBundleSorterApp: @@ -363,19 +831,19 @@ class SampleBundleSorterApp: out_root: Path, move: bool, resume: bool, - display_height: int = 512, + display_height: int = 720, + show_bin_previews: bool = False, ): self.all_bundles = bundles self.labels = labels self.out_root = out_root self.move = move self.display_height = display_height + self.show_bin_previews_var_value = show_bin_previews self.logger = ActionLogger(out_root) if resume: - self.all_bundles = [ - b for b in self.all_bundles if not self.logger.already_logged(b.meta_path) - ] + self.all_bundles = [b for b in self.all_bundles if not self.logger.already_logged(b.meta_path)] self.idx = 0 self.history = [] @@ -388,24 +856,36 @@ class SampleBundleSorterApp: (label_root / "masks").mkdir(parents=True, exist_ok=True) self.root = tk.Tk() - self.root.title("Agrobotics Sample Bundle Sorter") - self.root.geometry("1100x750") + self.root.title("Weed Pair Sorter - RGB + RE + NIR") + self.root.geometry("1350x900") self.root.bind("", self.on_key) self.top_frame = tk.Frame(self.root) self.top_frame.pack(side=tk.TOP, fill=tk.X) - self.info_label = tk.Label(self.top_frame, text="", font=("Segoe UI", 11)) - self.info_label.pack(side=tk.LEFT, padx=10, pady=6) + self.info_label = tk.Label(self.top_frame, text="", font=("Segoe UI", 11), anchor="w", justify="left") + self.info_label.pack(side=tk.LEFT, padx=10, pady=6, fill=tk.X, expand=True) - self.legend_label = tk.Label(self.top_frame, text=self.build_legend_text(), font=("Segoe UI", 10)) + self.legend_label = tk.Label(self.top_frame, text=self.build_legend_text(), font=("Segoe UI", 10), anchor="e") self.legend_label.pack(side=tk.RIGHT, padx=10, pady=6) + self.opts_frame = tk.Frame(self.root) + self.opts_frame.pack(side=tk.TOP, fill=tk.X) + + self.show_bin_previews_var = tk.BooleanVar(value=self.show_bin_previews_var_value) + tk.Checkbutton( + self.opts_frame, + text="Mostrar previews dos .bin: CAM_A=RGB, CAM_B=RE, CAM_C=NIR", + variable=self.show_bin_previews_var, + command=self.render, + font=("Segoe UI", 10), + ).pack(side=tk.LEFT, padx=10, pady=2) + self.img_frame = tk.Frame(self.root) self.img_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True) - self.preview_label = tk.Label(self.img_frame) - self.preview_label.pack(side=tk.LEFT, expand=True, padx=6, pady=6) + self.preview_label = tk.Label(self.img_frame, bg="#222") + self.preview_label.pack(side=tk.TOP, expand=True, padx=6, pady=6) self.status_var = tk.StringVar(value="Pronto.") self.status_label = tk.Label(self.root, textvariable=self.status_var, font=("Segoe UI", 10), anchor="w") @@ -413,27 +893,13 @@ class SampleBundleSorterApp: self.footer = tk.Label( self.root, - text="1..9/0 = labels | espaço/n/→ = próxima (skip) | p/← = anterior | b = undo | q/Esc = sair", + text="1..9/0 = labels | espaço/n/→ = pular | p/← = anterior | b = undo | v = liga/desliga previews .bin | q/Esc = sair", font=("Segoe UI", 10), ) self.footer.pack(side=tk.BOTTOM, fill=tk.X, pady=2) self.render() - def unique_file(self, p: Path) -> Path: - if not p.exists(): - return p - - stem = p.stem - suffix = p.suffix - k = 1 - - while True: - cand = p.with_name(f"{stem}__{k}{suffix}") - if not cand.exists(): - return cand - k += 1 - def build_legend_text(self) -> str: parts = [] for i, label in enumerate(self.labels, start=1): @@ -441,14 +907,6 @@ class SampleBundleSorterApp: parts.append(f"[{key}] {label}") return " | ".join(parts) - def pil_load_preview(self, preview_path: Path) -> ImageTk.PhotoImage: - img = Image.open(preview_path).convert("RGB") - h_target = self.display_height - w, h = img.size - new_w = int(w * (h_target / h)) - img = img.resize((new_w, h_target), Image.BILINEAR) - return ImageTk.PhotoImage(img) - def render(self): if not self.all_bundles: messagebox.showinfo("Fim", "Não há amostras para exibir.") @@ -459,21 +917,30 @@ class SampleBundleSorterApp: bundle = self.all_bundles[self.idx] try: - tk_img = self.pil_load_preview(bundle.preview_path) + tk_img, status_extra = build_display_image( + bundle, + show_bin_previews=self.show_bin_previews_var.get() if hasattr(self, "show_bin_previews_var") else self.show_bin_previews_var_value, + display_height=self.display_height, + ) self.preview_tk = tk_img self.preview_label.configure(image=self.preview_tk) except Exception as e: self.info_label.configure(text=f"Erro ao abrir preview: {e}") + self.status_var.set(f"ERRO: {e}") return bins_text = ", ".join(p.name for p in bundle.bin_paths) + mapped_text = ", ".join(f"{cam}={path.name}" for cam, path in bundle.bin_by_camera.items()) self.info_label.configure( text=( - f"{self.idx+1}/{len(self.all_bundles)} | " + f"{self.idx + 1}/{len(self.all_bundles)} | " f"sample='{bundle.sample_id}' | " - f"bins={len(bundle.bin_paths)} [{bins_text}]" + f"bins={len(bundle.bin_paths)} [{bins_text}] | " + f"mapa: {mapped_text or 'n/a'}" ) ) + if status_extra: + self.status_var.set(status_extra.strip(" |")) def send_to_label(self, label_index: int): if label_index < 0 or label_index >= len(self.labels): @@ -483,9 +950,9 @@ class SampleBundleSorterApp: bundle = self.all_bundles[self.idx] label_root = self.out_root / label - dst_preview = self.unique_file(label_root / "previews" / bundle.preview_path.name) - dst_meta = self.unique_file(label_root / "metas" / bundle.meta_path.name) - dst_bins = [self.unique_file(label_root / "bins" / p.name) for p in bundle.bin_paths] + dst_preview = unique_file(label_root / "previews" / bundle.preview_path.name) + dst_meta = unique_file(label_root / "metas" / bundle.meta_path.name) + dst_bins = [unique_file(label_root / "bins" / p.name) for p in bundle.bin_paths] try: if self.move: @@ -523,16 +990,12 @@ class SampleBundleSorterApp: "index": self.idx, }) - self.status_var.set( - f"{'Movido' if self.move else 'Copiado'} → '{label}': {bundle.sample_id}" - ) - + self.status_var.set(f"{'Movido' if self.move else 'Copiado'} → '{label}': {bundle.sample_id}") self.idx += 1 if self.idx >= len(self.all_bundles): messagebox.showinfo("Concluído", "Você chegou ao final da fila!") self.root.destroy() return - self.render() except Exception as e: @@ -551,7 +1014,6 @@ class SampleBundleSorterApp: preview_src = Path(last["preview_src"]) meta_src = Path(last["meta_src"]) bin_srcs = [Path(p) for p in last["bin_srcs"]] - preview_dst = Path(last["preview_dst"]) meta_dst = Path(last["meta_dst"]) bin_dsts = [Path(p) for p in last["bin_dsts"]] @@ -588,6 +1050,25 @@ class SampleBundleSorterApp: messagebox.showerror("Erro", f"Falha no undo: {e}") self.status_var.set(f"ERRO: {e}") + def skip(self): + bundle = self.all_bundles[self.idx] + self.logger.log( + action="skip", + sample_dir=bundle.sample_dir, + preview_path=bundle.preview_path, + meta_path=bundle.meta_path, + bin_paths=bundle.bin_paths, + label=None, + dest_sample_dir=None, + ) + self.status_var.set(f"Pulada → {bundle.sample_id}") + self.idx += 1 + if self.idx >= len(self.all_bundles): + messagebox.showinfo("Concluído", "Você chegou ao final da fila!") + self.root.destroy() + return + self.render() + def on_key(self, event): ch = event.keysym.lower() @@ -597,23 +1078,7 @@ class SampleBundleSorterApp: return if ch in ("space", "s", "n", "right"): - bundle = self.all_bundles[self.idx] - self.logger.log( - action="skip", - sample_dir=bundle.sample_dir, - preview_path=bundle.preview_path, - meta_path=bundle.meta_path, - bin_paths=bundle.bin_paths, - label=None, - dest_sample_dir=None, - ) - self.status_var.set(f"Pulada → {bundle.sample_id}") - self.idx += 1 - if self.idx >= len(self.all_bundles): - messagebox.showinfo("Concluído", "Você chegou ao final da fila!") - self.root.destroy() - return - self.render() + self.skip() return if ch in ("p", "left"): @@ -626,6 +1091,12 @@ class SampleBundleSorterApp: self.undo() return + if ch == "v": + self.show_bin_previews_var.set(not self.show_bin_previews_var.get()) + self.status_var.set("Previews .bin ligados." if self.show_bin_previews_var.get() else "Previews .bin desligados.") + self.render() + return + if ch in ("q", "escape"): self.root.destroy() return @@ -637,8 +1108,8 @@ class SampleBundleSorterApp: class SetupWindow: def __init__(self): self.root = tk.Tk() - self.root.title("Configurar - Sample Bundle Sorter") - self.root.geometry("720x520") + self.root.title("Configurar - Weed Pair Sorter") + self.root.geometry("780x590") frm_in = tk.LabelFrame(self.root, text="Pastas de entrada (raízes com subpastas de amostras)") frm_in.pack(fill=tk.BOTH, expand=False, padx=10, pady=8) @@ -667,21 +1138,27 @@ class SetupWindow: frm_opts.pack(fill=tk.X, expand=False, padx=10, pady=8) self.move_var = tk.BooleanVar(value=False) self.resume_var = tk.BooleanVar(value=True) - self.height_var = tk.IntVar(value=512) + self.show_bin_previews_var = tk.BooleanVar(value=True) + self.height_var = tk.IntVar(value=760) tk.Checkbutton(frm_opts, text="Mover em vez de copiar", variable=self.move_var).pack(anchor="w", padx=6) tk.Checkbutton(frm_opts, text="Retomar (pular itens já logados)", variable=self.resume_var).pack(anchor="w", padx=6) + tk.Checkbutton( + frm_opts, + text="Mostrar previews carregados direto dos .bin (CAM_A=RGB, CAM_B=RE, CAM_C=NIR)", + variable=self.show_bin_previews_var, + ).pack(anchor="w", padx=6) frm_height = tk.Frame(frm_opts) frm_height.pack(fill=tk.X, padx=6, pady=6) - tk.Label(frm_height, text="Altura de exibição (px):").pack(side=tk.LEFT) + tk.Label(frm_height, text="Altura total de exibição (px):").pack(side=tk.LEFT) tk.Entry(frm_height, textvariable=self.height_var, width=6).pack(side=tk.LEFT, padx=6) tk.Button(self.root, text="Iniciar classificação", command=self.start).pack(pady=10) tk.Label( self.root, - text="Teclas: 1..9 (0=10ª), espaço/n/→=pular, p/←=anterior, b=undo, q/Esc=sair", + text="Teclas: 1..9 (0=10ª), espaço/n/→=pular, p/←=anterior, b=undo, v=liga/desliga .bin, q/Esc=sair", fg="#555", ).pack(pady=4) @@ -728,6 +1205,7 @@ class SetupWindow: self.move_var.get(), self.resume_var.get(), self.height_var.get(), + self.show_bin_previews_var.get(), ) self.root.destroy() @@ -742,7 +1220,7 @@ def run_with_gui_setup(): if not res: return - input_folders, labels, out_root, move, resume, height = res + input_folders, labels, out_root, move, resume, height, show_bin_previews = res bundles = collect_all_bundles(input_folders) if not bundles: @@ -761,20 +1239,22 @@ def run_with_gui_setup(): move=move, resume=resume, display_height=height, + show_bin_previews=show_bin_previews, ) app.run() def main(): parser = argparse.ArgumentParser( - description="Classificar manualmente amostras no novo formato do módulo multiespectral." + description="Classificar manualmente amostras multiespectrais com preview RGB/RE/NIR dos .bin." ) parser.add_argument("--inputs", nargs="+", help="Pastas-raiz contendo subpastas de amostras") - parser.add_argument("--labels", nargs="+", help="Labels (classes) mapeadas para teclas 1..9/0") + parser.add_argument("--labels", nargs="+", help="Labels/classes mapeadas para teclas 1..9/0") parser.add_argument("--out-root", help="Pasta raiz de saída") parser.add_argument("--move", action="store_true", help="Mover em vez de copiar") parser.add_argument("--resume", action="store_true", help="Pular itens já presentes no sorting_log.csv") - parser.add_argument("--display-height", type=int, default=512, help="Altura de exibição do preview (px)") + parser.add_argument("--display-height", type=int, default=760, help="Altura total de exibição do preview/grid (px)") + parser.add_argument("--show-bin-previews", action="store_true", help="Inicia mostrando CAM_A/CAM_B/CAM_C reconstruídas dos .bin") parser.add_argument("--no-gui-setup", action="store_true", help="Não abrir a GUI de setup") args = parser.parse_args() @@ -796,7 +1276,8 @@ def main(): print( "Nenhuma amostra válida encontrada. " "Formatos aceitos: preview.png + meta.json + CAM_A/B/C.bin, " - "ou .png/json + _CAM_A/B/C.bin, " + ".png/json + _CAM_A/B/C.bin, " + "layout root/previews + root/metas + root/bins, " "ou formato antigo _cam0/cam1/cam2.bin." ) sys.exit(1) @@ -808,6 +1289,7 @@ def main(): move=args.move, resume=args.resume, display_height=args.display_height, + show_bin_previews=args.show_bin_previews, ) app.run() diff --git a/Python/OAK/datasets/oak-fcc-3/_6_normalize.py b/Python/OAK/datasets/oak-fcc-3/_6_normalize.py index 4d4246123..607c30606 100644 --- a/Python/OAK/datasets/oak-fcc-3/_6_normalize.py +++ b/Python/OAK/datasets/oak-fcc-3/_6_normalize.py @@ -372,6 +372,7 @@ def build_tensor_from_sample( "module_params": module_params_path, "bayer_pattern": bayer, "fusion_result": copy_json_safe(getattr(core, "last_fusion_result", None)), + "radiometric_normalization_result": copy_json_safe(getattr(core, "last_radiometric_normalization_result", None)), "patch_normalization_result": copy_json_safe(getattr(core, "last_patch_normalization_result", None)), "frame_quality": copy_json_safe(getattr(core, "last_frame_quality_result", None)), } diff --git a/Python/OAK/datasets/oak-fcc-3/_6b_copy_paste_tensor_aug.py b/Python/OAK/datasets/oak-fcc-3/_6b_copy_paste_tensor_aug.py new file mode 100644 index 000000000..e78069371 --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/_6b_copy_paste_tensor_aug.py @@ -0,0 +1,1429 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +_5b_copy_paste_tensor_aug.py + +Augmentation Copy/Paste pós-normalização para dataset multiespectral OAK-FCC-3. + +Objetivo +-------- +Gerar amostras sintéticas seguras copiando regiões da classe ERVA de imagens doadoras +(chao_erva / chao_cana_erva) e colando em imagens receptoras (chao / chao_cana), +trabalhando diretamente no tensor final já normalizado/alinhado: + + tensor CHW float32 [R, G, B, RE, NIR] + masks/*.npy semantic: 0=chao, 1=cana, 2=erva, 255=ignore + masks_vegetation/*.npy 0=background, 1=vegetation, 255=ignore + masks_cana/*.npy 0=not_cana, 1=cana, 255=ignore + +Por que pós-normalização? +------------------------- +Neste ponto RGB/RE/NIR já estão: + - alinhados por homografia + - cropados + - redimensionados + - corrigidos por flat/radiometria + - no mesmo espaço da máscara + +Isso evita tentar copiar/colar em RAW multi-câmera, onde cada câmera tem geometria +própria e o RGB ainda é Bayer. + +Saída +----- +Cria um novo dataset em: + + dataset/copypaste/group// + tensors/*.npy + masks/*.npy + masks_vegetation/*.npy + masks_cana/*.npy + metas/*.json + previews/*.png # preview RGB real do tensor final + visuals/*.png # grids/debug para auditoria manual + +Uso sugerido +------------ +Gerar copy/paste só depois do normalize e preferencialmente só para TREINO. +Validação deve continuar com dados reais. + +Exemplo básico: + +python _5b_copy_paste_tensor_aug.py ^ + --src-root dataset/1024x640/group ^ + --dst-root dataset/copypaste/group ^ + --copies 2 ^ + --donor-groups chao_erva,chao_cana_erva ^ + --receiver-groups chao,chao_cana ^ + --clear-dst ^ + --save-visuals + +Exemplo mais conservador: + +python _5b_copy_paste_tensor_aug.py ^ + --src-root dataset/1024x640/group ^ + --dst-root dataset/copypaste/group ^ + --copies 1 ^ + --max-pastes 1 ^ + --scale-min 0.85 --scale-max 1.10 ^ + --rotate-deg 6 ^ + --max-cana-overlap-pct 1.0 ^ + --save-visuals ^ + --clear-dst + +Depois rode o auditor: + +python audit_multispec_dataset_bulletproof.py ^ + --input_path dataset/copypaste/group ^ + --out_dir audit_copypaste ^ + --manual-review ^ + --save-visuals +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import random +import shutil +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import cv2 +import numpy as np + + +# ============================================================ +# Constantes do contrato +# ============================================================ + +CHANNELS = ["R", "G", "B", "RE", "NIR"] +IGNORE_INDEX = 255 +CLS_CHAO = 0 +CLS_CANA = 1 +CLS_ERVA = 2 + +EPS = 1e-6 + + +# ============================================================ +# Utilidades +# ============================================================ + + +def ensure_dir(path: Path | str) -> Path: + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + return p + + +def clear_dir(path: Path | str) -> None: + p = Path(path) + if p.exists(): + shutil.rmtree(p) + p.mkdir(parents=True, exist_ok=True) + + +def load_json(path: Path) -> Dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def save_json(path: Path, data: Dict[str, Any]) -> None: + ensure_dir(path.parent) + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def write_csv(path: Path, rows: List[Dict[str, Any]]) -> None: + ensure_dir(path.parent) + if not rows: + path.write_text("", encoding="utf-8") + return + + keys: List[str] = [] + for r in rows: + for k in r.keys(): + if k not in keys: + keys.append(k) + + with path.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore") + w.writeheader() + w.writerows(rows) + + +def parse_csv_list(text: Optional[str]) -> List[str]: + if not text: + return [] + return [x.strip() for x in str(text).split(",") if x.strip()] + + +def safe_float(x: Any, default: float = 0.0) -> float: + try: + if x is None: + return default + v = float(x) + if not np.isfinite(v): + return default + return v + except Exception: + return default + + +def normalize_to_u8(x: np.ndarray, p_low: float = 1.0, p_high: float = 99.0) -> np.ndarray: + arr = np.asarray(x, dtype=np.float32) + finite = np.isfinite(arr) + if not np.any(finite): + return np.zeros(arr.shape, dtype=np.uint8) + vals = arr[finite] + lo = float(np.percentile(vals, p_low)) + hi = float(np.percentile(vals, p_high)) + if hi <= lo + EPS: + hi = lo + 1.0 + y = np.clip((arr - lo) / (hi - lo), 0.0, 1.0) + return np.clip(y * 255.0, 0, 255).astype(np.uint8) + + +def tensor_rgb_to_bgr(tensor: np.ndarray, stretch: bool = False) -> np.ndarray: + rgb = np.transpose(tensor[:3], (1, 2, 0)).astype(np.float32) + if stretch: + chans = [normalize_to_u8(rgb[:, :, c]) for c in range(3)] + rgb8 = np.dstack(chans) + else: + rgb8 = np.clip(rgb * 255.0, 0, 255).astype(np.uint8) + return cv2.cvtColor(rgb8, cv2.COLOR_RGB2BGR) + + +def colorize_mask(mask: np.ndarray) -> np.ndarray: + out = np.zeros((*mask.shape[:2], 3), dtype=np.uint8) + out[mask == CLS_CHAO] = (0, 0, 128) # chao, BGR equivalente ao seu preview antigo + out[mask == CLS_CANA] = (128, 0, 0) # cana + out[mask == CLS_ERVA] = (0, 128, 0) # erva + out[mask == IGNORE_INDEX] = (0, 0, 0) + return out + + +def overlay_mask_on_rgb(tensor: np.ndarray, mask: np.ndarray, alpha: float = 0.35) -> np.ndarray: + rgb = tensor_rgb_to_bgr(tensor, stretch=False) + mask_bgr = colorize_mask(mask) + return cv2.addWeighted(rgb, 1.0 - alpha, mask_bgr, alpha, 0) + + +def put_text(img: np.ndarray, lines: List[str]) -> np.ndarray: + out = img.copy() + h_bar = 26 + 22 * max(0, len(lines) - 1) + cv2.rectangle(out, (0, 0), (out.shape[1], h_bar), (0, 0, 0), -1) + for i, line in enumerate(lines): + cv2.putText( + out, + str(line)[:150], + (8, 20 + 22 * i), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + (0, 255, 255) if i == 0 else (255, 255, 255), + 1, + cv2.LINE_AA, + ) + return out + + +def save_preview_grid( + path: Path, + receiver_before: np.ndarray, + result_tensor: np.ndarray, + result_mask: np.ndarray, + paste_mask: np.ndarray, + title: str, +) -> None: + ensure_dir(path.parent) + before = tensor_rgb_to_bgr(receiver_before, stretch=False) + after = overlay_mask_on_rgb(result_tensor, result_mask, alpha=0.35) + stretch = tensor_rgb_to_bgr(result_tensor, stretch=True) + + paste_vis = np.zeros_like(after) + paste_vis[paste_mask > 0] = (0, 255, 0) + paste_overlay = cv2.addWeighted(tensor_rgb_to_bgr(result_tensor, False), 0.65, paste_vis, 0.35, 0) + + panels = [ + put_text(before, ["receiver before", title]), + put_text(after, ["result + mask", "green=erva"]), + put_text(stretch, ["result RGB stretch", "visual check"]), + put_text(paste_overlay, ["paste mask overlay", f"pixels={int((paste_mask>0).sum())}"]), + ] + + # redimensiona painéis para grid 2x2 + panel_w = 480 + resized = [] + for p in panels: + scale = panel_w / p.shape[1] + resized.append(cv2.resize(p, (panel_w, int(p.shape[0] * scale)), interpolation=cv2.INTER_AREA)) + max_h = max(p.shape[0] for p in resized) + padded = [] + for p in resized: + if p.shape[0] < max_h: + pad = np.zeros((max_h - p.shape[0], p.shape[1], 3), dtype=np.uint8) + p = np.vstack([p, pad]) + padded.append(p) + + gap_v = np.full((max_h, 12, 3), 28, dtype=np.uint8) + row1 = np.hstack([padded[0], gap_v, padded[1]]) + row2 = np.hstack([padded[2], gap_v, padded[3]]) + gap_h = np.full((12, row1.shape[1], 3), 28, dtype=np.uint8) + grid = np.vstack([row1, gap_h, row2]) + cv2.imwrite(str(path), grid) + + +# ============================================================ +# Estruturas +# ============================================================ + + +@dataclass +class Sample: + group: str + base: str + root: Path + tensor_path: Path + mask_path: Path + veg_path: Optional[Path] + cana_path: Optional[Path] + meta_path: Optional[Path] + preview_path: Optional[Path] + stats: Dict[str, Any] + meta_signature: Dict[str, float] + tensor_signature: Dict[str, float] + + +@dataclass +class PasteObject: + donor: Sample + bbox: Tuple[int, int, int, int] + mask_crop: np.ndarray + tensor_crop: np.ndarray + area: int + + +@dataclass +class PasteParams: + scale: float + rotate_deg: float + flip_h: bool + rgb_gain: float + re_gain: float + nir_gain: float + feather_px: int + target_x: int + target_y: int + + +# ============================================================ +# Leitura de dataset pós-normalização +# ============================================================ + + +def list_groups(root: Path, wanted: Optional[List[str]] = None) -> List[Path]: + if not root.exists(): + raise FileNotFoundError(f"src-root não encontrado: {root}") + wanted_set = set(wanted or []) + out = [] + for g in sorted(root.iterdir()): + if not g.is_dir(): + continue + if wanted_set and g.name not in wanted_set: + continue + if (g / "tensors").is_dir() and (g / "masks").is_dir(): + out.append(g) + return out + + +def load_mask_npy(path: Path) -> np.ndarray: + arr = np.load(str(path)) + if arr.ndim != 2: + raise RuntimeError(f"Máscara inválida: {path} shape={arr.shape}") + return arr.astype(np.int64, copy=False) + + +def load_tensor_npy(path: Path) -> np.ndarray: + arr = np.load(str(path)).astype(np.float32) + if arr.ndim != 3: + raise RuntimeError(f"Tensor inválido: {path} shape={arr.shape}") + if arr.shape[0] != 5 and arr.shape[-1] == 5: + arr = np.transpose(arr, (2, 0, 1)) + if arr.shape[0] < 5: + raise RuntimeError(f"Tensor precisa ter 5 canais: {path} shape={arr.shape}") + return np.ascontiguousarray(arr[:5]) + + +def load_json_safe(path: Optional[Path]) -> Dict[str, Any]: + if path is None or not Path(path).exists(): + return {} + try: + return load_json(Path(path)) + except Exception: + return {} + + +def _find_camera_role_map(meta: Dict[str, Any]) -> Dict[str, str]: + out: Dict[str, str] = {} + stream_meta = meta.get('stream_meta') if isinstance(meta.get('stream_meta'), dict) else {} + camera_info = stream_meta.get('camera_info') if isinstance(stream_meta.get('camera_info'), dict) else {} + for cam_key, info in camera_info.items(): + if not isinstance(info, dict): + continue + role = str(info.get('role', '')).lower().strip() + ck = str(cam_key).upper() + if role in ('rgb', 're', 'nir'): + out[ck] = role + elif ck == 'CAM_A': + out[ck] = 'rgb' + elif ck == 'CAM_B': + out[ck] = 're' + elif ck == 'CAM_C': + out[ck] = 'nir' + if not out: + out = {'CAM_A': 'rgb', 'CAM_B': 're', 'CAM_C': 'nir'} + return out + + +def extract_meta_signature(meta: Dict[str, Any]) -> Dict[str, float]: + sig: Dict[str, float] = {} + role_map = _find_camera_role_map(meta) + stream_meta = meta.get('stream_meta') if isinstance(meta.get('stream_meta'), dict) else {} + frame_controls = stream_meta.get('frame_controls') if isinstance(stream_meta.get('frame_controls'), dict) else {} + actual_controls = meta.get('actual_camera_controls') if isinstance(meta.get('actual_camera_controls'), dict) else {} + + def read_ctrl(cam_key: str) -> Tuple[float, float]: + c = frame_controls.get(cam_key) if isinstance(frame_controls.get(cam_key), dict) else actual_controls.get(cam_key) + if not isinstance(c, dict): + return 0.0, 0.0 + exp = safe_float(c.get('exposure_time_us'), 0.0) + iso = safe_float(c.get('sensitivity_iso') or c.get('iso'), 0.0) + return exp, iso + + for ck, role in role_map.items(): + exp, iso = read_ctrl(ck) + if exp > 0: + sig[f'{role}_exp_us'] = exp + if iso > 0: + sig[f'{role}_iso'] = iso + if exp > 0 and iso > 0: + sig[f'{role}_energy'] = exp * iso + sig[f'{role}_log_energy'] = float(np.log(max(1.0, exp * iso))) + return sig + + +def compute_tensor_signature(tensor: np.ndarray) -> Dict[str, float]: + sig: Dict[str, float] = {} + t = np.asarray(tensor, dtype=np.float32) + for i, name in enumerate(CHANNELS): + vals = t[i].reshape(-1) + vals = vals[np.isfinite(vals)] + if vals.size == 0: + sig[f'{name}_med'] = 0.0 + sig[f'{name}_iqr'] = 0.0 + continue + sig[f'{name}_med'] = float(np.median(vals)) + sig[f'{name}_iqr'] = float(np.percentile(vals, 75) - np.percentile(vals, 25)) + rgb = np.transpose(t[:3], (1,2,0)) + lum = 0.299 * rgb[:,:,0] + 0.587 * rgb[:,:,1] + 0.114 * rgb[:,:,2] + vals = lum.reshape(-1) + vals = vals[np.isfinite(vals)] + if vals.size > 0: + sig['rgb_luma_med'] = float(np.median(vals)) + sig['rgb_luma_iqr'] = float(np.percentile(vals, 75) - np.percentile(vals, 25)) + else: + sig['rgb_luma_med'] = 0.0 + sig['rgb_luma_iqr'] = 0.0 + return sig + + +def scene_compatibility_score(receiver_sig: Dict[str, float], donor_sig: Dict[str, float], receiver_meta: Dict[str, float], donor_meta: Dict[str, float]) -> float: + score = 0.0 + # tensor domain similarity: luminância e RE/NIR medianos + for key, w in [('rgb_luma_med', 3.0), ('RE_med', 1.5), ('NIR_med', 1.5)]: + rv = safe_float(receiver_sig.get(key), 0.0) + dv = safe_float(donor_sig.get(key), 0.0) + score += w * abs(rv - dv) + # meta domain similarity: energia de exposição por role (se existir) + for key, w in [('rgb_log_energy', 0.15), ('re_log_energy', 0.10), ('nir_log_energy', 0.10)]: + rv = receiver_meta.get(key) + dv = donor_meta.get(key) + if rv is None or dv is None: + continue + score += w * abs(float(rv) - float(dv)) + return float(score) + + +def derive_stats(mask: np.ndarray, veg: Optional[np.ndarray], cana: Optional[np.ndarray]) -> Dict[str, Any]: + valid = mask != IGNORE_INDEX + total = int(valid.sum()) + out = { + "pixels_total": total, + "pixels_chao": int(((mask == CLS_CHAO) & valid).sum()), + "pixels_cana": int(((mask == CLS_CANA) & valid).sum()), + "pixels_erva": int(((mask == CLS_ERVA) & valid).sum()), + "pct_chao": 0.0, + "pct_cana": 0.0, + "pct_erva": 0.0, + "pixels_target": 0, + "pct_target": 0.0, + } + if total > 0: + out["pct_chao"] = out["pixels_chao"] / total + out["pct_cana"] = out["pixels_cana"] / total + out["pct_erva"] = out["pixels_erva"] / total + + if veg is not None and cana is not None: + valid2 = (veg != IGNORE_INDEX) & (cana != IGNORE_INDEX) + total2 = int(valid2.sum()) + target = (veg == 1) & (cana == 0) & valid2 + out["pixels_target"] = int(target.sum()) + if total2 > 0: + out["pct_target"] = out["pixels_target"] / total2 + else: + out["pixels_target"] = out["pixels_erva"] + out["pct_target"] = out["pct_erva"] + + return out + + +def collect_samples(src_root: Path, groups: List[str], min_erva_pixels: int = 1) -> List[Sample]: + samples: List[Sample] = [] + for group_dir in list_groups(src_root, groups): + tensor_dir = group_dir / "tensors" + mask_dir = group_dir / "masks" + veg_dir = group_dir / "masks_vegetation" + cana_dir = group_dir / "masks_cana" + meta_dir = group_dir / "metas" + preview_dir = group_dir / "previews" + + for tensor_path in sorted(tensor_dir.glob("*.npy")): + base = tensor_path.stem + mask_path = mask_dir / f"{base}.npy" + if not mask_path.exists(): + continue + veg_path = veg_dir / f"{base}.npy" if (veg_dir / f"{base}.npy").exists() else None + cana_path = cana_dir / f"{base}.npy" if (cana_dir / f"{base}.npy").exists() else None + meta_path = meta_dir / f"{base}.json" if (meta_dir / f"{base}.json").exists() else None + preview_path = preview_dir / f"{base}.png" if (preview_dir / f"{base}.png").exists() else None + + mask = load_mask_npy(mask_path) + veg = load_mask_npy(veg_path) if veg_path else None + cana = load_mask_npy(cana_path) if cana_path else None + stats = derive_stats(mask, veg, cana) + meta = load_json_safe(meta_path) + tensor_sig = compute_tensor_signature(load_tensor_npy(tensor_path)) + meta_sig = extract_meta_signature(meta) + + samples.append(Sample( + group=group_dir.name, + base=base, + root=group_dir, + tensor_path=tensor_path, + mask_path=mask_path, + veg_path=veg_path, + cana_path=cana_path, + meta_path=meta_path, + preview_path=preview_path, + stats=stats, + meta_signature=meta_sig, + tensor_signature=tensor_sig, + )) + + return samples + + +# ============================================================ +# Extração de objetos de erva +# ============================================================ + + +def connected_components_from_erva(mask: np.ndarray, min_area: int, max_area: int) -> List[Tuple[int, int, int, int, int]]: + erva = (mask == CLS_ERVA).astype(np.uint8) + if erva.sum() == 0: + return [] + + # Fecha furinhos pequenos sem transformar tudo em bloco sólido. + kernel = np.ones((3, 3), np.uint8) + erva = cv2.morphologyEx(erva, cv2.MORPH_OPEN, kernel, iterations=1) + erva = cv2.morphologyEx(erva, cv2.MORPH_CLOSE, kernel, iterations=1) + + n, labels, stats, _cent = cv2.connectedComponentsWithStats(erva, connectivity=8) + comps: List[Tuple[int, int, int, int, int]] = [] + for i in range(1, n): + x, y, w, h, area = stats[i] + area = int(area) + if area < min_area: + continue + if max_area > 0 and area > max_area: + continue + if w < 4 or h < 4: + continue + comps.append((int(x), int(y), int(w), int(h), area)) + return comps + + +def crop_with_margin(x: int, y: int, w: int, h: int, H: int, W: int, margin: int) -> Tuple[int, int, int, int]: + x1 = max(0, x - margin) + y1 = max(0, y - margin) + x2 = min(W, x + w + margin) + y2 = min(H, y + h + margin) + return x1, y1, x2, y2 + + +def choose_paste_object( + donor_samples: List[Sample], + receiver: Sample, + rng: random.Random, + args, +) -> Optional[PasteObject]: + if not donor_samples: + return None + + # Primeiro faz uma pré-seleção compatível com base em tensor + meta. + candidate_pool = donor_samples + if bool(args.use_scene_compatibility): + k = min(len(donor_samples), max(8, int(args.compatibility_candidate_pool))) + sampled = donor_samples if len(donor_samples) <= k else rng.sample(donor_samples, k) + scored = [] + for d in sampled: + s = scene_compatibility_score(receiver.tensor_signature, d.tensor_signature, receiver.meta_signature, d.meta_signature) + scored.append((s, d)) + scored.sort(key=lambda x: x[0]) + # guarda top candidatos mais parecidos + top_k = min(len(scored), max(4, int(args.compatibility_top_k))) + candidate_pool = [d for _s, d in scored[:top_k]] + + for _ in range(50): + donor = rng.choice(candidate_pool) + mask = load_mask_npy(donor.mask_path) + comps = connected_components_from_erva( + mask, + min_area=int(args.min_component_area), + max_area=int(args.max_component_area), + ) + if not comps: + continue + + areas = np.array([c[4] for c in comps], dtype=np.float64) + probs = np.sqrt(areas) + probs = probs / max(probs.sum(), EPS) + idx = int(np.random.default_rng(rng.randint(0, 2**31 - 1)).choice(len(comps), p=probs)) + x, y, w, h, area = comps[idx] + + tensor = load_tensor_npy(donor.tensor_path) + H, W = mask.shape[:2] + x1, y1, x2, y2 = crop_with_margin(x, y, w, h, H, W, int(args.crop_margin)) + mask_crop = (mask[y1:y2, x1:x2] == CLS_ERVA).astype(np.uint8) + if int(mask_crop.sum()) < int(args.min_component_area): + continue + tensor_crop = tensor[:, y1:y2, x1:x2].copy() + + return PasteObject( + donor=donor, + bbox=(x1, y1, x2, y2), + mask_crop=mask_crop, + tensor_crop=tensor_crop, + area=int(mask_crop.sum()), + ) + + return None + + +# ============================================================ +# Transformação do objeto e blend +# ============================================================ + + +def transform_object(obj: PasteObject, p: PasteParams) -> Tuple[np.ndarray, np.ndarray]: + tensor = obj.tensor_crop.astype(np.float32) + mask = obj.mask_crop.astype(np.uint8) + C, h, w = tensor.shape + + if p.flip_h: + tensor = tensor[:, :, ::-1].copy() + mask = mask[:, ::-1].copy() + + scale = max(0.1, float(p.scale)) + new_w = max(4, int(round(w * scale))) + new_h = max(4, int(round(h * scale))) + + # Primeiro escala. + tensor_hw = np.transpose(tensor, (1, 2, 0)) + tensor_hw = cv2.resize(tensor_hw, (new_w, new_h), interpolation=cv2.INTER_LINEAR) + mask = cv2.resize(mask, (new_w, new_h), interpolation=cv2.INTER_NEAREST) + + # Depois rotação conservadora mantendo canvas. + angle = float(p.rotate_deg) + if abs(angle) > 1e-3: + cx, cy = new_w * 0.5, new_h * 0.5 + M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0) + cos = abs(M[0, 0]) + sin = abs(M[0, 1]) + bound_w = int(new_h * sin + new_w * cos) + bound_h = int(new_h * cos + new_w * sin) + M[0, 2] += bound_w * 0.5 - cx + M[1, 2] += bound_h * 0.5 - cy + tensor_hw = cv2.warpAffine( + tensor_hw, + M, + (bound_w, bound_h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REFLECT_101, + ) + mask = cv2.warpAffine( + mask, + M, + (bound_w, bound_h), + flags=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + tensor_out = np.transpose(tensor_hw.astype(np.float32), (2, 0, 1)) + + # Aplica ganhos leves e coerentes: RGB anda junto; RE/NIR separados. + if C >= 3: + tensor_out[0:3] = tensor_out[0:3] * np.float32(p.rgb_gain) + if C >= 4: + tensor_out[3] = tensor_out[3] * np.float32(p.re_gain) + if C >= 5: + tensor_out[4] = tensor_out[4] * np.float32(p.nir_gain) + + return np.ascontiguousarray(tensor_out), mask.astype(np.uint8) + + +def make_feather_alpha(mask: np.ndarray, feather_px: int) -> np.ndarray: + m = (mask > 0).astype(np.float32) + if int(feather_px) <= 0: + return m + + # Distância dentro do objeto para criar borda suave. + dist = cv2.distanceTransform((m > 0).astype(np.uint8), cv2.DIST_L2, 3) + alpha = np.clip(dist / max(1.0, float(feather_px)), 0.0, 1.0) + + # Suaviza sem vazar demais. + k = max(3, int(feather_px) * 2 + 1) + if k % 2 == 0: + k += 1 + alpha = cv2.GaussianBlur(alpha, (k, k), 0) + alpha[m <= 0] = 0.0 + return np.clip(alpha, 0.0, 1.0).astype(np.float32) + + +def local_channel_match( + obj_tensor: np.ndarray, + recv_tensor: np.ndarray, + obj_mask: np.ndarray, + dst_x: int, + dst_y: int, + patch_radius: int, + strength: float, +) -> np.ndarray: + """ + Ajusta o objeto para a vizinhança local do receptor de forma conservadora. + RGB é ajustado como um bloco (luminância compartilhada) para evitar matiz + roxa/rosada. RE e NIR ajustam separadamente, mas com limites menores. + """ + if strength <= 0: + return obj_tensor + + C, oh, ow = obj_tensor.shape + _, H, W = recv_tensor.shape + x1 = max(0, dst_x - patch_radius) + y1 = max(0, dst_y - patch_radius) + x2 = min(W, dst_x + ow + patch_radius) + y2 = min(H, dst_y + oh + patch_radius) + + recv_patch = recv_tensor[:, y1:y2, x1:x2] + if recv_patch.size == 0: + return obj_tensor + + out = obj_tensor.copy() + m = obj_mask > 0 + if int(m.sum()) < 10: + return out + + # RGB como um bloco, preservando cromaticidade. + if C >= 3: + obj_rgb = np.transpose(out[:3], (1, 2, 0)) + recv_rgb = np.transpose(recv_patch[:3], (1, 2, 0)) + obj_l = 0.299 * obj_rgb[:, :, 0] + 0.587 * obj_rgb[:, :, 1] + 0.114 * obj_rgb[:, :, 2] + recv_l = 0.299 * recv_rgb[:, :, 0] + 0.587 * recv_rgb[:, :, 1] + 0.114 * recv_rgb[:, :, 2] + obj_vals = obj_l[m] + recv_vals = recv_l.reshape(-1) + recv_vals = recv_vals[np.isfinite(recv_vals)] + obj_vals = obj_vals[np.isfinite(obj_vals)] + if obj_vals.size >= 10 and recv_vals.size >= 10: + obj_med = float(np.median(obj_vals)) + recv_med = float(np.median(recv_vals)) + obj_iqr = float(np.percentile(obj_vals, 75) - np.percentile(obj_vals, 25)) + recv_iqr = float(np.percentile(recv_vals, 75) - np.percentile(recv_vals, 25)) + gain = 1.0 + if obj_iqr > 1e-4 and recv_iqr > 1e-4: + gain = np.clip(recv_iqr / obj_iqr, 0.90, 1.10) + bias = np.clip(recv_med - obj_med * gain, -0.04, 0.04) + adjusted = out[:3] * gain + bias + out[:3] = (1.0 - strength) * out[:3] + strength * adjusted + + # RE e NIR separados, com envelope mais conservador. + for c in range(3, min(C, 5)): + obj_vals = out[c][m] + recv_vals = recv_patch[c].reshape(-1) + recv_vals = recv_vals[np.isfinite(recv_vals)] + obj_vals = obj_vals[np.isfinite(obj_vals)] + if obj_vals.size < 10 or recv_vals.size < 10: + continue + obj_med = float(np.median(obj_vals)) + recv_med = float(np.median(recv_vals)) + obj_iqr = float(np.percentile(obj_vals, 75) - np.percentile(obj_vals, 25)) + recv_iqr = float(np.percentile(recv_vals, 75) - np.percentile(recv_vals, 25)) + gain = 1.0 + if obj_iqr > 1e-4 and recv_iqr > 1e-4: + gain = np.clip(recv_iqr / obj_iqr, 0.92, 1.08) + bias = np.clip(recv_med - obj_med * gain, -0.035, 0.035) + adjusted = out[c] * gain + bias + out[c] = (1.0 - strength) * out[c] + strength * adjusted + + return out.astype(np.float32) + + +def valid_paste_position( + recv_mask: np.ndarray, + recv_cana: Optional[np.ndarray], + obj_mask: np.ndarray, + x: int, + y: int, + args, +) -> Tuple[bool, Dict[str, float]]: + oh, ow = obj_mask.shape + H, W = recv_mask.shape + if x < 0 or y < 0 or x + ow > W or y + oh > H: + return False, {"reason_code": 1.0} + + paste_area = int((obj_mask > 0).sum()) + if paste_area < int(args.min_paste_pixels_after_transform): + return False, {"paste_area": paste_area, "reason_code": 2.0} + + region_sem = recv_mask[y:y+oh, x:x+ow] + valid_region = region_sem != IGNORE_INDEX + overlap_ignore = int(((obj_mask > 0) & (~valid_region)).sum()) + ignore_pct = 100.0 * overlap_ignore / max(1, paste_area) + if ignore_pct > float(args.max_ignore_overlap_pct): + return False, {"paste_area": paste_area, "ignore_overlap_pct": ignore_pct, "reason_code": 3.0} + + cana_overlap_pct = 0.0 + if recv_cana is not None: + region_cana = recv_cana[y:y+oh, x:x+ow] + cana_overlap = int(((obj_mask > 0) & (region_cana == 1)).sum()) + cana_overlap_pct = 100.0 * cana_overlap / max(1, paste_area) + if cana_overlap_pct > float(args.max_cana_overlap_pct): + return False, {"paste_area": paste_area, "cana_overlap_pct": cana_overlap_pct, "reason_code": 4.0} + else: + cana_overlap = int(((obj_mask > 0) & (region_sem == CLS_CANA)).sum()) + cana_overlap_pct = 100.0 * cana_overlap / max(1, paste_area) + if cana_overlap_pct > float(args.max_cana_overlap_pct): + return False, {"paste_area": paste_area, "cana_overlap_pct": cana_overlap_pct, "reason_code": 4.0} + + # Evita colar muito em cima de erva já existente para realmente aumentar variedade. + existing_erva_overlap = int(((obj_mask > 0) & (region_sem == CLS_ERVA)).sum()) + existing_erva_pct = 100.0 * existing_erva_overlap / max(1, paste_area) + if existing_erva_pct > float(args.max_existing_erva_overlap_pct): + return False, {"paste_area": paste_area, "existing_erva_overlap_pct": existing_erva_pct, "reason_code": 5.0} + + return True, { + "paste_area": float(paste_area), + "ignore_overlap_pct": float(ignore_pct), + "cana_overlap_pct": float(cana_overlap_pct), + "existing_erva_overlap_pct": float(existing_erva_pct), + "reason_code": 0.0, + } + + +def choose_position( + recv_mask: np.ndarray, + recv_cana: Optional[np.ndarray], + obj_mask: np.ndarray, + rng: random.Random, + args, +) -> Tuple[Optional[int], Optional[int], Dict[str, float]]: + H, W = recv_mask.shape + oh, ow = obj_mask.shape + if oh >= H or ow >= W: + return None, None, {"placement_failed": 1.0, "reason": "object_larger_than_receiver"} + + margin = int(args.receiver_border_margin) + x_min, y_min = margin, margin + x_max, y_max = W - ow - margin, H - oh - margin + if x_max <= x_min or y_max <= y_min: + x_min, y_min = 0, 0 + x_max, y_max = W - ow, H - oh + + best = None + best_info: Dict[str, float] = {} + + for attempt in range(int(args.max_place_attempts)): + x = rng.randint(max(0, x_min), max(0, x_max)) + y = rng.randint(max(0, y_min), max(0, y_max)) + + ok, info = valid_paste_position(recv_mask, recv_cana, obj_mask, x, y, args) + if ok: + info["placement_attempt"] = float(attempt + 1) + return x, y, info + + # guarda melhor tentativa por menor cana/ignore overlap + score = info.get("cana_overlap_pct", 999.0) + info.get("ignore_overlap_pct", 999.0) + info.get("existing_erva_overlap_pct", 999.0) + if best is None or score < best[0]: + best = (score, x, y) + best_info = info + + best_info["placement_failed"] = 1.0 + return None, None, best_info + + +def apply_paste( + recv_tensor: np.ndarray, + recv_sem: np.ndarray, + recv_veg: Optional[np.ndarray], + recv_cana: Optional[np.ndarray], + obj_tensor: np.ndarray, + obj_mask: np.ndarray, + x: int, + y: int, + feather_px: int, + args, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + out_tensor = recv_tensor.copy() + out_sem = recv_sem.copy() + + if recv_veg is not None: + out_veg = recv_veg.copy() + else: + out_veg = np.zeros_like(recv_sem, dtype=np.int64) + out_veg[recv_sem == CLS_CANA] = 1 + out_veg[recv_sem == CLS_ERVA] = 1 + out_veg[recv_sem == IGNORE_INDEX] = IGNORE_INDEX + + if recv_cana is not None: + out_cana = recv_cana.copy() + else: + out_cana = np.zeros_like(recv_sem, dtype=np.int64) + out_cana[recv_sem == CLS_CANA] = 1 + out_cana[recv_sem == IGNORE_INDEX] = IGNORE_INDEX + + C, oh, ow = obj_tensor.shape + region = out_tensor[:, y:y+oh, x:x+ow] + + binary = (obj_mask > 0).astype(np.uint8) + alpha = make_feather_alpha(binary, feather_px=int(feather_px)) + alpha3 = alpha.reshape(1, oh, ow).astype(np.float32) + + # Só cola nos pixels do objeto. Bordas usam alpha suavizado. + blended = region * (1.0 - alpha3) + obj_tensor * alpha3 + out_tensor[:, y:y+oh, x:x+ow] = blended + + # Segurança: preserva faixa razoável. Não queremos criar valores absurdos. + if bool(args.clip_tensor_01): + out_tensor = np.clip(out_tensor, 0.0, 1.0).astype(np.float32) + else: + out_tensor = np.nan_to_num(out_tensor, nan=0.0, posinf=1.0, neginf=0.0).astype(np.float32) + + paste_pixels = binary > 0 + sem_region = out_sem[y:y+oh, x:x+ow] + veg_region = out_veg[y:y+oh, x:x+ow] + cana_region = out_cana[y:y+oh, x:x+ow] + + # Atualiza máscara semântica. Erva colada vira classe 2. + sem_region[paste_pixels] = CLS_ERVA + veg_region[paste_pixels] = 1 + + # Como a erva colada é target, ela não é cana. + cana_region[paste_pixels] = 0 + + out_sem[y:y+oh, x:x+ow] = sem_region + out_veg[y:y+oh, x:x+ow] = veg_region + out_cana[y:y+oh, x:x+ow] = cana_region + + global_paste_mask = np.zeros_like(recv_sem, dtype=np.uint8) + global_paste_mask[y:y+oh, x:x+ow][paste_pixels] = 1 + + return out_tensor, out_sem, out_veg, out_cana, global_paste_mask + + +# ============================================================ +# Salvamento de amostra sintética +# ============================================================ + + +def save_synthetic_sample( + dst_root: Path, + out_group: str, + new_base: str, + tensor: np.ndarray, + sem: np.ndarray, + veg: np.ndarray, + cana: np.ndarray, + receiver: Sample, + donor_records: List[Dict[str, Any]], + paste_mask: np.ndarray, + args, +) -> Dict[str, Any]: + group_dir = dst_root / out_group + tensors_dir = ensure_dir(group_dir / "tensors") + masks_dir = ensure_dir(group_dir / "masks") + veg_dir = ensure_dir(group_dir / "masks_vegetation") + cana_dir = ensure_dir(group_dir / "masks_cana") + metas_dir = ensure_dir(group_dir / "metas") + previews_dir = ensure_dir(group_dir / "previews") + visuals_dir = ensure_dir(group_dir / "visuals") + + tensor_path = tensors_dir / f"{new_base}.npy" + sem_path = masks_dir / f"{new_base}.npy" + veg_path = veg_dir / f"{new_base}.npy" + cana_path = cana_dir / f"{new_base}.npy" + meta_path = metas_dir / f"{new_base}.json" + preview_path = previews_dir / f"{new_base}.png" + visual_path = visuals_dir / f"{new_base}_debug.png" + + np.save(str(tensor_path), np.ascontiguousarray(tensor.astype(np.float32))) + np.save(str(sem_path), np.ascontiguousarray(sem.astype(np.uint8 if sem.max() <= 255 else np.int64))) + np.save(str(veg_path), np.ascontiguousarray(veg.astype(np.uint8 if veg.max() <= 255 else np.int64))) + np.save(str(cana_path), np.ascontiguousarray(cana.astype(np.uint8 if cana.max() <= 255 else np.int64))) + + # Meta: preserva origem do receptor quando existir e adiciona bloco copy_paste. + meta: Dict[str, Any] = {} + if receiver.meta_path and receiver.meta_path.exists(): + try: + meta = load_json(receiver.meta_path) + except Exception: + meta = {} + + meta.update({ + "synthetic": True, + "saved_payload_type": "tensor_npy", + "saved_tensor_path": str(tensor_path), + "saved_mask_path": str(sem_path), + "saved_payload_shape": list(tensor.shape), + "saved_payload_dtype": "float32", + "channels": CHANNELS, + "copy_paste_augmentation": { + "script": "_5b_copy_paste_tensor_aug.py", + "version": "1.1", + "receiver": { + "group": receiver.group, + "base": receiver.base, + "tensor_path": str(receiver.tensor_path), + "mask_path": str(receiver.mask_path), + }, + "donors": donor_records, + "args_summary": { + "max_pastes": args.max_pastes, + "blend": args.blend, + "match_local_mean": args.match_local_mean, + "feather_px": args.feather_px, + "scale_min": args.scale_min, + "scale_max": args.scale_max, + "rotate_deg": args.rotate_deg, + "max_cana_overlap_pct": args.max_cana_overlap_pct, + "use_scene_compatibility": args.use_scene_compatibility, + }, + }, + }) + save_json(meta_path, meta) + + # Sempre salva preview real do tensor final. + cv2.imwrite(str(preview_path), tensor_rgb_to_bgr(tensor, stretch=False)) + + if args.save_visuals: + save_preview_grid( + visual_path, + receiver_before=load_tensor_npy(receiver.tensor_path), + result_tensor=tensor, + result_mask=sem, + paste_mask=paste_mask, + title=f"{new_base} | recv={receiver.group}/{receiver.base}", + ) + + stats = derive_stats(sem, veg, cana) + return { + "new_base": new_base, + "out_group": out_group, + "tensor_path": str(tensor_path), + "mask_path": str(sem_path), + "meta_path": str(meta_path), + "preview_path": str(preview_path), + "visual_debug_path": str(visual_path) if args.save_visuals else "", + "receiver_group": receiver.group, + "receiver_base": receiver.base, + "num_donors": len(donor_records), + "paste_pixels_total": int((paste_mask > 0).sum()), + **{f"out_{k}": v for k, v in stats.items()}, + } + + +# ============================================================ +# Processo principal +# ============================================================ + + +def load_receiver_arrays(s: Sample) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray]]: + tensor = load_tensor_npy(s.tensor_path) + sem = load_mask_npy(s.mask_path) + veg = load_mask_npy(s.veg_path) if s.veg_path else None + cana = load_mask_npy(s.cana_path) if s.cana_path else None + return tensor, sem, veg, cana + + +def sample_paste_params(rng: random.Random, args) -> PasteParams: + jitter = float(args.channel_gain_jitter) + rgb_gain = rng.uniform(1.0 - jitter, 1.0 + jitter) + # espectrais com jitter ainda menor por segurança visual + spec_jitter = jitter * 0.75 + return PasteParams( + scale=rng.uniform(float(args.scale_min), float(args.scale_max)), + rotate_deg=rng.uniform(-float(args.rotate_deg), float(args.rotate_deg)), + flip_h=rng.random() < float(args.flip_prob), + rgb_gain=rgb_gain, + re_gain=rng.uniform(1.0 - spec_jitter, 1.0 + spec_jitter), + nir_gain=rng.uniform(1.0 - spec_jitter, 1.0 + spec_jitter), + feather_px=int(args.feather_px), + target_x=0, + target_y=0, + ) + + +def make_output_group(receiver_group: str, args) -> str: + if args.output_group_mode == "receiver": + return receiver_group + if args.output_group_mode == "copypaste": + return "copy_paste" + if args.output_group_mode == "suffix": + return f"{receiver_group}_copypaste" + return receiver_group + + +def run_copy_paste(args) -> None: + rng = random.Random(int(args.seed)) + np.random.seed(int(args.seed)) + + src_root = Path(args.src_root) + dst_root = Path(args.dst_root) + + if args.clear_dst: + print(f"[INFO] Limpando destino: {dst_root}") + clear_dir(dst_root) + else: + ensure_dir(dst_root) + + donor_groups = parse_csv_list(args.donor_groups) + receiver_groups = parse_csv_list(args.receiver_groups) + + print("==============================================") + print("Copy/Paste Tensor Aug OAK-FCC-3") + print(f"SRC_ROOT : {src_root}") + print(f"DST_ROOT : {dst_root}") + print(f"donor_groups : {donor_groups}") + print(f"receiver_groups : {receiver_groups}") + print(f"copies : {args.copies}") + print(f"max_pastes : {args.max_pastes}") + print(f"seed : {args.seed}") + print("==============================================") + + donors_all = collect_samples(src_root, donor_groups, min_erva_pixels=int(args.min_total_erva_pixels_donor)) + receivers_all = collect_samples(src_root, receiver_groups, min_erva_pixels=0) + + donors = [s for s in donors_all if int(s.stats.get("pixels_erva", 0)) >= int(args.min_total_erva_pixels_donor)] + receivers = receivers_all + + if args.limit_donors and args.limit_donors > 0: + donors = rng.sample(donors, min(len(donors), int(args.limit_donors))) + if args.limit_receivers and args.limit_receivers > 0: + receivers = rng.sample(receivers, min(len(receivers), int(args.limit_receivers))) + + if not donors: + raise RuntimeError("Nenhum donor com erva suficiente encontrado.") + if not receivers: + raise RuntimeError("Nenhum receiver encontrado.") + + print(f"[DATA] donors={len(donors)} | receivers={len(receivers)}") + + rows: List[Dict[str, Any]] = [] + rejected_rows: List[Dict[str, Any]] = [] + + total_target = len(receivers) * int(args.copies) + generated = 0 + failed = 0 + + for recv_idx, receiver in enumerate(receivers, start=1): + for copy_i in range(int(args.copies)): + seed_local = rng.randint(0, 2**31 - 1) + local_rng = random.Random(seed_local) + new_base = f"{receiver.base}_cp_{copy_i:02d}" + + try: + recv_tensor, recv_sem, recv_veg, recv_cana = load_receiver_arrays(receiver) + H, W = recv_sem.shape + result_tensor = recv_tensor.copy() + result_sem = recv_sem.copy() + result_veg = recv_veg.copy() if recv_veg is not None else None + result_cana = recv_cana.copy() if recv_cana is not None else None + paste_mask_total = np.zeros_like(recv_sem, dtype=np.uint8) + donor_records: List[Dict[str, Any]] = [] + + n_pastes = local_rng.randint(int(args.min_pastes), int(args.max_pastes)) + + for paste_i in range(n_pastes): + obj = choose_paste_object(donors, receiver, local_rng, args) + if obj is None: + rejected_rows.append({ + "receiver": receiver.base, + "copy_i": copy_i, + "paste_i": paste_i, + "reason": "no_valid_donor_object", + }) + continue + + params = sample_paste_params(local_rng, args) + obj_tensor, obj_mask = transform_object(obj, params) + + # Remove objeto pequeno após transformação. + if int((obj_mask > 0).sum()) < int(args.min_paste_pixels_after_transform): + rejected_rows.append({ + "receiver": receiver.base, + "copy_i": copy_i, + "paste_i": paste_i, + "donor": obj.donor.base, + "reason": "object_too_small_after_transform", + "pixels": int((obj_mask > 0).sum()), + }) + continue + + x, y, place_info = choose_position(result_sem, result_cana, obj_mask, local_rng, args) + if x is None or y is None: + rejected_rows.append({ + "receiver": receiver.base, + "copy_i": copy_i, + "paste_i": paste_i, + "donor": obj.donor.base, + "reason": "placement_failed", + **place_info, + }) + continue + + if bool(args.match_local_mean): + obj_tensor = local_channel_match( + obj_tensor=obj_tensor, + recv_tensor=result_tensor, + obj_mask=obj_mask, + dst_x=x, + dst_y=y, + patch_radius=int(args.match_radius), + strength=float(args.match_strength), + ) + + result_tensor, result_sem, result_veg, result_cana, paste_mask = apply_paste( + recv_tensor=result_tensor, + recv_sem=result_sem, + recv_veg=result_veg, + recv_cana=result_cana, + obj_tensor=obj_tensor, + obj_mask=obj_mask, + x=x, + y=y, + feather_px=int(args.feather_px), + args=args, + ) + + paste_mask_total = np.maximum(paste_mask_total, paste_mask) + donor_records.append({ + "donor_group": obj.donor.group, + "donor_base": obj.donor.base, + "scene_compatibility_score": scene_compatibility_score(receiver.tensor_signature, obj.donor.tensor_signature, receiver.meta_signature, obj.donor.meta_signature), + "donor_bbox": list(obj.bbox), + "original_component_area": int(obj.area), + "transformed_paste_pixels": int((obj_mask > 0).sum()), + "x": int(x), + "y": int(y), + "params": asdict(params), + "placement": place_info, + }) + + if len(donor_records) == 0 or int((paste_mask_total > 0).sum()) < int(args.min_total_paste_pixels_output): + failed += 1 + rejected_rows.append({ + "receiver": receiver.base, + "receiver_group": receiver.group, + "copy_i": copy_i, + "new_base": new_base, + "reason": "no_effective_paste", + "paste_pixels_total": int((paste_mask_total > 0).sum()), + }) + continue + + out_group = make_output_group(receiver.group, args) + row = save_synthetic_sample( + dst_root=dst_root, + out_group=out_group, + new_base=new_base, + tensor=result_tensor, + sem=result_sem, + veg=result_veg if result_veg is not None else np.zeros_like(result_sem), + cana=result_cana if result_cana is not None else np.zeros_like(result_sem), + receiver=receiver, + donor_records=donor_records, + paste_mask=paste_mask_total, + args=args, + ) + row.update({ + "seed": seed_local, + "copy_i": copy_i, + "receiver_idx": recv_idx, + "requested_pastes": n_pastes, + }) + rows.append(row) + generated += 1 + + if generated % int(args.print_every) == 0: + print(f"[OK] generated={generated}/{total_target} | failed={failed} | last={new_base}") + + except Exception as e: + failed += 1 + rejected_rows.append({ + "receiver": receiver.base, + "receiver_group": receiver.group, + "copy_i": copy_i, + "new_base": new_base, + "reason": "exception", + "message": str(e), + }) + if args.stop_on_error: + raise + + manifest_path = dst_root / "copy_paste_manifest.csv" + rejected_path = dst_root / "copy_paste_rejected.csv" + summary_path = dst_root / "copy_paste_summary.json" + + write_csv(manifest_path, rows) + write_csv(rejected_path, rejected_rows) + + # Resumo por grupo de saída. + by_group: Dict[str, int] = {} + for r in rows: + g = str(r.get("out_group", "unknown")) + by_group[g] = by_group.get(g, 0) + 1 + + total_paste_pixels = int(sum(int(r.get("paste_pixels_total", 0)) for r in rows)) + summary = { + "schema": "multispec_tensor_copy_paste_aug_v1", + "src_root": str(src_root), + "dst_root": str(dst_root), + "donor_groups": donor_groups, + "receiver_groups": receiver_groups, + "donors_available": len(donors), + "receivers_available": len(receivers), + "requested_outputs": total_target, + "generated": generated, + "failed": failed, + "generated_by_group": by_group, + "total_paste_pixels": total_paste_pixels, + "avg_paste_pixels_per_output": total_paste_pixels / max(1, generated), + "args": vars(args), + } + save_json(summary_path, summary) + + print("\n==============================================") + print("Copy/Paste finalizado") + print(f"Gerados : {generated} / {total_target}") + print(f"Falhas : {failed}") + print(f"Manifest : {manifest_path}") + print(f"Rejeitados : {rejected_path}") + print(f"Summary : {summary_path}") + print(f"Destino : {dst_root}") + print("==============================================") + + +# ============================================================ +# CLI +# ============================================================ + + +def build_arg_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser( + description="Copy/Paste pós-normalização para tensor multiespectral OAK-FCC-3." + ) + + ap.add_argument("--src-root", default="dataset/1024x640/group", help="Raiz group pós-normalização.") + ap.add_argument("--dst-root", default="dataset/copypaste/group", help="Raiz group de saída.") + ap.add_argument("--donor-groups", default="chao_erva,chao_cana_erva", help="Grupos doadores com erva.") + ap.add_argument("--receiver-groups", default="chao,chao_cana", help="Grupos receptores.") + ap.add_argument("--output-group-mode", choices=["receiver", "suffix", "copypaste"], default="suffix", + help="Como nomear grupos de saída.") + ap.add_argument("--copies", type=int, default=1, help="Cópias sintéticas por receiver.") + ap.add_argument("--min-pastes", type=int, default=1, help="Mínimo de objetos colados por imagem.") + ap.add_argument("--max-pastes", type=int, default=2, help="Máximo de objetos colados por imagem.") + ap.add_argument("--seed", type=int, default=42) + ap.add_argument("--clear-dst", action="store_true") + ap.add_argument("--stop-on-error", action="store_true") + ap.add_argument("--print-every", type=int, default=25) + + # Filtros de donor/objeto + ap.add_argument("--min-total-erva-pixels-donor", type=int, default=300, help="Mínimo de pixels erva no donor.") + ap.add_argument("--min-component-area", type=int, default=120, help="Área mínima do componente de erva.") + ap.add_argument("--max-component-area", type=int, default=50000, help="Área máxima do componente. 0 desativa.") + ap.add_argument("--crop-margin", type=int, default=10, help="Margem ao redor do componente recortado.") + ap.add_argument("--min-paste-pixels-after-transform", type=int, default=80) + ap.add_argument("--min-total-paste-pixels-output", type=int, default=120) + + # Transformação + ap.add_argument("--scale-min", type=float, default=0.80) + ap.add_argument("--scale-max", type=float, default=1.20) + ap.add_argument("--rotate-deg", type=float, default=8.0) + ap.add_argument("--flip-prob", type=float, default=0.50) + ap.add_argument("--channel-gain-jitter", type=float, default=0.02, + help="Jitter de ganho do objeto. RGB usa ganho conjunto; RE/NIR usam jitter ainda menor. 0.02 = ±2%%.") + + # Posicionamento seguro + ap.add_argument("--receiver-border-margin", type=int, default=8) + ap.add_argument("--max-place-attempts", type=int, default=80) + ap.add_argument("--max-cana-overlap-pct", type=float, default=1.0, + help="Máx %% de pixels do objeto sobre cana. 1.0 = quase não cola em cana.") + ap.add_argument("--max-ignore-overlap-pct", type=float, default=1.0) + ap.add_argument("--max-existing-erva-overlap-pct", type=float, default=20.0) + + # Blend e matching + ap.add_argument("--blend", choices=["feather"], default="feather") + ap.add_argument("--feather-px", type=int, default=5) + ap.add_argument("--match-local-mean", action="store_true", default=True) + ap.add_argument("--no-match-local-mean", dest="match_local_mean", action="store_false") + ap.add_argument("--match-radius", type=int, default=36) + ap.add_argument("--match-strength", type=float, default=0.35) + ap.add_argument("--clip-tensor-01", action="store_true", default=True) + ap.add_argument("--no-clip-tensor-01", dest="clip_tensor_01", action="store_false") + + # Compatibilidade donor/receiver + ap.add_argument("--use-scene-compatibility", action="store_true", default=True, + help="Usa assinatura de cena (tensor + meta) para preferir donors compatíveis com o receiver.") + ap.add_argument("--no-use-scene-compatibility", dest="use_scene_compatibility", action="store_false") + ap.add_argument("--compatibility-candidate-pool", type=int, default=32) + ap.add_argument("--compatibility-top-k", type=int, default=8) + + # Limites para teste rápido + ap.add_argument("--limit-donors", type=int, default=0) + ap.add_argument("--limit-receivers", type=int, default=0) + + # Visual + ap.add_argument("--save-visuals", action="store_true", help="Salva preview 2x2 para auditoria manual.") + + return ap + + +def main() -> None: + args = build_arg_parser().parse_args() + run_copy_paste(args) + + +if __name__ == "__main__": + main() diff --git a/Python/OAK/datasets/oak-fcc-3/_7_split.py b/Python/OAK/datasets/oak-fcc-3/_7_split.py index 0398a2d97..648a2a930 100644 --- a/Python/OAK/datasets/oak-fcc-3/_7_split.py +++ b/Python/OAK/datasets/oak-fcc-3/_7_split.py @@ -1,5 +1,50 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +""" +_7_split_multi_source.py + +Split estratificado por grupo/família para OAK-FCC-3, com suporte a múltiplas +raízes de dataset e proteção contra vazamento de dados sintéticos/augmentados. + +Por que este script existe? +--------------------------- +No fluxo atual temos, por exemplo: + + dataset/1024x640/group -> dados reais normalizados + dataset/copypaste/group -> dados sintéticos copy/paste pós-normalização + +A validação precisa continuar 100% real. Então este script: + - usa dados reais para decidir o split por família + - manda aug/copy-paste/sintéticos somente para TRAIN + - opcionalmente só inclui sintéticos cuja família real caiu no TRAIN + - copia tensores, máscaras, masks auxiliares, metas, previews e visuals + +Estrutura esperada: + //tensors/*.npy + //masks/*.npy + //masks_vegetation/*.npy + //masks_cana/*.npy + //metas/*.json + //previews/*.png + //visuals/*.png + +Uso recomendado para real + copy/paste: +--------------------------------------- +python _7_split_multi_source.py ^ + --src-roots dataset/1024x640/group,dataset/copypaste/group ^ + --train-only-roots dataset/copypaste/group ^ + --train 0.7 --val 0.3 --test 0.0 ^ + --synthetic-train-only ^ + --synthetic-respect-family-split ^ + --clear-dst + +Com isso: + - dados reais vão para train/val conforme split + - copy/paste vai apenas para train + - copy/paste derivado de família que caiu em val/test é ignorado por padrão +""" + +from __future__ import annotations import os import re @@ -8,166 +53,388 @@ import json import shutil import random import argparse +from dataclasses import dataclass from pathlib import Path +from typing import Dict, List, Optional, Tuple, Any -with open("config.json", "r", encoding="utf-8") as f: - config = json.load(f) +# ============================================================ +# Config +# ============================================================ -RESOLUCAO = tuple(config.get("resolucao")) +try: + with open("config.json", "r", encoding="utf-8") as f: + CONFIG = json.load(f) +except Exception: + CONFIG = {} + +RESOLUCAO = tuple(CONFIG.get("resolucao", [1024, 640])) +MULTI_HEAD = bool(CONFIG.get("multi_head", False)) TENSOR_EXT = ".npy" MASK_NPY_SUFFIX = ".npy" + AUX_MASK_DIRS = [ "masks_vegetation", "masks_cana", ] +COPY_OPTIONAL_DIRS = [ + "metas", + "previews", + "visuals", +] + +IGNORE_FILENAMES = { + "normalize_manifest.csv", + "copy_paste_manifest.csv", + "copy_paste_rejected.csv", + "copy_paste_summary.json", + "audit_summary.json", + "audit_health.json", +} + + +# ============================================================ +# Regex de família/origem +# ============================================================ + RE_ORIGINAL_PREFIX = re.compile(r"^original_(.+)$", re.IGNORECASE) -RE_AUGMENTED_FAMILY = re.compile(r"^augmented_(.+?)(?:_aug[a-zA-Z0-9]*_\d+)?$", re.IGNORECASE) + +# augmented_abc_augx_00 -> family abc +RE_AUGMENTED_FAMILY = re.compile( + r"^augmented_(.+?)(?:_aug[a-zA-Z0-9]*_\d+)?$", + re.IGNORECASE, +) RE_AUG_SUFFIX = re.compile(r"_aug[a-zA-Z0-9]*_\d+$", re.IGNORECASE) -MULTI_HEAD = bool(config.get("multi_head", False)) + +# copy-paste: abc_cp_00 -> family abc +RE_COPYPASTE_SUFFIX = re.compile(r"(.+)_cp_\d+$", re.IGNORECASE) + +# grupos copy/paste: chao_cana_copypaste -> split_group chao_cana +RE_GROUP_COPYPASTE_SUFFIX = re.compile(r"(.+)_copypaste$", re.IGNORECASE) -def garantir(p): - os.makedirs(p, exist_ok=True) +# ============================================================ +# Utilidades +# ============================================================ + +def garantir(p: str | Path) -> None: + os.makedirs(str(p), exist_ok=True) -def limpar_dir(p): - if os.path.isdir(p): - shutil.rmtree(p) +def limpar_dir(p: str | Path) -> None: + if os.path.isdir(str(p)): + shutil.rmtree(str(p)) garantir(p) -def lista_grupos(root): +def norm_path(p: str | Path) -> str: + return os.path.normcase(os.path.abspath(str(p))) + + +def parse_csv_list(s: Optional[str]) -> List[str]: + if not s: + return [] + return [x.strip() for x in str(s).split(",") if x.strip()] + + +def parse_map(s: str, value_type=int) -> Dict[str, Any]: + out: Dict[str, Any] = {} + if not s: + return out + for item in s.split(","): + item = item.strip() + if not item: + continue + if ":" not in item: + raise ValueError(f"Item de mapa inválido: {item}. Use grupo:valor") + k, v = item.split(":", 1) + out[k.strip()] = value_type(v.strip()) + return out + + +def load_json_safe(path: str | Path) -> Dict[str, Any]: + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def save_json(path: str | Path, data: Dict[str, Any]) -> None: + garantir(os.path.dirname(str(path))) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def lista_grupos(root: str | Path) -> List[str]: + root = str(root) if not os.path.isdir(root): return [] out = [] - for g in sorted(os.listdir(root)): gdir = os.path.join(root, g) - if not os.path.isdir(gdir): continue - if ( os.path.isdir(os.path.join(gdir, "tensors")) and os.path.isdir(os.path.join(gdir, "masks")) ): out.append(g) - return out -def listar_tensors(tensor_dir): - if not os.path.isdir(tensor_dir): +def listar_tensors(tensor_dir: str | Path) -> List[str]: + if not os.path.isdir(str(tensor_dir)): return [] - return sorted([ - f for f in os.listdir(tensor_dir) + f for f in os.listdir(str(tensor_dir)) if f.lower().endswith(TENSOR_EXT) ]) -def mask_npy_from_tensor_name(tensor_name): +def mask_npy_from_tensor_name(tensor_name: str) -> str: base, _ = os.path.splitext(tensor_name) return base + MASK_NPY_SUFFIX -def classify_source_and_family(filename_no_ext): - """ - Mantém compatibilidade futura com augmentation. +def base_no_ext(filename: str) -> str: + return os.path.splitext(filename)[0] - Exemplos: - original_abc -> source=original, family=abc - augmented_abc_aug_00 -> source=augmented, family=abc - abc_aug_00 -> source=augmented, family=abc - abc -> source=unknown, family=abc - No caso atual, sem aug, source=unknown é tratado como original. +def normalize_group_for_split(group_name: str) -> str: """ + chao_cana_copypaste -> chao_cana + outros ficam iguais. + """ + m = RE_GROUP_COPYPASTE_SUFFIX.match(group_name) + if m: + return m.group(1) + return group_name + + +def output_group_name(source_group: str, split_group: str, is_synthetic: bool, mode: str) -> str: + """ + mode: + source: mantém nome original do grupo da fonte + base: usa grupo normalizado do split + suffix: sintético vai para _synthetic, real fica base + """ + if mode == "source": + return source_group + if mode == "base": + return split_group + if mode == "suffix": + if is_synthetic: + return f"{split_group}_synthetic" + return split_group + return source_group + + +def classify_source_and_family(filename_no_ext: str, meta: Optional[Dict[str, Any]] = None) -> Tuple[str, str, bool]: + """ + Retorna: + source: original | augmented | copypaste | synthetic | unknown + family: id da família para impedir vazamento + is_synthetic: True para aug/copy/synthetic + + Regras: + original_abc -> original, abc + augmented_abc_aug_00 -> augmented, abc + abc_aug_00 -> augmented, abc + abc_cp_00 -> copypaste, abc + meta.synthetic == true -> synthetic/copypaste, tenta family pelo receiver/base + abc -> unknown, abc, tratado como original real + """ + meta = meta or {} + m = RE_ORIGINAL_PREFIX.match(filename_no_ext) if m: - return "original", m.group(1) + return "original", m.group(1), False m = RE_AUGMENTED_FAMILY.match(filename_no_ext) if m: - return "augmented", m.group(1) + return "augmented", m.group(1), True if RE_AUG_SUFFIX.search(filename_no_ext): fam = RE_AUG_SUFFIX.sub("", filename_no_ext) - return "augmented", fam + return "augmented", fam, True - return "unknown", filename_no_ext + m = RE_COPYPASTE_SUFFIX.match(filename_no_ext) + if m: + return "copypaste", m.group(1), True + + # Meta do copy/paste v1/v2. + cp = meta.get("copy_paste_augmentation") + if isinstance(cp, dict): + recv = cp.get("receiver") + if isinstance(recv, dict): + fam = str(recv.get("base") or filename_no_ext) + return "copypaste", fam, True + return "copypaste", filename_no_ext, True + + if bool(meta.get("synthetic", False)): + return "synthetic", filename_no_ext, True + + return "unknown", filename_no_ext, False -def build_family_index(tensor_dir, mask_dir): - """ - family -> { - "original": tensor_name or None, - "augmented": [tensor_name, ...], - "all": [...] - } +@dataclass +class Sample: + src_root: str + source_root_label: str + source_root_train_only: bool - Só indexa se houver mask .npy correspondente. - """ - familias = {} - tensors = listar_tensors(tensor_dir) + source_group: str + split_group: str + output_group_default: str - for tensor_name in tensors: - base_no_ext, _ = os.path.splitext(tensor_name) - mask_npy_name = mask_npy_from_tensor_name(tensor_name) + tensor_path: str + tensor_name: str + base: str - if not os.path.exists(os.path.join(mask_dir, mask_npy_name)): + mask_path: str + source: str + family: str + is_synthetic: bool + + meta_path: Optional[str] + preview_path: Optional[str] + visual_path: Optional[str] + + +# ============================================================ +# Coleta de amostras +# ============================================================ + +def collect_samples_from_root( + src_root: str | Path, + train_only_roots_norm: set[str], + label: str = "", + groups_filter: Optional[set[str]] = None, +) -> List[Sample]: + src_root = str(src_root) + src_root_norm = norm_path(src_root) + root_train_only = src_root_norm in train_only_roots_norm + root_label = label or os.path.basename(os.path.normpath(src_root)) or src_root + + samples: List[Sample] = [] + + for group_name in lista_grupos(src_root): + if groups_filter and group_name not in groups_filter and normalize_group_for_split(group_name) not in groups_filter: continue - source, fam = classify_source_and_family(base_no_ext) + group_dir = os.path.join(src_root, group_name) + tensor_dir = os.path.join(group_dir, "tensors") + mask_dir = os.path.join(group_dir, "masks") + meta_dir = os.path.join(group_dir, "metas") + preview_dir = os.path.join(group_dir, "previews") + visual_dir = os.path.join(group_dir, "visuals") - d = familias.setdefault( - fam, - { - "original": None, - "augmented": [], - "all": [], - } + split_group = normalize_group_for_split(group_name) + + for tensor_name in listar_tensors(tensor_dir): + base = base_no_ext(tensor_name) + mask_name = mask_npy_from_tensor_name(tensor_name) + tensor_path = os.path.join(tensor_dir, tensor_name) + mask_path = os.path.join(mask_dir, mask_name) + + if not os.path.exists(mask_path): + continue + + meta_path = os.path.join(meta_dir, base + ".json") + if not os.path.exists(meta_path): + meta_path = None + + meta = load_json_safe(meta_path) if meta_path else {} + source, family, is_synthetic = classify_source_and_family(base, meta) + + preview_path = None + for ext in (".png", ".jpg", ".jpeg"): + cand = os.path.join(preview_dir, base + ext) + if os.path.exists(cand): + preview_path = cand + break + + visual_path = None + for ext in (".png", ".jpg", ".jpeg"): + cand = os.path.join(visual_dir, base + "_debug" + ext) + if os.path.exists(cand): + visual_path = cand + break + cand2 = os.path.join(visual_dir, base + ext) + if os.path.exists(cand2): + visual_path = cand2 + break + + samples.append(Sample( + src_root=src_root, + source_root_label=root_label, + source_root_train_only=root_train_only, + + source_group=group_name, + split_group=split_group, + output_group_default=group_name, + + tensor_path=tensor_path, + tensor_name=tensor_name, + base=base, + + mask_path=mask_path, + source=source, + family=family, + is_synthetic=is_synthetic, + + meta_path=meta_path, + preview_path=preview_path, + visual_path=visual_path, + )) + + return samples + + +def collect_all_samples( + src_roots: List[str], + train_only_roots: List[str], + groups: Optional[List[str]] = None, +) -> List[Sample]: + train_only_norm = {norm_path(p) for p in train_only_roots} + groups_filter = set(groups) if groups else None + + all_samples: List[Sample] = [] + for i, root in enumerate(src_roots): + label = f"root{i}" + samples = collect_samples_from_root( + src_root=root, + train_only_roots_norm=train_only_norm, + label=label, + groups_filter=groups_filter, ) + all_samples.extend(samples) - d["all"].append(tensor_name) - - if source == "original": - d["original"] = tensor_name - - elif source == "augmented": - d["augmented"].append(tensor_name) - - else: - # Sem prefixo: no fluxo atual, é original. - if d["original"] is None: - d["original"] = tensor_name - else: - d["augmented"].append(tensor_name) - - return familias + return all_samples -def allocate_counts(n, p_train, p_val, p_test, min_train, min_val, min_test): +# ============================================================ +# Split por família real +# ============================================================ + +def allocate_counts(n: int, p_train: float, p_val: float, p_test: float, min_train: int, min_val: int, min_test: int) -> Tuple[int, int, int]: n_train = int(round(n * p_train)) n_val = int(round(n * p_val)) n_test = n - n_train - n_val if n_test < 0: excesso = -n_test - take_train = min(excesso, max(0, n_train)) n_train -= take_train excesso -= take_train - if excesso > 0: take_val = min(excesso, max(0, n_val)) n_val -= take_val excesso -= take_val - n_test = 0 min_sum = min_train + min_val + min_test @@ -178,7 +445,6 @@ def allocate_counts(n, p_train, p_val, p_test, min_train, min_val, min_test): n_test = max(n_test, min_test) total = n_train + n_val + n_test - while total > n: if n_test > min_test: n_test -= 1 @@ -188,7 +454,6 @@ def allocate_counts(n, p_train, p_val, p_test, min_train, min_val, min_test): n_train -= 1 else: break - total = n_train + n_val + n_test while total < n: @@ -196,9 +461,7 @@ def allocate_counts(n, p_train, p_val, p_test, min_train, min_val, min_test): n_train += 1 else: n_val += 1 - total = n_train + n_val + n_test - else: n_train = min(n, max(1, min_train)) resto = n - n_train @@ -206,319 +469,318 @@ def allocate_counts(n, p_train, p_val, p_test, min_train, min_val, min_test): n_test = max(0, resto - n_val) diff = n - (n_train + n_val + n_test) - if diff != 0: if diff > 0: take = min(diff, n - n_train) n_train += take diff -= take - if diff > 0: n_val += diff else: diff = -diff - take = min(diff, n_test) n_test -= take diff -= take - if diff > 0: n_val -= diff return n_train, n_val, n_test -def copiar_optional(src_dir, dst_dir, base, ext): - src = os.path.join(src_dir, base + ext) +def build_family_split( + samples: List[Sample], + p_train: float, + p_val: float, + p_test: float, + seed: int, + mins: Dict[str, int], + caps_map: Optional[Dict[str, int]] = None, +) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, int]]]: + """ + Usa apenas amostras reais/originais para decidir a partição das famílias. - if not os.path.exists(src): + Retorna: + family_split[split_group][family] = train|val|test + group_summary[split_group] = contagens de famílias + """ + caps_map = caps_map or {} + + families_by_group: Dict[str, set[str]] = {} + + for s in samples: + if s.source_root_train_only: + continue + if s.is_synthetic: + continue + if s.source in ("augmented", "copypaste", "synthetic"): + continue + + families_by_group.setdefault(s.split_group, set()).add(s.family) + + family_split: Dict[str, Dict[str, str]] = {} + group_summary: Dict[str, Dict[str, int]] = {} + + for group_name in sorted(families_by_group.keys()): + fams = sorted(families_by_group[group_name]) + rng = random.Random(seed) + rng.shuffle(fams) + + total_familias = len(fams) + if total_familias == 0: + group_summary[group_name] = {"familias": 0, "train_families": 0, "val_families": 0, "test_families": 0} + continue + + n_tr, n_va, n_te = allocate_counts( + total_familias, + p_train, + p_val, + p_test, + mins["train"], + mins["val"], + mins["test"], + ) + + fam_train = set(fams[:n_tr]) + fam_val = set(fams[n_tr:n_tr + n_va]) + fam_test = set(fams[n_tr + n_va:n_tr + n_va + n_te]) + + if group_name in caps_map: + cap = int(caps_map[group_name]) + if len(fam_train) > cap: + fam_list = list(fam_train) + rng.shuffle(fam_list) + kept = set(fam_list[:cap]) + dropped = set(fam_list[cap:]) + fam_train = kept + print( + f"[{group_name}] cap-train-families={cap} → " + f"mantidas {len(kept)} famílias, descartadas {len(dropped)} do TRAIN" + ) + + group_map: Dict[str, str] = {} + for f in fam_train: + group_map[f] = "train" + for f in fam_val: + group_map[f] = "val" + for f in fam_test: + group_map[f] = "test" + + family_split[group_name] = group_map + group_summary[group_name] = { + "familias": total_familias, + "train_families": len(fam_train), + "val_families": len(fam_val), + "test_families": len(fam_test), + } + + return family_split, group_summary + + +# ============================================================ +# Cópia +# ============================================================ + +def copy_optional_file(src_path: Optional[str], dst_dir: str, dst_base: str, suffix: str = "") -> Optional[str]: + if not src_path or not os.path.exists(src_path): return None garantir(dst_dir) - - dst = os.path.join(dst_dir, base + ext) - shutil.copy2(src, dst) - + ext = os.path.splitext(src_path)[1] + dst = os.path.join(dst_dir, dst_base + suffix + ext) + shutil.copy2(src_path, dst) return dst -def copiar_mask_dir_optional(src_group_dir, dst_group_dir, mask_dir_name, base): - """ - Copia uma pasta auxiliar de máscara, como: - masks_vegetation/ - masks_cana/ - - Copia: - .npy obrigatório se existir - .png opcional se existir - - Retorna caminhos de destino ou None. - """ - src_dir = os.path.join(src_group_dir, mask_dir_name) - dst_dir = os.path.join(dst_group_dir, mask_dir_name) - - npy_src = os.path.join(src_dir, base + ".npy") - png_src = os.path.join(src_dir, base + ".png") - - result = { - "npy": None, - "png": None, - } - - if not os.path.exists(npy_src): - return result - +def copy_named_optional(src_dir: str, dst_dir: str, base: str, ext: str) -> Optional[str]: + src = os.path.join(src_dir, base + ext) + if not os.path.exists(src): + return None garantir(dst_dir) - - npy_dst = os.path.join(dst_dir, base + ".npy") - shutil.copy2(npy_src, npy_dst) - result["npy"] = npy_dst - - if os.path.exists(png_src): - png_dst = os.path.join(dst_dir, base + ".png") - shutil.copy2(png_src, png_dst) - result["png"] = png_dst - - return result + dst = os.path.join(dst_dir, base + ext) + shutil.copy2(src, dst) + return dst -def copiar( - nomes, - src_group_dir, - dst_group_dir, - copy_meta_preview=True, -): - """ - Copia: - - tensor .npy obrigatório - - mask .npy obrigatória - - mask .png opcional - - meta .json opcional - - preview .png/.jpg/.jpeg opcional - """ - src_tensor_dir = os.path.join(src_group_dir, "tensors") - src_mask_dir = os.path.join(src_group_dir, "masks") - src_meta_dir = os.path.join(src_group_dir, "metas") - src_preview_dir = os.path.join(src_group_dir, "previews") +def copiar_sample( + s: Sample, + dst_root: str, + split_name: str, + out_group_name: str, + copy_meta_preview: bool = True, + copy_visuals: bool = True, +) -> Optional[Dict[str, Any]]: + dst_group_dir = os.path.join(dst_root, split_name, "group", out_group_name) dst_tensor_dir = os.path.join(dst_group_dir, "tensors") dst_mask_dir = os.path.join(dst_group_dir, "masks") dst_meta_dir = os.path.join(dst_group_dir, "metas") dst_preview_dir = os.path.join(dst_group_dir, "previews") + dst_visual_dir = os.path.join(dst_group_dir, "visuals") garantir(dst_tensor_dir) garantir(dst_mask_dir) - rows = [] - moved = 0 + tensor_dst = os.path.join(dst_tensor_dir, s.tensor_name) + mask_dst = os.path.join(dst_mask_dir, os.path.basename(s.mask_path)) - for nome in nomes: - base, _ = os.path.splitext(nome) + if not (os.path.exists(s.tensor_path) and os.path.exists(s.mask_path)): + return None - tensor_src = os.path.join(src_tensor_dir, nome) - mask_npy_name = mask_npy_from_tensor_name(nome) - mask_npy_src = os.path.join(src_mask_dir, mask_npy_name) + shutil.copy2(s.tensor_path, tensor_dst) + shutil.copy2(s.mask_path, mask_dst) - if not (os.path.exists(tensor_src) and os.path.exists(mask_npy_src)): - continue + base = s.base - tensor_dst = os.path.join(dst_tensor_dir, nome) - mask_npy_dst = os.path.join(dst_mask_dir, mask_npy_name) + # mask debug PNG opcional + mask_png_src = os.path.join(os.path.dirname(s.mask_path), base + ".png") + mask_png_dst = None + if os.path.exists(mask_png_src): + mask_png_dst = os.path.join(dst_mask_dir, base + ".png") + shutil.copy2(mask_png_src, mask_png_dst) - shutil.copy2(tensor_src, tensor_dst) - shutil.copy2(mask_npy_src, mask_npy_dst) + # aux masks + aux_masks: Dict[str, Dict[str, Optional[str]]] = {} + src_group_dir = os.path.join(s.src_root, s.source_group) - mask_png_dst = None - meta_dst = None - preview_dst = None + for aux_dir in AUX_MASK_DIRS: + aux_src_dir = os.path.join(src_group_dir, aux_dir) + aux_dst_dir = os.path.join(dst_group_dir, aux_dir) - # Debug visual da máscara - mask_png_src = os.path.join(src_mask_dir, base + ".png") - if os.path.exists(mask_png_src): - mask_png_dst = os.path.join(dst_mask_dir, base + ".png") - shutil.copy2(mask_png_src, mask_png_dst) + aux_npy_src = os.path.join(aux_src_dir, base + ".npy") + aux_png_src = os.path.join(aux_src_dir, base + ".png") - if copy_meta_preview: - meta_dst = copiar_optional(src_meta_dir, dst_meta_dir, base, ".json") + aux_npy_dst = None + aux_png_dst = None - for ext in (".png", ".jpg", ".jpeg"): - cand = os.path.join(src_preview_dir, base + ext) - if os.path.exists(cand): - garantir(dst_preview_dir) - preview_dst = os.path.join(dst_preview_dir, base + ext) - shutil.copy2(cand, preview_dst) - break + if os.path.exists(aux_npy_src): + garantir(aux_dst_dir) + aux_npy_dst = os.path.join(aux_dst_dir, base + ".npy") + shutil.copy2(aux_npy_src, aux_npy_dst) - aux_masks = {} - for aux_dir in AUX_MASK_DIRS: - aux_masks[aux_dir] = copiar_mask_dir_optional( - src_group_dir=src_group_dir, - dst_group_dir=dst_group_dir, - mask_dir_name=aux_dir, - base=base, - ) + if os.path.exists(aux_png_src): + aux_png_dst = os.path.join(aux_dst_dir, base + ".png") + shutil.copy2(aux_png_src, aux_png_dst) - if MULTI_HEAD: - for aux_dir in AUX_MASK_DIRS: - aux_npy = aux_masks.get(aux_dir, {}).get("npy") - if aux_npy is None: - raise RuntimeError( - f"multi_head=true, mas máscara auxiliar ausente: " - f"{aux_dir}/{base}.npy em {src_group_dir}" - ) - - rows.append({ - "base": base, - "tensor": tensor_dst, - - "mask_npy": mask_npy_dst, - "mask_png": mask_png_dst, - - "mask_vegetation_npy": aux_masks.get("masks_vegetation", {}).get("npy"), - "mask_vegetation_png": aux_masks.get("masks_vegetation", {}).get("png"), - - "mask_cana_npy": aux_masks.get("masks_cana", {}).get("npy"), - "mask_cana_png": aux_masks.get("masks_cana", {}).get("png"), - - "meta": meta_dst, - "preview": preview_dst, - }) - - moved += 1 - - return moved, rows - - -def split_group( - group_name, - src_root, - dst_root, - p_train, - p_val, - p_test, - seed, - mins, - caps_map=None, - copy_meta_preview=True, -): - src_group_dir = os.path.join(src_root, group_name) - src_tensor_dir = os.path.join(src_group_dir, "tensors") - src_mask_dir = os.path.join(src_group_dir, "masks") - - familias = build_family_index(src_tensor_dir, src_mask_dir) - familias_originais = [fam for fam, d in familias.items() if d["original"] is not None] - total_familias = len(familias_originais) - - if total_familias == 0: - print(f"[{group_name}] 0 famílias com original, pulando.") - return { - "train": 0, - "val": 0, - "test": 0, - "familias": 0, - "rows": [], + aux_masks[aux_dir] = { + "npy": aux_npy_dst, + "png": aux_png_dst, } - rng = random.Random(seed) - rng.shuffle(familias_originais) - - n_tr, n_va, n_te = allocate_counts( - total_familias, - p_train, - p_val, - p_test, - mins["train"], - mins["val"], - mins["test"], - ) - - fam_train = set(familias_originais[:n_tr]) - fam_val = set(familias_originais[n_tr:n_tr + n_va]) - fam_test = set(familias_originais[n_tr + n_va:n_tr + n_va + n_te]) - - if caps_map and group_name in caps_map: - cap = caps_map[group_name] - - if len(fam_train) > cap: - fam_list = list(fam_train) - rng.shuffle(fam_list) - - kept = set(fam_list[:cap]) - dropped = set(fam_list[cap:]) - fam_train = kept - - print( - f"[{group_name}] cap-train-families={cap} → " - f"mantidas {len(kept)} famílias, descartadas {len(dropped)} do TRAIN" + if MULTI_HEAD and aux_npy_dst is None: + raise RuntimeError( + f"multi_head=true, mas máscara auxiliar ausente: " + f"{aux_dir}/{base}.npy em {src_group_dir}" ) - nomes_train, nomes_val, nomes_test = [], [], [] + meta_dst = None + preview_dst = None + visual_dst = None - for fam, d in familias.items(): - if fam in fam_train: - if d["original"]: - nomes_train.append(d["original"]) - if d["augmented"]: - nomes_train.extend(d["augmented"]) + if copy_meta_preview: + if s.meta_path and os.path.exists(s.meta_path): + garantir(dst_meta_dir) + meta_dst = os.path.join(dst_meta_dir, base + ".json") + shutil.copy2(s.meta_path, meta_dst) - elif fam in fam_val: - if d["original"]: - nomes_val.append(d["original"]) + if s.preview_path and os.path.exists(s.preview_path): + garantir(dst_preview_dir) + preview_dst = os.path.join(dst_preview_dir, base + os.path.splitext(s.preview_path)[1]) + shutil.copy2(s.preview_path, preview_dst) - elif fam in fam_test: - if d["original"]: - nomes_test.append(d["original"]) - - rows_all = [] - - split_defs = [ - ("train", nomes_train), - ("val", nomes_val), - ("test", nomes_test), - ] - - counts = {} - - for split_name, nomes in split_defs: - dst_group_dir = os.path.join(dst_root, split_name, "group", group_name) - - moved, rows = copiar( - nomes=nomes, - src_group_dir=src_group_dir, - dst_group_dir=dst_group_dir, - copy_meta_preview=copy_meta_preview, - ) - - counts[split_name] = moved - - for r in rows: - r["split"] = split_name - r["group"] = group_name - rows_all.append(r) - - print( - f"[{group_name}] famílias={total_familias} → " - f"train={counts['train']}, val={counts['val']}, test={counts['test']}" - ) + if copy_visuals and s.visual_path and os.path.exists(s.visual_path): + garantir(dst_visual_dir) + visual_dst = os.path.join(dst_visual_dir, os.path.basename(s.visual_path)) + shutil.copy2(s.visual_path, visual_dst) return { - "train": counts["train"], - "val": counts["val"], - "test": counts["test"], - "familias": total_familias, - "rows": rows_all, + "split": split_name, + "group": out_group_name, + "source_group": s.source_group, + "split_group": s.split_group, + "base": base, + "family": s.family, + "source": s.source, + "is_synthetic": int(bool(s.is_synthetic)), + "source_root": s.src_root, + "source_root_label": s.source_root_label, + "source_root_train_only": int(bool(s.source_root_train_only)), + + "tensor": tensor_dst, + "mask_npy": mask_dst, + "mask_png": mask_png_dst, + + "mask_vegetation_npy": aux_masks.get("masks_vegetation", {}).get("npy"), + "mask_vegetation_png": aux_masks.get("masks_vegetation", {}).get("png"), + + "mask_cana_npy": aux_masks.get("masks_cana", {}).get("npy"), + "mask_cana_png": aux_masks.get("masks_cana", {}).get("png"), + + "meta": meta_dst, + "preview": preview_dst, + "visual_debug": visual_dst, } -def write_manifest(path, rows): +def decide_sample_split( + s: Sample, + family_split: Dict[str, Dict[str, str]], + args, +) -> Tuple[Optional[str], str]: + """ + Retorna (split, reason) + split None = ignorado + """ + group_map = family_split.get(s.split_group, {}) + assigned = group_map.get(s.family) + + is_train_only_candidate = ( + s.source_root_train_only + or (bool(args.synthetic_train_only) and s.is_synthetic) + or s.source in ("augmented", "copypaste", "synthetic") + ) + + if is_train_only_candidate: + if bool(args.synthetic_respect_family_split): + if assigned is None: + if bool(args.allow_orphan_synthetic_train): + return "train", "train_only_orphan_allowed" + return None, "skip_train_only_orphan_no_real_family" + + if assigned != "train": + return None, f"skip_train_only_family_assigned_{assigned}" + + return "train", "train_only_family_train" + + return "train", "train_only_forced" + + # real/original + if assigned is None: + return None, "skip_real_no_family_assignment" + + return assigned, f"real_assigned_{assigned}" + + +# ============================================================ +# Manifestos +# ============================================================ + +def write_manifest(path: str, rows: List[Dict[str, Any]]) -> None: garantir(os.path.dirname(path)) fieldnames = [ "split", "group", + "source_group", + "split_group", "base", - "tensor", + "family", + "source", + "is_synthetic", + "source_root", + "source_root_label", + "source_root_train_only", + "tensor", "mask_npy", "mask_png", @@ -530,40 +792,40 @@ def write_manifest(path, rows): "meta", "preview", + "visual_debug", ] with open(path, "w", newline="", encoding="utf-8") as f: - w = csv.DictWriter(f, fieldnames=fieldnames) + w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") w.writeheader() w.writerows(rows) -def write_summary(path, summary): +def write_skipped(path: str, rows: List[Dict[str, Any]]) -> None: garantir(os.path.dirname(path)) - - with open(path, "w", encoding="utf-8") as f: - json.dump(summary, f, ensure_ascii=False, indent=2) + fieldnames = [ + "source_root", + "source_group", + "split_group", + "base", + "family", + "source", + "is_synthetic", + "reason", + ] + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + w.writeheader() + w.writerows(rows) -def parse_cap_map(s): - caps = {} +# ============================================================ +# Main +# ============================================================ - if not s: - return caps - - for item in s.split(","): - if not item.strip(): - continue - - k, v = item.strip().split(":") - caps[k.strip()] = int(v) - - return caps - - -def main(): +def main() -> None: ap = argparse.ArgumentParser( - description="Split estratificado por grupo sem vazamento para tensors/masks OAK-FCC-3." + description="Split multi-source OAK-FCC-3 sem vazamento, com sintéticos/aug apenas no train." ) ap.add_argument("--train", type=float, default=0.70) @@ -579,14 +841,28 @@ def main(): "--resolucao", type=str, default=None, - help="Sobrescreve resolução no formato WxH. Ex: 512x512.", + help="Sobrescreve resolução no formato WxH. Ex: 1024x640.", ) ap.add_argument( "--src-root", type=str, default=None, - help="Raiz normalizada. Default: dataset//group", + help="Compatibilidade: uma raiz normalizada. Default: dataset//group", + ) + + ap.add_argument( + "--src-roots", + type=str, + default="", + help="Lista de raízes separadas por vírgula. Ex: dataset/1024x640/group,dataset/copypaste/group", + ) + + ap.add_argument( + "--train-only-roots", + type=str, + default="", + help="Raízes que devem entrar somente no TRAIN. Ex: dataset/copypaste/group", ) ap.add_argument( @@ -600,14 +876,14 @@ def main(): "--groups", type=str, default=None, - help="Lista de grupos separados por vírgula.", + help="Lista de grupos/split_groups separados por vírgula.", ) ap.add_argument( "--cap-train-families", type=str, default="", - help="Mapa 'grupo:cap,...' para limitar famílias no TRAIN. Ex: 'chao:350'", + help="Mapa 'grupo:cap,...' para limitar famílias reais no TRAIN. Ex: 'chao:50'", ) ap.add_argument( @@ -622,6 +898,54 @@ def main(): help="Não copia metas/previews para o split.", ) + ap.add_argument( + "--no-visuals", + action="store_true", + help="Não copia pasta visuals/debug.", + ) + + ap.add_argument( + "--synthetic-train-only", + action="store_true", + default=True, + help="Força samples synthetic/augmented/copypaste para TRAIN apenas.", + ) + + ap.add_argument( + "--allow-synthetic-val", + dest="synthetic_train_only", + action="store_false", + help="Permite sintéticos no val/test. Não recomendado.", + ) + + ap.add_argument( + "--synthetic-respect-family-split", + action="store_true", + default=True, + help="Só inclui sintético no TRAIN se a família real correspondente caiu no TRAIN.", + ) + + ap.add_argument( + "--no-synthetic-respect-family-split", + dest="synthetic_respect_family_split", + action="store_false", + help="Inclui sintéticos no TRAIN mesmo sem checar a família real. Mais arriscado.", + ) + + ap.add_argument( + "--allow-orphan-synthetic-train", + action="store_true", + default=False, + help="Permite sintético no TRAIN mesmo sem família real encontrada.", + ) + + ap.add_argument( + "--output-group-mode", + choices=["source", "base", "suffix"], + default="source", + help="Nome do grupo de saída. source mantém chao_cana_copypaste; base junta em chao_cana; suffix cria _synthetic.", + ) + ap.add_argument( "--manifest", type=str, @@ -636,6 +960,13 @@ def main(): help="JSON de resumo. Default: /split_summary.json", ) + ap.add_argument( + "--skipped", + type=str, + default="", + help="CSV de samples ignorados. Default: /split_skipped.csv", + ) + args = ap.parse_args() if args.resolucao: @@ -647,11 +978,33 @@ def main(): else: resolucao = RESOLUCAO - src_root = args.src_root or os.path.join("dataset", f"{resolucao[0]}x{resolucao[1]}", "group") + default_src = os.path.join("dataset", f"{resolucao[0]}x{resolucao[1]}", "group") + + src_roots = parse_csv_list(args.src_roots) + if args.src_root: + src_roots.insert(0, args.src_root) + if not src_roots: + src_roots = [default_src] + + # Remove duplicatas preservando ordem. + seen = set() + src_roots_unique = [] + for r in src_roots: + nr = norm_path(r) + if nr not in seen: + seen.add(nr) + src_roots_unique.append(r) + src_roots = src_roots_unique + + train_only_roots = parse_csv_list(args.train_only_roots) + + for r in src_roots: + if not os.path.isdir(r): + raise SystemExit(f"[ERRO] src-root não encontrado: {r}") + dst_root = args.dst_root soma = args.train + args.val + args.test - if soma <= 0: raise ValueError("Soma de proporções deve ser > 0.") @@ -665,10 +1018,8 @@ def main(): "test": max(0, args.min_test), } - caps_map = parse_cap_map(args.cap_train_families) - - if not os.path.isdir(src_root): - raise SystemExit(f"[ERRO] src-root não encontrado: {src_root}") + groups = parse_csv_list(args.groups) if args.groups else None + caps_map = parse_map(args.cap_train_families, int) if args.clear_dst: print(f"[INFO] Limpando destino: {dst_root}") @@ -676,69 +1027,137 @@ def main(): else: garantir(dst_root) - grupos = lista_grupos(src_root) + samples = collect_all_samples( + src_roots=src_roots, + train_only_roots=train_only_roots, + groups=groups, + ) - if args.groups: - want = {g.strip() for g in args.groups.split(",") if g.strip()} - grupos = [g for g in grupos if g in want] - - if not grupos: - print(f"[WARN] Nenhum grupo encontrado em: {src_root}") + if not samples: + print("[WARN] Nenhuma amostra encontrada.") return + family_split, family_summary = build_family_split( + samples=samples, + p_train=p_train, + p_val=p_val, + p_test=p_test, + seed=args.seed, + mins=mins, + caps_map=caps_map, + ) + print("==========================================") - print("Split OAK-FCC-3") - print(f"SRC : {src_root}") - print(f"DST : {dst_root}") - print(f"Grupos : {', '.join(grupos)}") - print(f"Split : train={p_train:.3f}, val={p_val:.3f}, test={p_test:.3f}") - print(f"Mínimos : train={mins['train']} val={mins['val']} test={mins['test']}") - print(f"Seed : {args.seed}") + print("Split OAK-FCC-3 Multi-source") + print("SRC_ROOTS:") + for r in src_roots: + marker = " [TRAIN_ONLY]" if norm_path(r) in {norm_path(x) for x in train_only_roots} else "" + print(f" - {r}{marker}") + print(f"DST : {dst_root}") + print(f"Split : train={p_train:.3f}, val={p_val:.3f}, test={p_test:.3f}") + print(f"Mínimos : train={mins['train']} val={mins['val']} test={mins['test']}") + print(f"Seed : {args.seed}") + print(f"Synthetic : train_only={args.synthetic_train_only} respect_family={args.synthetic_respect_family_split}") print("==========================================") - total_global = { + print("\nFamílias reais por split_group:") + for g, info in family_summary.items(): + print( + f"[{g}] famílias={info['familias']} → " + f"train={info['train_families']}, val={info['val_families']}, test={info['test_families']}" + ) + + all_rows: List[Dict[str, Any]] = [] + skipped_rows: List[Dict[str, Any]] = [] + + totals = { "train": 0, "val": 0, "test": 0, - "familias": 0, + "skipped": 0, + "synthetic_train": 0, + "real_train": 0, + "real_val": 0, + "real_test": 0, } - all_rows = [] - summary_groups = {} + by_group: Dict[str, Dict[str, int]] = {} - for g in grupos: - res = split_group( - group_name=g, - src_root=src_root, - dst_root=dst_root, - p_train=p_train, - p_val=p_val, - p_test=p_test, - seed=args.seed, - mins=mins, - caps_map=caps_map, - copy_meta_preview=not args.no_meta_preview, + for s in samples: + split_name, reason = decide_sample_split(s, family_split, args) + + if split_name is None: + totals["skipped"] += 1 + skipped_rows.append({ + "source_root": s.src_root, + "source_group": s.source_group, + "split_group": s.split_group, + "base": s.base, + "family": s.family, + "source": s.source, + "is_synthetic": int(s.is_synthetic), + "reason": reason, + }) + continue + + out_group = output_group_name( + source_group=s.source_group, + split_group=s.split_group, + is_synthetic=s.is_synthetic, + mode=args.output_group_mode, ) - for k in total_global.keys(): - total_global[k] += res.get(k, 0) + row = copiar_sample( + s=s, + dst_root=dst_root, + split_name=split_name, + out_group_name=out_group, + copy_meta_preview=not args.no_meta_preview, + copy_visuals=not args.no_visuals, + ) - all_rows.extend(res.get("rows", [])) + if row is None: + totals["skipped"] += 1 + skipped_rows.append({ + "source_root": s.src_root, + "source_group": s.source_group, + "split_group": s.split_group, + "base": s.base, + "family": s.family, + "source": s.source, + "is_synthetic": int(s.is_synthetic), + "reason": "copy_failed_missing_tensor_or_mask", + }) + continue - summary_groups[g] = { - "train": res.get("train", 0), - "val": res.get("val", 0), - "test": res.get("test", 0), - "familias": res.get("familias", 0), - } + all_rows.append(row) + + totals[split_name] += 1 + if split_name == "train" and s.is_synthetic: + totals["synthetic_train"] += 1 + elif split_name == "train": + totals["real_train"] += 1 + elif split_name == "val": + totals["real_val"] += 1 + elif split_name == "test": + totals["real_test"] += 1 + + gsum = by_group.setdefault(out_group, {"train": 0, "val": 0, "test": 0, "synthetic_train": 0, "skipped": 0}) + gsum[split_name] = gsum.get(split_name, 0) + 1 + if split_name == "train" and s.is_synthetic: + gsum["synthetic_train"] += 1 manifest_path = args.manifest or os.path.join(dst_root, "split_manifest.csv") summary_path = args.summary or os.path.join(dst_root, "split_summary.json") + skipped_path = args.skipped or os.path.join(dst_root, "split_skipped.csv") write_manifest(manifest_path, all_rows) + write_skipped(skipped_path, skipped_rows) summary = { - "src_root": src_root, + "schema": "oak_fcc3_multi_source_split_v1", + "src_roots": src_roots, + "train_only_roots": train_only_roots, "dst_root": dst_root, "resolution": list(resolucao), "proportions": { @@ -748,21 +1167,36 @@ def main(): }, "mins": mins, "seed": args.seed, - "groups": summary_groups, - "total": total_global, + "multi_head": MULTI_HEAD, + "options": { + "synthetic_train_only": args.synthetic_train_only, + "synthetic_respect_family_split": args.synthetic_respect_family_split, + "allow_orphan_synthetic_train": args.allow_orphan_synthetic_train, + "output_group_mode": args.output_group_mode, + }, + "family_summary": family_summary, + "groups": by_group, + "total": totals, + "samples_seen": len(samples), + "manifest": manifest_path, + "skipped": skipped_path, } - write_summary(summary_path, summary) + save_json(summary_path, summary) print("\nResumo global:") - print(f" train: {total_global['train']}") - print(f" val: {total_global['val']}") - print(f" test: {total_global['test']}") - print(f" famílias: {total_global['familias']}") + print(f" train total: {totals['train']}") + print(f" real train: {totals['real_train']}") + print(f" synthetic train: {totals['synthetic_train']}") + print(f" val real: {totals['real_val']}") + print(f" test real: {totals['real_test']}") + print(f" skipped: {totals['skipped']}") print(f"\nManifest: {manifest_path}") + print(f"Skipped : {skipped_path}") print(f"Summary : {summary_path}") - print("\n✅ Split sem vazamento concluído!") + + print("\n✅ Split multi-source sem vazamento concluído!") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/Python/OAK/datasets/oak-fcc-3/_8_train_multihead.py b/Python/OAK/datasets/oak-fcc-3/_8_train_multihead.py index 0ea831d3b..4978b9f49 100644 --- a/Python/OAK/datasets/oak-fcc-3/_8_train_multihead.py +++ b/Python/OAK/datasets/oak-fcc-3/_8_train_multihead.py @@ -288,6 +288,7 @@ class OakFcc3TensorMultiHeadDataset(Dataset): self.channel_indices = channel_indices self.samples = self._collect_samples() + self._add_sample_class_stats() if not self.samples: raise RuntimeError(f"Nenhuma amostra encontrada em: {self.root}") @@ -450,6 +451,59 @@ class OakFcc3TensorMultiHeadDataset(Dataset): "base": s["base"], } + def _add_sample_class_stats(self): + for s in self.samples: + stats = { + "pixels_total": 0, + "pixels_chao": 0, + "pixels_cana": 0, + "pixels_erva": 0, + "pixels_vegetation": 0, + "pixels_target": 0, + "pct_cana": 0.0, + "pct_erva": 0.0, + "pct_target": 0.0, + "has_cana": False, + "has_erva": False, + "has_target": False, + } + + sem_path = s["masks"].get("semantic") + veg_path = s["masks"].get("vegetation") + cana_path = s["masks"].get("cana") + + if sem_path is not None and Path(sem_path).exists(): + sem = np.load(str(sem_path)).astype(np.int64) + valid = sem != 255 + total = int(valid.sum()) + stats["pixels_total"] = total + + if total > 0: + stats["pixels_chao"] = int(((sem == 0) & valid).sum()) + stats["pixels_cana"] = int(((sem == 1) & valid).sum()) + stats["pixels_erva"] = int(((sem == 2) & valid).sum()) + stats["pct_cana"] = stats["pixels_cana"] / total + stats["pct_erva"] = stats["pixels_erva"] / total + + if veg_path is not None and cana_path is not None and Path(veg_path).exists() and Path(cana_path).exists(): + veg = np.load(str(veg_path)).astype(np.int64) + cana = np.load(str(cana_path)).astype(np.int64) + + valid = (veg != 255) & (cana != 255) + total = int(valid.sum()) + + if total > 0: + target = (veg == 1) & (cana == 0) & valid + stats["pixels_vegetation"] = int(((veg == 1) & valid).sum()) + stats["pixels_target"] = int(target.sum()) + stats["pct_target"] = stats["pixels_target"] / total + + stats["has_cana"] = stats["pixels_cana"] > 0 + stats["has_erva"] = stats["pixels_erva"] > 0 + stats["has_target"] = stats["pixels_target"] > 0 + + s["class_stats"] = stats + def collate_fn(batch): imgs = torch.stack([b["image"] for b in batch], dim=0) @@ -534,6 +588,49 @@ def build_normalizer(config: dict, args, device: torch.device): return None, None +def build_sample_weights(ds, mode="target_focus"): + weights = [] + + for s in ds.samples: + st = s.get("class_stats", {}) + group = str(s.get("group", "")).lower() + + pct_cana = float(st.get("pct_cana", 0.0)) + pct_erva = float(st.get("pct_erva", 0.0)) + pct_target = float(st.get("pct_target", 0.0)) + + has_cana = bool(st.get("has_cana", False)) + has_erva = bool(st.get("has_erva", False)) + has_target = bool(st.get("has_target", False)) + + w = 1.0 + + # Reduz chão puro + if not has_cana and not has_erva and not has_target: + w *= 0.35 + + # Aumenta cana + if has_cana: + w *= 1.25 + + # Aumenta erva/target com força + if has_erva: + w *= 3.0 + + if has_target: + w *= 4.0 + + # Bônus suave por área real de target/erva + w *= 1.0 + min(5.0, 80.0 * pct_target) + w *= 1.0 + min(3.0, 50.0 * pct_erva) + + # Evita pesos absurdos + w = max(0.05, min(w, 20.0)) + weights.append(w) + + return torch.tensor(weights, dtype=torch.double) + + DEFAULT_CHANNEL_ORDER = ["R", "G", "B", "RE", "NIR"] def get_input_channel_names(config: dict) -> List[str]: @@ -1493,6 +1590,9 @@ def main(): parser.add_argument("--early-stop", type=int, default=25) + parser.add_argument("--balanced_sampler", action="store_true") + parser.add_argument("--samples_per_epoch", type=int, default=0) + args = parser.parse_args() set_seed(args.seed) @@ -1579,10 +1679,32 @@ def main(): print(f"[DATA] train={len(ds_train)} | val={len(ds_val)}") + train_sampler = None + train_shuffle = True + + if args.balanced_sampler: + from torch.utils.data import WeightedRandomSampler + sample_weights = build_sample_weights(ds_train) + num_samples = int(args.samples_per_epoch) if args.samples_per_epoch > 0 else len(ds_train) + + train_sampler = WeightedRandomSampler( + weights=sample_weights, + num_samples=num_samples, + replacement=True, + ) + + train_shuffle = False + + print("[SAMPLER] WeightedRandomSampler ativado") + print(f"[SAMPLER] peso min={float(sample_weights.min()):.3f} " + f"max={float(sample_weights.max()):.3f} " + f"mean={float(sample_weights.mean()):.3f}") + dl_train = DataLoader( ds_train, batch_size=args.batch, - shuffle=True, + shuffle=train_shuffle, + sampler=train_sampler, num_workers=args.num_workers, pin_memory=True, collate_fn=collate_fn, diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/convert_dataset_to_depthai.py b/Python/OAK/datasets/oak-fcc-3/calibration/convert_dataset_to_depthai.py new file mode 100644 index 000000000..3835944c7 --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/calibration/convert_dataset_to_depthai.py @@ -0,0 +1,257 @@ +# export_depthai_stereo_dataset.py +import argparse +import re +from pathlib import Path + +import cv2 +import numpy as np + + +# ============================================================ +# RAW10 unpack / preview +# ============================================================ + +def unpack_raw10_packed(raw: bytes, width: int, height: int) -> np.ndarray: + """ + RAW10 packed: + 5 bytes = 4 pixels de 10 bits. + Retorna uint16 HxW em 0..1023. + """ + arr = np.frombuffer(raw, dtype=np.uint8) + + pixel_count = width * height + expected_bytes = (pixel_count // 4) * 5 + + if pixel_count % 4 != 0: + raise RuntimeError(f"width*height precisa ser múltiplo de 4. Recebido: {pixel_count}") + + if arr.size < expected_bytes: + raise RuntimeError( + f"RAW10 menor que esperado: bytes={arr.size}, esperado={expected_bytes}, " + f"width={width}, height={height}" + ) + + arr = arr[:expected_bytes] + groups = arr.reshape(-1, 5).astype(np.uint16) + + p0 = (groups[:, 0] << 2) | ((groups[:, 4] >> 0) & 0x03) + p1 = (groups[:, 1] << 2) | ((groups[:, 4] >> 2) & 0x03) + p2 = (groups[:, 2] << 2) | ((groups[:, 4] >> 4) & 0x03) + p3 = (groups[:, 3] << 2) | ((groups[:, 4] >> 6) & 0x03) + + out = np.empty(groups.shape[0] * 4, dtype=np.uint16) + out[0::4] = p0 + out[1::4] = p1 + out[2::4] = p2 + out[3::4] = p3 + + return out.reshape(height, width) + + +def normalize_to_u8(img: np.ndarray, p_low=1.0, p_high=99.0) -> np.ndarray: + arr = img.astype(np.float32) + valid = np.isfinite(arr) + + if np.count_nonzero(valid) < 20: + return np.zeros(arr.shape[:2], dtype=np.uint8) + + vals = arr[valid] + lo = np.percentile(vals, p_low) + hi = np.percentile(vals, p_high) + + out = (arr - lo) / (hi - lo + 1e-6) + out = np.clip(out, 0.0, 1.0) + + return (out * 255).astype(np.uint8) + + +def read_raw10_mono_png_ready(path: Path, width: int, height: int, use_clahe: bool) -> np.ndarray: + raw = path.read_bytes() + raw10 = unpack_raw10_packed(raw, width, height) + gray = normalize_to_u8(raw10) + + if use_clahe: + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + gray = clahe.apply(gray) + + return gray + + +# ============================================================ +# Pairing +# ============================================================ + +def clean_stem_for_pair(path: Path, cam_key: str) -> str: + s = path.stem + + variants = [ + cam_key, + cam_key.lower(), + cam_key.replace("_", ""), + cam_key.replace("_", "").lower(), + ] + + for v in variants: + s = s.replace(v, "") + + s = re.sub(r"[_\-\s]+", "_", s).strip("_").lower() + return s + + +def find_cam_bins(root_dir: Path, cam_key: str): + return sorted([p for p in root_dir.rglob("*.bin") if cam_key.lower() in p.name.lower()]) + + +def find_pairs(root_dir: Path, left_cam: str, right_cam: str): + left_paths = find_cam_bins(root_dir, left_cam) + right_paths = find_cam_bins(root_dir, right_cam) + + right_map = {} + + for p in right_paths: + key = clean_stem_for_pair(p, right_cam) + right_map[(p.parent, key)] = p + right_map.setdefault((None, key), p) + + pairs = [] + + for lp in left_paths: + key = clean_stem_for_pair(lp, left_cam) + folder = lp.parent + + rp = right_map.get((folder, key)) or right_map.get((None, key)) + + if rp is None: + same_folder = [x for x in right_paths if x.parent == folder] + if len(same_folder) == 1: + rp = same_folder[0] + + if rp is not None: + pairs.append((lp, rp)) + + return pairs, left_paths, right_paths + + +# ============================================================ +# Main +# ============================================================ + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument("--root_dir", required=True, help="Pasta onde estão os .bin CAM_A/CAM_B/CAM_C") + parser.add_argument("--out_dir", required=True, help="Pasta de saída. Ex: C:/dev/depthai/dataset") + + parser.add_argument("--width", type=int, default=1280) + parser.add_argument("--height", type=int, default=800) + + # Para DepthAI stereo: + # left = CAM_C / NIR + # right = CAM_B / RE + parser.add_argument("--left_cam", default="CAM_C") + parser.add_argument("--right_cam", default="CAM_B") + + parser.add_argument("--prefix", default="p", help="Prefixo dos arquivos. Default: p") + parser.add_argument("--suffix", default="_0", help="Sufixo depois do índice. Default: _0") + parser.add_argument("--start_index", type=int, default=0) + + parser.add_argument("--no_clahe", action="store_true") + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--save_preview", action="store_true") + + parser.add_argument( + "--images_per_pose", + type=int, + default=3, + help="Quantidade de imagens por pose para gerar nomes tipo p0_0, p0_1, p0_2, p1_3..." + ) + + args = parser.parse_args() + + root_dir = Path(args.root_dir) + out_dir = Path(args.out_dir) + + left_dir = out_dir / "left" + right_dir = out_dir / "right" + + left_dir.mkdir(parents=True, exist_ok=True) + right_dir.mkdir(parents=True, exist_ok=True) + + preview_dir = out_dir / "_preview_pairs" + if args.save_preview: + preview_dir.mkdir(parents=True, exist_ok=True) + + pairs, left_paths, right_paths = find_pairs(root_dir, args.left_cam, args.right_cam) + + print(f"[INFO] root_dir={root_dir}") + print(f"[INFO] out_dir={out_dir}") + print(f"[INFO] left_cam={args.left_cam} -> {left_dir}") + print(f"[INFO] right_cam={args.right_cam} -> {right_dir}") + print(f"[INFO] arquivos left encontrados: {len(left_paths)}") + print(f"[INFO] arquivos right encontrados: {len(right_paths)}") + print(f"[INFO] pares encontrados: {len(pairs)}") + print(f"[INFO] size={args.width}x{args.height}") + print(f"[INFO] clahe={not args.no_clahe}") + + if not pairs: + raise RuntimeError("Nenhum par encontrado. Verifique nomes dos arquivos e CAMs.") + + for i, (left_path, right_path) in enumerate(pairs): + idx = args.start_index + i + pose_idx = idx // args.images_per_pose + name = f"{args.prefix}{pose_idx}_{idx}.png" + + left_out = left_dir / name + right_out = right_dir / name + + if not args.overwrite and (left_out.exists() or right_out.exists()): + print(f"[SKIP] {name} já existe. Use --overwrite para sobrescrever.") + continue + + try: + left_img = read_raw10_mono_png_ready( + left_path, + width=args.width, + height=args.height, + use_clahe=not args.no_clahe, + ) + + right_img = read_raw10_mono_png_ready( + right_path, + width=args.width, + height=args.height, + use_clahe=not args.no_clahe, + ) + + cv2.imwrite(str(left_out), left_img) + cv2.imwrite(str(right_out), right_img) + + if args.save_preview: + left_bgr = cv2.cvtColor(left_img, cv2.COLOR_GRAY2BGR) + right_bgr = cv2.cvtColor(right_img, cv2.COLOR_GRAY2BGR) + + cv2.putText(left_bgr, f"left {args.left_cam}", (20, 35), + cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA) + cv2.putText(right_bgr, f"right {args.right_cam}", (20, 35), + cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA) + + preview = np.hstack([left_bgr, right_bgr]) + preview = cv2.resize(preview, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA) + cv2.imwrite(str(preview_dir / name), preview) + + print(f"[OK] {idx:04d}: {left_path.name} -> left/{name} | {right_path.name} -> right/{name}") + + except Exception as e: + print(f"[ERRO] par {i}:") + print(f" left ={left_path}") + print(f" right={right_path}") + print(f" erro ={e}") + + print("") + print("[DONE] Dataset exportado.") + print(f" left : {left_dir}") + print(f" right: {right_dir}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/filter_depthai_dataset.py b/Python/OAK/datasets/oak-fcc-3/calibration/filter_depthai_dataset.py new file mode 100644 index 000000000..9e46c0afe --- /dev/null +++ b/Python/OAK/datasets/oak-fcc-3/calibration/filter_depthai_dataset.py @@ -0,0 +1,307 @@ +import argparse +import shutil +from pathlib import Path + +import cv2 +import numpy as np + + +def get_aruco_dict(name: str): + aruco = cv2.aruco + name = name.upper() + + mapping = { + "4X4_50": aruco.DICT_4X4_50, + "4X4_100": aruco.DICT_4X4_100, + "4X4_250": aruco.DICT_4X4_250, + "4X4_1000": aruco.DICT_4X4_1000, + "5X5_50": aruco.DICT_5X5_50, + "5X5_100": aruco.DICT_5X5_100, + "5X5_250": aruco.DICT_5X5_250, + "5X5_1000": aruco.DICT_5X5_1000, + } + + if name not in mapping: + raise RuntimeError(f"Dicionário ArUco não suportado: {name}") + + if hasattr(aruco, "Dictionary_get"): + return aruco.Dictionary_get(mapping[name]) + + return aruco.getPredefinedDictionary(mapping[name]) + + +def count_markers(path: Path, aruco_dict, use_clahe: bool): + img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) + + if img is None: + return 0, None + + proc = img + + if use_clahe: + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + proc = clahe.apply(proc) + + corners, ids, rejected = cv2.aruco.detectMarkers(proc, aruco_dict) + + count = 0 if ids is None else len(ids) + return count, proc + + +def create_charuco_board(squares_x, squares_y, square_size_cm, marker_size_cm, aruco_dict): + return cv2.aruco.CharucoBoard_create( + int(squares_x), + int(squares_y), + float(square_size_cm), + float(marker_size_cm), + aruco_dict + ) + + +def count_charuco(path: Path, aruco_dict, board, use_clahe: bool): + img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) + + if img is None: + return 0, 0, None + + proc = img.copy() + + if use_clahe: + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + proc = clahe.apply(proc) + + marker_corners, marker_ids, rejected = cv2.aruco.detectMarkers(proc, aruco_dict) + + marker_count = 0 if marker_ids is None else len(marker_ids) + + if marker_ids is None or marker_count == 0: + return marker_count, 0, proc + + try: + cv2.aruco.refineDetectedMarkers( + proc, + board, + marker_corners, + marker_ids, + rejectedCorners=rejected + ) + except Exception: + pass + + ret, charuco_corners, charuco_ids = cv2.aruco.interpolateCornersCharuco( + marker_corners, + marker_ids, + proc, + board, + minMarkers=1 + ) + + charuco_count = 0 if charuco_ids is None else len(charuco_ids) + + return marker_count, charuco_count, proc + + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument("--dataset_dir", required=True, help="Pasta dataset com left/ e right/") + parser.add_argument("--out_dir", default=None, help="Se informado, cria dataset filtrado em outra pasta") + parser.add_argument("--min_markers", type=int, default=4) + parser.add_argument("--aruco_dict", default="4X4_1000") + parser.add_argument("--images_per_pose", type=int, default=3) + parser.add_argument("--squares_x", type=int, default=13) + parser.add_argument("--squares_y", type=int, default=7) + parser.add_argument("--square_size_cm", type=float, default=3.1) + parser.add_argument("--marker_size_cm", type=float, default=2.3) + parser.add_argument("--min_charuco", type=int, default=20) + parser.add_argument("--no_clahe", action="store_true") + parser.add_argument("--max_pairs", type=int, default=0, help="Limita a quantidade final de pares exportados. Use 39 para DepthAI padrão: 13 poses x 3 imagens.") + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--copy", action="store_true", help="Copia em vez de mover/reescrever") + parser.add_argument("--save_debug", action="store_true") + + args = parser.parse_args() + + dataset_dir = Path(args.dataset_dir) + left_dir = dataset_dir / "left" + right_dir = dataset_dir / "right" + + if not left_dir.exists() or not right_dir.exists(): + raise RuntimeError(f"Dataset precisa ter left/ e right/: {dataset_dir}") + + out_dir = Path(args.out_dir) if args.out_dir else dataset_dir + out_left = out_dir / "left" + out_right = out_dir / "right" + rejected_dir = out_dir / "_rejected" + debug_dir = out_dir / "_debug_marker_check" + + if args.out_dir: + if out_dir.exists() and args.overwrite: + shutil.rmtree(out_dir) + + out_left.mkdir(parents=True, exist_ok=True) + out_right.mkdir(parents=True, exist_ok=True) + else: + # Se for filtrar in-place, primeiro joga tudo para staging. + staging_dir = dataset_dir / "_staging_original" + if staging_dir.exists() and args.overwrite: + shutil.rmtree(staging_dir) + + if staging_dir.exists(): + raise RuntimeError( + f"Staging já existe: {staging_dir}. " + f"Apague manualmente ou use --overwrite." + ) + + staging_left = staging_dir / "left" + staging_right = staging_dir / "right" + staging_left.mkdir(parents=True, exist_ok=True) + staging_right.mkdir(parents=True, exist_ok=True) + + for p in left_dir.glob("*.png"): + shutil.move(str(p), str(staging_left / p.name)) + for p in right_dir.glob("*.png"): + shutil.move(str(p), str(staging_right / p.name)) + + left_dir = staging_left + right_dir = staging_right + + out_left.mkdir(parents=True, exist_ok=True) + out_right.mkdir(parents=True, exist_ok=True) + + rejected_dir.mkdir(parents=True, exist_ok=True) + if args.save_debug: + debug_dir.mkdir(parents=True, exist_ok=True) + + aruco_dict = get_aruco_dict(args.aruco_dict) + + board = create_charuco_board( + args.squares_x, + args.squares_y, + args.square_size_cm, + args.marker_size_cm, + aruco_dict + ) + + left_files = sorted(left_dir.glob("*.png")) + right_map = {p.name: p for p in right_dir.glob("*.png")} + + valid_pairs = [] + rejected = [] + + print(f"[INFO] dataset_dir={dataset_dir}") + print(f"[INFO] out_dir={out_dir}") + print(f"[INFO] left files={len(left_files)}") + print(f"[INFO] aruco_dict={args.aruco_dict}") + print(f"[INFO] min_markers={args.min_markers}") + print(f"[INFO] clahe={not args.no_clahe}") + + for left_path in left_files: + right_path = right_map.get(left_path.name) + + if right_path is None: + rejected.append((left_path, None, "missing_right", 0, 0)) + continue + + left_markers, left_charuco, left_img = count_charuco( + left_path, + aruco_dict, + board, + use_clahe=not args.no_clahe + ) + + right_markers, right_charuco, right_img = count_charuco( + right_path, + aruco_dict, + board, + use_clahe=not args.no_clahe + ) + + ok = ( + left_markers >= args.min_markers and + right_markers >= args.min_markers and + left_charuco >= args.min_charuco and + right_charuco >= args.min_charuco + ) + + if ok: + valid_pairs.append((left_path, right_path, left_charuco, right_charuco)) + print( + f"[OK] {left_path.name}: " + f"left markers={left_markers} charuco={left_charuco} | " + f"right markers={right_markers} charuco={right_charuco}" + ) + else: + rejected.append((left_path, right_path, "low_charuco", left_charuco, right_charuco)) + print( + f"[REJECT] {left_path.name}: " + f"left markers={left_markers} charuco={left_charuco} | " + f"right markers={right_markers} charuco={right_charuco}" + ) + + rej_left_dir = rejected_dir / "left" + rej_right_dir = rejected_dir / "right" + rej_left_dir.mkdir(parents=True, exist_ok=True) + rej_right_dir.mkdir(parents=True, exist_ok=True) + + shutil.copy2(left_path, rej_left_dir / left_path.name) + shutil.copy2(right_path, rej_right_dir / right_path.name) + + if args.save_debug and left_img is not None and right_img is not None: + left_bgr = cv2.cvtColor(left_img, cv2.COLOR_GRAY2BGR) + right_bgr = cv2.cvtColor(right_img, cv2.COLOR_GRAY2BGR) + + cv2.putText(left_bgr, f"left markers={left_markers}", (20, 35), + cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA) + cv2.putText(right_bgr, f"right markers={right_markers}", (20, 35), + cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA) + + dbg = np.hstack([left_bgr, right_bgr]) + cv2.imwrite(str(debug_dir / left_path.name), dbg) + + valid_pairs = sorted( + valid_pairs, + key=lambda x: min(x[2], x[3]), + reverse=True + ) + + if args.max_pairs > 0: + valid_pairs = valid_pairs[:args.max_pairs] + + print("") + print(f"[INFO] pares válidos: {len(valid_pairs)}") + print(f"[INFO] pares rejeitados: {len(rejected)}") + + if len(valid_pairs) < 10: + print("[WARN] Poucos pares válidos. Talvez o dicionário ArUco esteja errado ou as imagens estejam ruins.") + + # Reindexa os válidos no padrão DepthAI: p0_0, p0_1, p0_2, p1_3... + for new_idx, (left_path, right_path, left_markers, right_markers) in enumerate(valid_pairs): + pose_idx = new_idx // args.images_per_pose + new_name = f"p{pose_idx}_{new_idx}.png" + + dst_left = out_left / new_name + dst_right = out_right / new_name + + if dst_left.exists() or dst_right.exists(): + if not args.overwrite: + raise RuntimeError(f"Arquivo já existe: {new_name}. Use --overwrite.") + dst_left.unlink(missing_ok=True) + dst_right.unlink(missing_ok=True) + + if args.copy or args.out_dir: + shutil.copy2(left_path, dst_left) + shutil.copy2(right_path, dst_right) + else: + shutil.copy2(left_path, dst_left) + shutil.copy2(right_path, dst_right) + + print("") + print("[DONE] Dataset filtrado/reindexado.") + print(f" left : {out_left}") + print(f" right: {out_right}") + print(f" rejected: {rejected_dir}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json b/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json index 42a2f14dd..1024c8bfd 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json +++ b/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json @@ -58,41 +58,184 @@ } }, "homography_calibration_size": [1280, 800], - "homographies": { - "re_to_rgb": [ - [ - 1.0103099557765387, - 0.00879456142897448, - -9.666692994320178 - ], - [ - -0.00014434784827104316, - 1.0214419841673288, - 50.41247184630176 - ], - [ - 6.270900951674823e-06, - 1.788434277122896e-05, - 1.0 - ] - ], - "nir_to_rgb": [ - [ - 0.994769714806237, - 0.005296878287321148, - -6.433772056422987 - ], - [ - -0.00092900679814922, - 1.0017494877636166, - 29.883379280823718 - ], - [ - -5.35529902496836e-08, - 7.331793189295934e-06, - 1.0 - ] - ] + "homography_profile": "baixa", + "homography_profile_by_role": { + "re": "baixa", + "nir": "baixa" + }, + "homography_profiles": { + "baixa": { + "description": "Plano mais baixo/distante da câmera, normalmente mais próximo do chão.", + "depth": 120.0, + "homography_calibration_size": [1280, 800], + "homography_source": "all_valid_triplets", + "homography_stats": { + "re_total_points": 267, + "re_inliers": 267, + "re_inlier_pct": 100.0, + "nir_total_points": 282, + "nir_inliers": 276, + "nir_inlier_pct": 97.87234042553192, + "re_frames_used": 13, + "nir_frames_used": 13, + "common_frames_used": 13, + "overlap_common_pct": 92.09267578125 + }, + "homographies": { + "re_to_rgb": [ + [ + 1.014814963583485, + 0.015257560646391, + -27.5415127674596 + ], + [ + 0.000064618247568, + 1.020756196943496, + 51.80289590472656 + ], + [ + 0.000003411885963, + 0.0000210631067, + 1.0 + ] + ], + "nir_to_rgb": [ + [ + 0.993261705358079, + 0.008537634512529, + -19.84130896890106 + ], + [ + -0.004568701240477, + 1.002385627262319, + 37.83216994660247 + ], + [ + -0.00000725852956, + 0.000011588890195, + 1.0 + ] + ] + } + }, + + "media": { + "description": "Plano médio, calibrado com ChArUco a aproximadamente 66 cm da lente.", + "depth": 66.0, + "homography_calibration_size": [1280, 800], + "homography_source": "all_valid_triplets", + "homography_stats": { + "min_common_frame": 4, + "min_total_points": 30, + "re_total_points": 681, + "re_inliers": 666, + "re_inlier_pct": 97.79735682819384, + "nir_total_points": 682, + "nir_inliers": 662, + "nir_inlier_pct": 97.0674486803519, + "re_frames_used": 10, + "nir_frames_used": 10, + "common_frames_used": 10, + "overlap_re_pct": 92.06279296874999, + "overlap_nir_pct": 92.97744140625001, + "overlap_common_pct": 90.9361328125 + }, + "homographies": { + "re_to_rgb": [ + [ + 1.0178493693104127, + 0.01417807024780309, + -21.68597109171115 + ], + [ + 0.0010063750504059967, + 1.0231248578982763, + 57.68510361307461 + ], + [ + 0.000003886299643192958, + 0.0000197941570901694, + 1.0 + ] + ], + "nir_to_rgb": [ + [ + 0.9967828768745967, + 0.007204312783804796, + -27.956111536702632 + ], + [ + -0.0033537915240536544, + 1.005150033976991, + 43.58722588915352 + ], + [ + -0.000005956433574512551, + 0.000010346309041666395, + 1.0 + ] + ] + } + }, + + "alta": { + "description": "Plano mais alto/próximo da câmera, calibrado com ChArUco acima do plano médio.", + "depth": 36.0, + "homography_calibration_size": [1280, 800], + "homography_source": "all_valid_triplets", + "homography_stats": { + "min_common_frame": 4, + "min_total_points": 30, + "re_total_points": 336, + "re_inliers": 305, + "re_inlier_pct": 90.77380952380952, + "nir_total_points": 333, + "nir_inliers": 330, + "nir_inlier_pct": 99.09909909909909, + "re_frames_used": 5, + "nir_frames_used": 5, + "common_frames_used": 5, + "overlap_re_pct": 91.26904296875, + "overlap_nir_pct": 90.83525390625, + "overlap_common_pct": 88.8357421875 + }, + "homographies": { + "re_to_rgb": [ + [ + 1.022058360214937, + 0.013411355134898, + -11.893143823943664 + ], + [ + 0.001034227529954, + 1.026408605195186, + 69.3538160647828 + ], + [ + 0.000003600847244, + 0.000019493014216, + 1.0 + ] + ], + "nir_to_rgb": [ + [ + 1.001478266241777, + 0.007623070623269, + -43.01507447243508 + ], + [ + -0.002877662918552, + 1.008411842924782, + 55.15260407195015 + ], + [ + -0.000005444722264, + 0.000009647719914, + 1.0 + ] + ] + } + } }, "crop_valid_common": true, "resize_after_crop": true, diff --git a/Python/OAK/datasets/oak-fcc-3/config.json b/Python/OAK/datasets/oak-fcc-3/config.json index d08fd5444..7e8b471c9 100644 --- a/Python/OAK/datasets/oak-fcc-3/config.json +++ b/Python/OAK/datasets/oak-fcc-3/config.json @@ -1,7 +1,7 @@ { "camera": "oak-fcc-3", "modelo": "segformer_b1", - "model_name": "target_fixed", + "model_name": "copypaste", "main_class_name": "cana", "es_classes": "", "model_to_use": "geral", @@ -27,7 +27,7 @@ "mask_dir": "masks", "classes": {"chao": 0, "cana": 1, "erva": 2}, "ignore_index": 255, - "loss_weight": 0.10 + "loss_weight": 0.20 }, "vegetation": { "enabled": true, @@ -54,7 +54,7 @@ "mask_dir": "__derived_target__", "classes": {"background": 0, "target": 1}, "ignore_index": 255, - "loss_weight": 0.45, + "loss_weight": 0.35, "derived": true } }, diff --git a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py index 439adfca7..24cceafb5 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py +++ b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py @@ -1090,17 +1090,7 @@ class RawProcessorCore: warped_mask = self._affine_image(mask, dx, dy, theta_deg) elif mode == "homography": - H = cfg.get("homographies", {}).get(f"{role}_to_rgb") - - if H is None: - raise RuntimeError( - f"fusion_config.alignment_mode='homography', " - f"mas homografia '{role}_to_rgb' está ausente. " - f"Isso deixaria o canal {role.upper()} sem alinhamento." - ) - - calib_size = cfg.get("homography_calibration_size", None) - + H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) H = self._scale_homography_to_runtime( H, calib_size=calib_size, @@ -3611,22 +3601,30 @@ class RawProcessorCore: def _direct_fusion_get_role_homography_fast(self, role, meta, ref_size): """ Retorna H_role_to_rgb escalada para o espaço da referência RGB. + + Suporta: + - contrato antigo: fusion_config.homographies.re_to_rgb/nir_to_rgb + - contrato novo: fusion_config.homography_profiles..homographies.* """ role = str(role).lower() - fusion = getattr(self, "fusion_config", {}) or {} - homographies = fusion.get("homographies", {}) or {} - key = f"{role}_to_rgb" - H = homographies.get(key) + H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) - if H is None: - # Fallbacks para contratos diferentes. - H = homographies.get(role) + ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) - if H is None: - raise RuntimeError(f"Homografia ausente para role={role}. Esperado fusion_config.homographies.{key}") + H_scaled = self._scale_homography_to_runtime( + H, + calib_size=calib_size, + runtime_size=(ref_w, ref_h), + ) - return self._direct_fusion_scale_homography_for_ref_fast(H, meta, ref_size) + if H_scaled is None or H_scaled.shape != (3, 3): + raise RuntimeError( + f"Homografia inválida para role={role}, profile={profile_name}: " + f"shape={None if H_scaled is None else H_scaled.shape}" + ) + + return H_scaled.astype(np.float32) def _direct_fusion_resize_spec_to_ref_if_needed_fast(self, img, ref_size): """ @@ -3770,6 +3768,7 @@ class RawProcessorCore: "geometry_cache_hit": bool(geom.get("prepare_cache_hit", False)), "geometry_cache_hits": int(geom.get("cache_hits", 0)), "geometry_cache_misses": int(geom.get("cache_misses", 0)), + "homography_profiles_used": geom.get("homography_profiles_used", {}), } tensor = np.empty((int(channels_expected), target_h, target_w), dtype=np.float32) @@ -3962,34 +3961,38 @@ class RawProcessorCore: """ Chave simples e estável para cache da geometria. - A geometria depende de: - - tamanho do RGB de referência + Considera: + - tamanho do RGB/ref - target final - roles presentes - - crop_valid_common / resize_after_crop - - homografias e calibration_size - - Para evitar custo de serializar o JSON todo por frame, usamos uma versão - simples. Se você editar module_params em runtime, chame - clear_direct_fusion_geometry_cache(). + - crop/resize + - homografia efetivamente selecionada por perfil + - calibration_size efetivo por role """ ref_h, ref_w = int(ref_size[0]), int(ref_size[1]) target_w, target_h = int(target_size[0]), int(target_size[1]) fusion = getattr(self, "fusion_config", {}) or {} - homographies = fusion.get("homographies", {}) or {} - - # Pequena assinatura numérica das homografias. - def h_sig(key): - H = homographies.get(key) - if H is None: - return None - arr = np.asarray(H, dtype=np.float32).reshape(-1) - # arredonda para evitar ruído float/json, mas detecta mudança real. - return tuple(np.round(arr, 8).tolist()) - roles = tuple(sorted([str(r).lower() for r in role_to_cam.keys()])) + def h_sig_for_role(role): + role = str(role).lower() + + if role not in role_to_cam: + return None + + try: + H, calib_size, profile_name = self._resolve_homography_entry_for_role(role) + except Exception: + return None + + arr = np.asarray(H, dtype=np.float32).reshape(-1) + return ( + str(profile_name), + tuple(calib_size or []), + tuple(np.round(arr, 8).tolist()), + ) + return ( ref_w, ref_h, @@ -3998,9 +4001,8 @@ class RawProcessorCore: roles, bool(fusion.get("crop_valid_common", False)), bool(fusion.get("resize_after_crop", False)), - tuple(fusion.get("homography_calibration_size") or fusion.get("calibration_size") or []), - h_sig("re_to_rgb"), - h_sig("nir_to_rgb"), + h_sig_for_role("re"), + h_sig_for_role("nir"), ) def clear_direct_fusion_geometry_cache(self): @@ -4053,8 +4055,14 @@ class RawProcessorCore: # Homografias escaladas para runtime. # ------------------------------------------------------------ H_role_to_rgb = {} + homography_profiles_used = {} for role in ("re", "nir"): if role in role_to_cam: + H_raw, calib_size, profile_name = self._resolve_homography_entry_for_role(role) + homography_profiles_used[role] = { + "profile": profile_name, + "calib_size": list(calib_size) if calib_size is not None else None, + } H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast(role, meta, ref_size) # ------------------------------------------------------------ @@ -4103,6 +4111,7 @@ class RawProcessorCore: "prepare_cache_hit": False, "cache_hits": int(self._direct_fusion_geometry_cache_hits), "cache_misses": int(self._direct_fusion_geometry_cache_misses), + "homography_profiles_used": homography_profiles_used, } # Cache pequeno: normalmente só uma geometria. Se mudar resolução/config, @@ -4376,6 +4385,120 @@ class RawProcessorCore: tensor[int(channel_index)] = out + def _resolve_homography_profile_name_for_role(self, role: str) -> str: + """ + Resolve qual perfil de homografia usar para uma role. + + Prioridade: + 1) fusion_config.homography_profile_by_role[role] + 2) fusion_config.homography_profile + 3) "default" + """ + role = str(role).lower() + fusion = getattr(self, "fusion_config", {}) or {} + + by_role = fusion.get("homography_profile_by_role", {}) or {} + if isinstance(by_role, dict): + selected = by_role.get(role) + if selected: + return str(selected).lower() + + selected = fusion.get("homography_profile", None) + if selected: + return str(selected).lower() + + return "default" + + def _resolve_homography_entry_for_role(self, role: str): + """ + Resolve a homografia no contrato novo ou antigo. + + Contrato novo: + fusion_config.homography_profiles..homographies._to_rgb + + Contrato antigo: + fusion_config.homographies._to_rgb + + Retorna: + H, calib_size, profile_name + """ + role = str(role).lower() + fusion = getattr(self, "fusion_config", {}) or {} + + key = f"{role}_to_rgb" + + selected_profile = self._resolve_homography_profile_name_for_role(role) + + # ------------------------------------------------------------ + # Futuro: auto por profundidade. + # Por enquanto, cai em media/default de forma explícita. + # ------------------------------------------------------------ + if selected_profile == "auto": + profiles = fusion.get("homography_profiles", {}) or {} + if "media" in profiles: + selected_profile = "media" + elif "default" in profiles: + selected_profile = "default" + else: + selected_profile = "" + + # ------------------------------------------------------------ + # Contrato novo: homography_profiles + # ------------------------------------------------------------ + profiles = fusion.get("homography_profiles", {}) or {} + if isinstance(profiles, dict) and selected_profile: + profile = profiles.get(selected_profile) + + if profile is None: + # tolera nomes com caixa diferente + for name, item in profiles.items(): + if str(name).lower() == selected_profile: + profile = item + selected_profile = str(name) + break + + if isinstance(profile, dict): + profile_homographies = profile.get("homographies", {}) or {} + H = profile_homographies.get(key) + + if H is None: + # fallback curto: "re" ou "nir" + H = profile_homographies.get(role) + + if H is not None: + calib_size = ( + profile.get("homography_calibration_size") + or profile.get("calibration_size") + or fusion.get("homography_calibration_size") + or fusion.get("calibration_size") + or None + ) + return H, calib_size, selected_profile + + # ------------------------------------------------------------ + # Contrato antigo: homographies direto + # ------------------------------------------------------------ + homographies = fusion.get("homographies", {}) or {} + H = homographies.get(key) + + if H is None: + H = homographies.get(role) + + if H is not None: + calib_size = ( + fusion.get("homography_calibration_size") + or fusion.get("calibration_size") + or None + ) + return H, calib_size, "legacy" + + raise RuntimeError( + f"Homografia ausente para role={role}. " + f"Procurei profile='{selected_profile}' em " + f"fusion_config.homography_profiles.*.homographies.{key} " + f"e fallback fusion_config.homographies.{key}" + ) + def _raw10_rgb_linear_demosaic_to_rgb_float01_fast( @@ -4806,3 +4929,6 @@ class RawProcessorCore: self._flatfield_runtime_cache[key] = gain_tensor return gain_tensor + + + \ No newline at end of file diff --git a/Python/OAK/datasets/oak-fcc-3/depth_calibration_multi.py b/Python/OAK/datasets/oak-fcc-3/depth_calibration_multi.py index 17d0b07e5..c1ae1ea7c 100644 --- a/Python/OAK/datasets/oak-fcc-3/depth_calibration_multi.py +++ b/Python/OAK/datasets/oak-fcc-3/depth_calibration_multi.py @@ -85,11 +85,10 @@ def raw10_bin_to_gray(path: Path, width: int, height: int, *, is_rgb: bool, baye raw = path.read_bytes() raw10 = unpack_raw10_packed(raw, width, height) - if is_rgb: - bgr = debayer_raw10_to_bgr_u8(raw10, bayer=bayer) - gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) - else: - gray = normalize_to_u8(raw10) + # Para detecção ChArUco, usar o RAW Bayer como intensidade costuma ser mais fiel + # que debayerizar, porque o debayer pode suavizar os IDs ArUco. + # O parâmetro is_rgb fica mantido por compatibilidade com chamadas antigas. + gray = normalize_to_u8(raw10) if use_clahe: clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) @@ -97,33 +96,52 @@ def raw10_bin_to_gray(path: Path, width: int, height: int, *, is_rgb: bool, baye return gray - # ============================================================ # Metadata # ============================================================ def find_meta(root_dir: Path) -> Path | None: - candidates = list(root_dir.rglob("meta.json")) + list(root_dir.rglob("metadata.json")) + candidates = ( + list(root_dir.rglob("meta.json")) + + list(root_dir.rglob("metadata.json")) + + sorted(root_dir.rglob("*.json")) + ) return candidates[0] if candidates else None def extract_camera_info_from_meta(meta: dict, cam_key: str): + # Caminho usado pelo meta atual do oak_fcc3. + stream_meta = meta.get("stream_meta") + if isinstance(stream_meta, dict): + camera_info = stream_meta.get("camera_info") + if isinstance(camera_info, dict): + info = camera_info.get(cam_key) + if isinstance(info, dict) and "width" in info and "height" in info: + return info + + # Formatos alternativos. for root_key in ["camera_info", "cameras", "camera_meta", "payload_sources_info"]: root = meta.get(root_key) if isinstance(root, dict): info = root.get(cam_key) - if isinstance(info, dict): + if isinstance(info, dict) and "width" in info and "height" in info: return info + # Busca recursiva, mas só aceita se parecer info geométrica da câmera. stack = [meta] while stack: obj = stack.pop() + if isinstance(obj, dict): if cam_key in obj and isinstance(obj[cam_key], dict): - return obj[cam_key] + info = obj[cam_key] + if "width" in info and "height" in info: + return info + for v in obj.values(): if isinstance(v, (dict, list)): stack.append(v) + elif isinstance(obj, list): for v in obj: if isinstance(v, (dict, list)): @@ -155,11 +173,11 @@ def try_get_width_height_from_meta(root_dir: Path, cam_key: str): return int(width), int(height) -def resolve_width_height(args, cam_key: str): +def resolve_width_height(args, cam_key: str, search_root: Path): if args.width > 0 and args.height > 0: return args.width, args.height - w, h = try_get_width_height_from_meta(Path(args.root_dir), cam_key) + w, h = try_get_width_height_from_meta(search_root, cam_key) if w and h: return w, h @@ -268,6 +286,26 @@ def resolve_homography_triplet(triplets: list[dict], homo_ref_frame: str | None, raise RuntimeError(f"Não encontrei triplet correspondente a --homo_ref_frame={homo_ref_frame}") +def find_triplets_multi(root_dirs: list[Path], cams: list[str]): + all_triplets = [] + all_by_cam = {cam: [] for cam in cams} + + for root in root_dirs: + triplets, by_cam = find_triplets(root, cams) + + for cam in cams: + all_by_cam[cam].extend(by_cam[cam]) + + for item in triplets: + item = dict(item) + item["__root_dir"] = root + all_triplets.append(item) + + print(f"[INFO] root_dir={root} triplets={len(triplets)}") + + return all_triplets, all_by_cam + + # ============================================================ # ChArUco helpers # ============================================================ @@ -319,11 +357,34 @@ def get_board_corners(board): def create_detector_params(): aruco = cv2.aruco + if hasattr(aruco, "DetectorParameters"): - return aruco.DetectorParameters() - if hasattr(aruco, "DetectorParameters_create"): - return aruco.DetectorParameters_create() - return None + params = aruco.DetectorParameters() + elif hasattr(aruco, "DetectorParameters_create"): + params = aruco.DetectorParameters_create() + else: + return None + + params.adaptiveThreshWinSizeMin = 3 + params.adaptiveThreshWinSizeMax = 53 + params.adaptiveThreshWinSizeStep = 4 + + params.minMarkerPerimeterRate = 0.01 + params.maxMarkerPerimeterRate = 4.0 + + params.polygonalApproxAccuracyRate = 0.05 + params.minCornerDistanceRate = 0.02 + params.minDistanceToBorder = 1 + + try: + params.cornerRefinementMethod = aruco.CORNER_REFINE_SUBPIX + params.cornerRefinementWinSize = 5 + params.cornerRefinementMaxIterations = 50 + params.cornerRefinementMinAccuracy = 0.01 + except Exception: + pass + + return params def detect_charuco(gray: np.ndarray, board, aruco_dict, min_corners: int): @@ -636,13 +697,261 @@ def compute_planar_homography_from_triplet( } + +def detect_charuco_best(gray: np.ndarray, board, aruco_dict, min_corners: int, cam: str = ""): + """ + Tenta múltiplos pré-processamentos e escalas. + Retorna a melhor detecção mesmo quando ela fica abaixo de min_corners. + """ + variants = [] + + base = gray.copy() + variants.append(("raw", base)) + + clahe2 = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + clahe4 = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8, 8)) + + variants.append(("clahe_2", clahe2.apply(base))) + variants.append(("clahe_4", clahe4.apply(base))) + + blur = cv2.GaussianBlur(base, (3, 3), 0) + variants.append(("blur_clahe_2", clahe2.apply(blur))) + + th = cv2.adaptiveThreshold( + base, + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + 31, + 5, + ) + variants.append(("adaptive", th)) + + variants.append(("invert_raw", 255 - base)) + variants.append(("invert_clahe_2", 255 - clahe2.apply(base))) + + best = { + "name": None, + "corners": None, + "ids": None, + "count": 0, + "accepted": False, + } + + for name, img in variants: + for scale in [1.0, 2.0, 3.0]: + if scale == 1.0: + test_img = img + else: + test_img = cv2.resize( + img, + None, + fx=scale, + fy=scale, + interpolation=cv2.INTER_CUBIC, + ) + + corners, ids = detect_charuco(test_img, board, aruco_dict, min_corners=1) + count = 0 if ids is None else len(ids) + + if count > best["count"]: + if corners is not None and scale != 1.0: + corners = corners / scale + + best.update({ + "name": f"{name}_x{scale:g}" if scale != 1.0 else name, + "corners": corners, + "ids": ids, + "count": count, + "accepted": count >= min_corners, + }) + + return best["corners"], best["ids"], best["name"], best["count"], best["accepted"] + + +def _make_overlap_masks_from_homographies(H_re, H_nir, image_size: tuple[int, int]): + image_w, image_h = image_size + ones = np.ones((image_h, image_w), dtype=np.uint8) * 255 + + overlap_re_to_rgb = cv2.warpPerspective( + ones, + H_re, + (image_w, image_h), + flags=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + overlap_nir_to_rgb = cv2.warpPerspective( + ones, + H_nir, + (image_w, image_h), + flags=cv2.INTER_NEAREST, + borderMode=cv2.BORDER_CONSTANT, + borderValue=0, + ) + + overlap_common_rgb = cv2.bitwise_and(overlap_re_to_rgb, overlap_nir_to_rgb) + return overlap_re_to_rgb, overlap_nir_to_rgb, overlap_common_rgb + + +def compute_planar_homography_from_collected_pairs( + homography_pairs: dict, + image_size: tuple[int, int], + args, +): + """ + Calcula H_RE_to_RGB e H_NIR_to_RGB usando TODOS os pares válidos acumulados + durante a varredura do dataset. + + Premissa: todos os frames usados representam o mesmo plano físico. + """ + re_items = homography_pairs.get("RE_to_RGB", []) + nir_items = homography_pairs.get("NIR_to_RGB", []) + + if len(re_items) == 0: + raise RuntimeError("Nenhum par válido acumulado para homografia RE -> RGB.") + + if len(nir_items) == 0: + raise RuntimeError("Nenhum par válido acumulado para homografia NIR -> RGB.") + + def stack_points(items, label): + src = np.vstack([x["pts_src"] for x in items]).astype(np.float32) + dst = np.vstack([x["pts_dst"] for x in items]).astype(np.float32) + + min_total = max(4, int(args.homo_min_total_points)) + if len(src) < min_total: + raise RuntimeError( + f"Poucos pontos totais para homografia {label}: {len(src)}. " + f"Mínimo configurado={min_total}." + ) + + return src, dst + + pts_re_src, pts_re_dst = stack_points(re_items, "RE_to_RGB") + pts_nir_src, pts_nir_dst = stack_points(nir_items, "NIR_to_RGB") + + H_re, mask_re = cv2.findHomography( + pts_re_src, + pts_re_dst, + cv2.RANSAC, + args.homo_ransac_thresh, + ) + + H_nir, mask_nir = cv2.findHomography( + pts_nir_src, + pts_nir_dst, + cv2.RANSAC, + args.homo_ransac_thresh, + ) + + if H_re is None: + raise RuntimeError("cv2.findHomography falhou para RE -> RGB usando todos os frames válidos.") + + if H_nir is None: + raise RuntimeError("cv2.findHomography falhou para NIR -> RGB usando todos os frames válidos.") + + re_inliers = int(np.count_nonzero(mask_re)) if mask_re is not None else 0 + nir_inliers = int(np.count_nonzero(mask_nir)) if mask_nir is not None else 0 + + overlap_re_to_rgb, overlap_nir_to_rgb, overlap_common_rgb = _make_overlap_masks_from_homographies( + H_re, + H_nir, + image_size, + ) + + re_frame_indices = sorted({int(x["triplet_idx"]) for x in re_items}) + nir_frame_indices = sorted({int(x["triplet_idx"]) for x in nir_items}) + common_frame_indices = sorted(set(re_frame_indices) & set(nir_frame_indices)) + + stats = { + "mode": "all_valid_triplets", + "min_common_frame": int(args.homo_min_common_frame), + "min_total_points": int(args.homo_min_total_points), + "re_total_points": int(len(pts_re_src)), + "nir_total_points": int(len(pts_nir_src)), + "re_inliers": re_inliers, + "nir_inliers": nir_inliers, + "re_inlier_pct": float(re_inliers / max(1, len(pts_re_src)) * 100.0), + "nir_inlier_pct": float(nir_inliers / max(1, len(pts_nir_src)) * 100.0), + "re_frames_used": int(len(re_frame_indices)), + "nir_frames_used": int(len(nir_frame_indices)), + "common_frames_used": int(len(common_frame_indices)), + "re_pair_records": int(len(re_items)), + "nir_pair_records": int(len(nir_items)), + "overlap_re_pct": float(np.mean(overlap_re_to_rgb > 0) * 100.0), + "overlap_nir_pct": float(np.mean(overlap_nir_to_rgb > 0) * 100.0), + "overlap_common_pct": float(np.mean(overlap_common_rgb > 0) * 100.0), + } + + common_ids_re = np.concatenate([x["common_ids"] for x in re_items]).astype(np.int32) + common_ids_nir = np.concatenate([x["common_ids"] for x in nir_items]).astype(np.int32) + + return { + "H_RE_to_RGB": H_re, + "H_NIR_to_RGB": H_nir, + "mask_RE_to_RGB": mask_re, + "mask_NIR_to_RGB": mask_nir, + "common_ids_RE_to_RGB": common_ids_re, + "common_ids_NIR_to_RGB": common_ids_nir, + "overlap_RE_to_RGB": overlap_re_to_rgb, + "overlap_NIR_to_RGB": overlap_nir_to_rgb, + "overlap_common_RGB": overlap_common_rgb, + "stats": stats, + "frame_indices_RE_to_RGB": np.array(re_frame_indices, dtype=np.int32), + "frame_indices_NIR_to_RGB": np.array(nir_frame_indices, dtype=np.int32), + "frame_indices_common": np.array(common_frame_indices, dtype=np.int32), + } + + +def add_homography_to_save_dict(save_dict: dict, homo_result: dict, args): + hs = homo_result["stats"] + + save_dict["has_planar_homography"] = True + save_dict["planar_homography_source"] = "all_valid_triplets" + save_dict["planar_homography_resolved"] = "all_valid_triplets" + save_dict["planar_homography_note"] = ( + "Homography maps RE/NIR to RGB using all valid ChArUco detections " + "from the same physical plane." + ) + + save_dict["H_RE_to_RGB"] = homo_result["H_RE_to_RGB"] + save_dict["H_NIR_to_RGB"] = homo_result["H_NIR_to_RGB"] + save_dict[f"H_{args.re_cam}_to_{args.rgb_cam}"] = homo_result["H_RE_to_RGB"] + save_dict[f"H_{args.nir_cam}_to_{args.rgb_cam}"] = homo_result["H_NIR_to_RGB"] + + save_dict["homography_mask_RE_to_RGB"] = homo_result["mask_RE_to_RGB"] + save_dict["homography_mask_NIR_to_RGB"] = homo_result["mask_NIR_to_RGB"] + save_dict["homography_common_ids_RE_to_RGB"] = homo_result["common_ids_RE_to_RGB"] + save_dict["homography_common_ids_NIR_to_RGB"] = homo_result["common_ids_NIR_to_RGB"] + + save_dict["homography_frame_indices_RE_to_RGB"] = homo_result["frame_indices_RE_to_RGB"] + save_dict["homography_frame_indices_NIR_to_RGB"] = homo_result["frame_indices_NIR_to_RGB"] + save_dict["homography_frame_indices_common"] = homo_result["frame_indices_common"] + + save_dict["overlap_RE_to_RGB"] = homo_result["overlap_RE_to_RGB"] + save_dict["overlap_NIR_to_RGB"] = homo_result["overlap_NIR_to_RGB"] + save_dict["overlap_common_RGB"] = homo_result["overlap_common_RGB"] + + for k, v in hs.items(): + save_dict[f"planar_homography_{k}"] = v + + return save_dict + + # ============================================================ # Main # ============================================================ def main(): parser = argparse.ArgumentParser() - parser.add_argument("--root_dir", default="calibration/stereo_dataset", required=True) + parser.add_argument("--root_dir", default=None) + parser.add_argument( + "--root_dirs", + nargs="+", + default=None, + help="Lista de diretórios de calibração para juntar no mesmo cálculo stereo. Ex: baixa media alta", + ) parser.add_argument("--out_dir", default="calibration/multicam_charuco_calib_out") parser.add_argument("--rgb_cam", default="CAM_A") @@ -650,8 +959,8 @@ def main(): parser.add_argument("--nir_cam", default="CAM_C") parser.add_argument("--ref_cam", default="CAM_A", help="Referência global. Recomendo CAM_A/RGB.") - parser.add_argument("--width", type=int, default=1280) - parser.add_argument("--height", type=int, default=800) + parser.add_argument("--width", type=int, default=0) + parser.add_argument("--height", type=int, default=0) parser.add_argument("--rgb_bayer", default="BGGR") parser.add_argument("--squares_x", type=int, default=13) @@ -664,26 +973,76 @@ def main(): parser.add_argument("--min_corners", type=int, default=40) parser.add_argument("--min_common", type=int, default=40) - # Homografia planar de referência. + # Homografia planar usando todos os frames válidos do mesmo plano físico. parser.add_argument( - "--homo_ref_frame", - default=None, + "--homography_mode", + default="all_valid", + choices=["all_valid", "off"], help=( - "Triplet usado como plano de referência para H_RE_to_RGB e H_NIR_to_RGB. " - "Aceita índice, nome parcial/stem ou caminho de um .bin do triplet. " - "Se omitido, não salva homografias planares." + "Modo de homografia planar. 'all_valid' acumula todos os pares válidos " + "RE->RGB e NIR->RGB encontrados no dataset. 'off' desativa." + ), + ) + # Homografia all_valid: + # - Não corta detecções fracas por câmera antes de acumular pontos. + # - Cada frame/par contribui se tiver pelo menos homo_min_common_frame IDs comuns. + # - O corte forte acontece no acumulado total, em homo_min_total_points. + parser.add_argument( + "--homo_min_common_frame", + type=int, + default=4, + help=( + "Mínimo de IDs comuns por frame/par para adicionar pontos à homografia. " + "Use 4 como mínimo matemático; 5-8 para ficar menos permissivo." + ), + ) + parser.add_argument( + "--homo_min_total_points", + type=int, + default=30, + help=( + "Mínimo de pontos acumulados no dataset inteiro para calcular cada homografia. " + "Ex: 30 para teste, 50-100 para calibração mais robusta." ), ) - parser.add_argument("--homo_min_corners", type=int, default=30) - parser.add_argument("--homo_min_common", type=int, default=20) parser.add_argument("--homo_ransac_thresh", type=float, default=3.0) + # Compatibilidade com comandos antigos. Não são mais usados como corte da homografia all_valid. + parser.add_argument("--homo_min_corners", type=int, default=None, help=argparse.SUPPRESS) + parser.add_argument("--homo_min_common", type=int, default=None, help=argparse.SUPPRESS) + parser.add_argument( + "--min_calib_triplets", + type=int, + default=5, + help="Mínimo de triplets aceitos para executar calibração intrínseca/stereo.", + ) + parser.add_argument("--no_clahe", action="store_true") parser.add_argument("--show", action="store_true") args = parser.parse_args() - root_dir = Path(args.root_dir) + if args.root_dirs: + root_dirs = [Path(p) for p in args.root_dirs] + elif args.root_dir: + root_dirs = [Path(args.root_dir)] + else: + raise RuntimeError("Informe --root_dir ou --root_dirs.") + + # Compatibilidade com comandos antigos: + # --homo_min_common antigo vira o novo corte mínimo por frame/par. + # --homo_min_corners antigo não é mais usado como corte para homografia all_valid, + # porque agora aceitamos detecções pequenas e filtramos pelo total acumulado. + if args.homo_min_common is not None: + args.homo_min_common_frame = int(args.homo_min_common) + + if getattr(args, "root_dirs", None): + root_dirs = [Path(p) for p in args.root_dirs] + elif getattr(args, "root_dir", None): + root_dirs = [Path(args.root_dir)] + else: + raise RuntimeError("Informe --root_dir ou --root_dirs.") + out_dir = Path(args.out_dir) debug_dir = out_dir / "debug" out_dir.mkdir(parents=True, exist_ok=True) @@ -701,14 +1060,14 @@ def main(): sizes = {} for cam in cams: - w, h = resolve_width_height(args, cam) + w, h = resolve_width_height(args, cam, root_dirs[0]) sizes[cam] = (w, h) image_w = min(w for w, h in sizes.values()) image_h = min(h for w, h in sizes.values()) image_size = (image_w, image_h) - print(f"[INFO] root_dir={root_dir}") + print(f"[INFO] root_dir={root_dirs}") print(f"[INFO] cams={cams} ref_cam={args.ref_cam}") for cam in cams: print(f"[INFO] {cam} role={cam_roles[cam]} size={sizes[cam]}") @@ -717,13 +1076,18 @@ def main(): print(f"[INFO] square_length={args.square_length}") print(f"[INFO] marker_length={args.marker_length}") print(f"[INFO] rectify_alpha={args.rectify_alpha}") + print(f"[INFO] homography_mode={args.homography_mode}") + if args.homography_mode == "all_valid": + print(f"[INFO] homo_min_common_frame={args.homo_min_common_frame}") + print(f"[INFO] homo_min_total_points={args.homo_min_total_points}") + print(f"[INFO] homo_ransac_thresh={args.homo_ransac_thresh}") - triplets, by_cam = find_triplets(root_dir, cams) + triplets, by_cam = find_triplets_multi(root_dirs, cams) for cam in cams: print(f"[INFO] arquivos {cam}: {len(by_cam[cam])}") print(f"[INFO] triplets encontrados: {len(triplets)}") - if len(triplets) < 8: + if len(triplets) < 5: raise RuntimeError("Poucos triplets encontrados. Verifique nomes dos arquivos CAM_A/B/C.") aruco_dict = get_aruco_dict(args.aruco_dict) @@ -741,6 +1105,11 @@ def main(): accepted = 0 rejected = 0 + homography_pairs = { + "RE_to_RGB": [], + "NIR_to_RGB": [], + } + for idx, item in enumerate(triplets): print(f"[{idx + 1}/{len(triplets)}] " + " | ".join([f"{cam}={item[cam].name}" for cam in cams])) @@ -764,22 +1133,101 @@ def main(): gray = cv2.resize(gray, image_size, interpolation=cv2.INTER_AREA) gray_by_cam[cam] = gray - corners, ids = detect_charuco(gray, board, aruco_dict, min_corners=args.min_corners) + corners, ids, det_mode, det_count, det_ok = detect_charuco_best( + gray, + board, + aruco_dict, + min_corners=args.min_corners, + cam=cam, + ) + + # Mantém a melhor detecção bruta para homografia, mesmo quando + # ela fica abaixo do mínimo mais rígido da calibração stereo. detections[cam] = (corners, ids) + detections[f"{cam}__count"] = det_count + detections[f"{cam}__mode"] = det_mode - counts = {cam: (0 if detections[cam][1] is None else len(detections[cam][1])) for cam in cams} + print( + f" [DETECT] {cam}: best={det_mode} corners={det_count} " + f"{'OK' if det_ok else f'LOW<{args.min_corners}'}" + ) - if any(detections[cam][0] is None for cam in cams): + raw_counts = {cam: (0 if detections[cam][1] is None else len(detections[cam][1])) for cam in cams} + + # Homografia all_valid: + # Aqui não usamos corte por câmera tipo "RGB precisa ter 20/40 pontos". + # Se um frame achou poucos pontos, mas tem pelo menos 4 IDs comuns no par, + # esses pontos entram no acumulado. O corte forte é feito depois, no total. + if args.homography_mode == "all_valid": + rgb_corners, rgb_ids = detections[args.rgb_cam] + re_corners, re_ids = detections[args.re_cam] + nir_corners, nir_ids = detections[args.nir_cam] + + pts_re, pts_rgb_re, common_re = common_points_pair( + re_corners, + re_ids, + rgb_corners, + rgb_ids, + min_common=args.homo_min_common_frame, + ) + if pts_re is not None: + homography_pairs["RE_to_RGB"].append({ + "triplet_idx": idx, + "pts_src": pts_re, + "pts_dst": pts_rgb_re, + "common_ids": np.array(common_re, dtype=np.int32), + "src_file": str(item[args.re_cam]), + "dst_file": str(item[args.rgb_cam]), + "src_count": raw_counts[args.re_cam], + "dst_count": raw_counts[args.rgb_cam], + }) + print(f" [HOMO ADD] RE->RGB common={len(common_re)} total_records={len(homography_pairs['RE_to_RGB'])}") + else: + print(f" [HOMO SKIP] RE->RGB common={len(common_re)} < {args.homo_min_common_frame}") + + pts_nir, pts_rgb_nir, common_nir = common_points_pair( + nir_corners, + nir_ids, + rgb_corners, + rgb_ids, + min_common=args.homo_min_common_frame, + ) + if pts_nir is not None: + homography_pairs["NIR_to_RGB"].append({ + "triplet_idx": idx, + "pts_src": pts_nir, + "pts_dst": pts_rgb_nir, + "common_ids": np.array(common_nir, dtype=np.int32), + "src_file": str(item[args.nir_cam]), + "dst_file": str(item[args.rgb_cam]), + "src_count": raw_counts[args.nir_cam], + "dst_count": raw_counts[args.rgb_cam], + }) + print(f" [HOMO ADD] NIR->RGB common={len(common_nir)} total_records={len(homography_pairs['NIR_to_RGB'])}") + else: + print(f" [HOMO SKIP] NIR->RGB common={len(common_nir)} < {args.homo_min_common_frame}") + + detections_calib = {} + for cam in cams: + corners, ids = detections[cam] + if raw_counts[cam] >= args.min_corners: + detections_calib[cam] = (corners, ids) + else: + detections_calib[cam] = (None, None) + + counts = {cam: (0 if detections_calib[cam][1] is None else len(detections_calib[cam][1])) for cam in cams} + + if any(detections_calib[cam][0] is None for cam in cams): print(" [REJECT] detect insuficiente: " + ", ".join([f"{cam}={counts[cam]}" for cam in cams])) - dbg = draw_debug_panel(gray_by_cam, detections, 0, False, f"triplet_{idx:04d}") + dbg = draw_debug_panel(gray_by_cam, detections_calib, 0, False, f"triplet_{idx:04d}") cv2.imwrite(str(debug_dir / f"triplet_{idx:04d}_rejected.png"), dbg) rejected += 1 continue - obj, imgpoints_by_cam, common_ids = common_points_multi(detections, board_corners, min_common=args.min_common) + obj, imgpoints_by_cam, common_ids = common_points_multi(detections_calib, board_corners, min_common=args.min_common) if obj is None: print(f" [REJECT] comuns insuficientes nas 3 cams: common={len(common_ids)}") - dbg = draw_debug_panel(gray_by_cam, detections, len(common_ids), False, f"triplet_{idx:04d}") + dbg = draw_debug_panel(gray_by_cam, detections_calib, len(common_ids), False, f"triplet_{idx:04d}") cv2.imwrite(str(debug_dir / f"triplet_{idx:04d}_rejected.png"), dbg) rejected += 1 continue @@ -791,7 +1239,7 @@ def main(): single_imgpoints[cam].append(imgpoints_by_cam[cam].copy()) accepted += 1 - dbg = draw_debug_panel(gray_by_cam, detections, len(common_ids), True, f"triplet_{idx:04d}") + dbg = draw_debug_panel(gray_by_cam, detections_calib, len(common_ids), True, f"triplet_{idx:04d}") cv2.imwrite(str(debug_dir / f"triplet_{idx:04d}_accepted.png"), dbg) if args.show: @@ -810,11 +1258,79 @@ def main(): cv2.destroyAllWindows() print("") - print(f"[INFO] triplets aceitos: {accepted}") - print(f"[INFO] triplets rejeitados: {rejected}") + print(f"[INFO] triplets aceitos para calibração stereo: {accepted}") + print(f"[INFO] triplets rejeitados para calibração stereo: {rejected}") + re_acc_points = sum(len(x["pts_src"]) for x in homography_pairs["RE_to_RGB"]) + nir_acc_points = sum(len(x["pts_src"]) for x in homography_pairs["NIR_to_RGB"]) + print(f"[INFO] pares acumulados homografia RE->RGB: {len(homography_pairs['RE_to_RGB'])} | pontos={re_acc_points}") + print(f"[INFO] pares acumulados homografia NIR->RGB: {len(homography_pairs['NIR_to_RGB'])} | pontos={nir_acc_points}") - if accepted < 8: - raise RuntimeError(f"Poucos triplets aceitos: {accepted}. Ideal: 20-40+ bons.") + homo_result = None + if args.homography_mode == "all_valid": + print("") + print("[HOMO] Calculando homografia planar com TODOS os pares válidos do dataset...") + try: + homo_result = compute_planar_homography_from_collected_pairs( + homography_pairs=homography_pairs, + image_size=image_size, + args=args, + ) + + hs = homo_result["stats"] + print("[HOMO] Resultado planar all_valid:") + print(f" RE points/inliers={hs['re_total_points']}/{hs['re_inliers']} ({hs['re_inlier_pct']:.1f}%)") + print(f" NIR points/inliers={hs['nir_total_points']}/{hs['nir_inliers']} ({hs['nir_inlier_pct']:.1f}%)") + print(f" frames RE/NIR/common={hs['re_frames_used']}/{hs['nir_frames_used']}/{hs['common_frames_used']}") + print(f" overlap RE={hs['overlap_re_pct']:.1f}% NIR={hs['overlap_nir_pct']:.1f}% common={hs['overlap_common_pct']:.1f}%") + + except Exception as e: + print(f"[HOMO][WARN] Não foi possível calcular homografia all_valid: {e}") + homo_result = None + + out_path = out_dir / f"multicam_calib_{'_'.join(cams)}_ref_{args.ref_cam}.npz" + + if accepted < args.min_calib_triplets: + if homo_result is None: + raise RuntimeError( + f"Poucos triplets aceitos para calibração stereo: {accepted}. " + f"Mínimo configurado={args.min_calib_triplets}. " + f"Também não foi possível salvar homografia." + ) + + save_dict = { + "schema": "multicam_charuco_raw10_v4_planar_homography_accumulated_only", + "cams": np.array(cams), + "rgb_cam": args.rgb_cam, + "nir_cam": args.nir_cam, + "re_cam": args.re_cam, + "ref_cam": args.ref_cam, + "image_size": np.array(image_size, dtype=np.int32), + "squares_x": args.squares_x, + "squares_y": args.squares_y, + "square_length": args.square_length, + "marker_length": args.marker_length, + "aruco_dict": args.aruco_dict, + "rectify_alpha": args.rectify_alpha, + "accepted": accepted, + "rejected": rejected, + "stereo_calibration_available": False, + "stereo_calibration_note": ( + f"Calibração stereo não executada porque accepted={accepted} " + f"< min_calib_triplets={args.min_calib_triplets}." + ), + } + + for cam in cams: + save_dict[f"role_{cam}"] = cam_roles[cam] + + add_homography_to_save_dict(save_dict, homo_result, args) + np.savez_compressed(out_path, **save_dict) + + print("") + print(f"[OK] homografia planar salva em: {out_path}") + print("[OK] calibração stereo não foi executada por falta de triplets aceitos.") + print(f"[OK] debug salvo em: {debug_dir}") + return K = {} D = {} @@ -901,41 +1417,12 @@ def main(): extr_R_to_ref[cam] = R_cam_to_ref extr_T_to_ref[cam] = T_cam_to_ref - # Homografia planar opcional. - homo_result = None - homo_triplet = None - homo_ref_resolved = "" - - if args.homo_ref_frame: - print("") - print(f"[HOMO] Resolvendo frame de referência planar: {args.homo_ref_frame}") - homo_triplet, homo_ref_resolved = resolve_homography_triplet(triplets, args.homo_ref_frame, args.rgb_cam) - print(f"[HOMO] Usando triplet: {homo_ref_resolved}") - for cam in cams: - print(f" {cam}: {homo_triplet[cam]}") - - homo_result = compute_planar_homography_from_triplet( - triplet=homo_triplet, - cams=cams, - cam_roles=cam_roles, - sizes=sizes, - image_size=image_size, - args=args, - board=board, - aruco_dict=aruco_dict, - ) - - hs = homo_result["stats"] - print("[HOMO] Resultado planar:") - print(f" RGB corners={hs['rgb_corners']} RE corners={hs['re_corners']} NIR corners={hs['nir_corners']}") - print(f" RE common/inliers={hs['re_common']}/{hs['re_inliers']}") - print(f" NIR common/inliers={hs['nir_common']}/{hs['nir_inliers']}") - print(f" overlap RE={hs['overlap_re_pct']:.1f}% NIR={hs['overlap_nir_pct']:.1f}% common={hs['overlap_common_pct']:.1f}%") + # Homografia planar all_valid já foi calculada antes da calibração stereo. out_path = out_dir / f"multicam_calib_{'_'.join(cams)}_ref_{args.ref_cam}.npz" save_dict = { - "schema": "multicam_charuco_raw10_v2_planar_homography", + "schema": "multicam_charuco_raw10_v4_planar_homography_accumulated", "cams": np.array(cams), "rgb_cam": args.rgb_cam, "nir_cam": args.nir_cam, @@ -950,6 +1437,10 @@ def main(): "rectify_alpha": args.rectify_alpha, "accepted": accepted, "rejected": rejected, + "stereo_calibration_available": True, + "calibration_mode": "stereo_global", + "source_root_dirs": np.array([str(p) for p in root_dirs]), + "source_root_count": len(root_dirs), } for cam in cams: @@ -974,31 +1465,7 @@ def main(): save_dict[f"pair_{key}_{rk}"] = rv if homo_result is not None: - hs = homo_result["stats"] - save_dict["has_planar_homography"] = True - save_dict["planar_homography_source"] = str(args.homo_ref_frame) - save_dict["planar_homography_resolved"] = str(homo_ref_resolved) - save_dict["planar_homography_note"] = "Homography maps RE/NIR to RGB for the physical plane visible in homo_ref_frame." - - save_dict["H_RE_to_RGB"] = homo_result["H_RE_to_RGB"] - save_dict["H_NIR_to_RGB"] = homo_result["H_NIR_to_RGB"] - save_dict[f"H_{args.re_cam}_to_{args.rgb_cam}"] = homo_result["H_RE_to_RGB"] - save_dict[f"H_{args.nir_cam}_to_{args.rgb_cam}"] = homo_result["H_NIR_to_RGB"] - - save_dict["homography_mask_RE_to_RGB"] = homo_result["mask_RE_to_RGB"] - save_dict["homography_mask_NIR_to_RGB"] = homo_result["mask_NIR_to_RGB"] - save_dict["homography_common_ids_RE_to_RGB"] = homo_result["common_ids_RE_to_RGB"] - save_dict["homography_common_ids_NIR_to_RGB"] = homo_result["common_ids_NIR_to_RGB"] - - save_dict["overlap_RE_to_RGB"] = homo_result["overlap_RE_to_RGB"] - save_dict["overlap_NIR_to_RGB"] = homo_result["overlap_NIR_to_RGB"] - save_dict["overlap_common_RGB"] = homo_result["overlap_common_RGB"] - - for cam in cams: - save_dict[f"planar_homography_file_{cam}"] = str(homo_triplet[cam]) - - for k, v in hs.items(): - save_dict[f"planar_homography_{k}"] = v + add_homography_to_save_dict(save_dict, homo_result, args) else: save_dict["has_planar_homography"] = False diff --git a/Python/OAK/datasets/oak-fcc-3/utils/audit_dataset_manual.py b/Python/OAK/datasets/oak-fcc-3/utils/audit_dataset_manual.py index a132a17e7..08d523926 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/audit_dataset_manual.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/audit_dataset_manual.py @@ -1,15 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +audit_multispec_dataset_bulletproof.py + +Auditoria parruda para dataset multiespectral OAK-FCC-3 / OAK-FCC-3P. + +Objetivo: + Avaliar se o tensor final usado para treino/inferência está saudável o bastante + para campo, com rastreabilidade por amostra, por grupo, por classe e por canal. + +Suporta: + 1) Dataset original RAW multi-câmera: + group//metas/*.json + group//bins/*_CAM_A.bin, *_CAM_B.bin, *_CAM_C.bin + group//masks/*.png/.npy + + 2) Dataset normalizado tensor_npy: + dataset//group//metas/*.json + dataset//group//tensors/*.npy + dataset//group//masks/*.npy/.png + + 3) Dataset final com payload único .bin/.raw, quando meta traz saved_payload_path. + +Gera: + - audit_summary.json + - audit_health.json + - audit_warnings.json + - audit_samples.csv + - audit_by_group.csv + - audit_by_class_channel.csv + - audit_by_sample_class_feature.csv + - audit_core_telemetry.csv + - audit_manifest_readme.txt + - visuals/*.png + - fixed/group/*, opcionalmente, com amostras aprovadas + +Exemplos: + # Auditoria visual rápida no dataset normalizado + python -m utils.audit_multispec_dataset_bulletproof ^ + --input_path dataset/1024x640/group ^ + --out_dir audit_out ^ + --save-visuals --visual-every 20 + + # Revisão manual e criação do fixed/group + python -m utils.audit_multispec_dataset_bulletproof ^ + --input_path dataset/1024x640/group ^ + --out_dir audit_out_manual ^ + --manual-review --build-fixed-dataset --save-rejected-previews + + # Auditoria do RAW original reconstruindo o tensor com RawProcessorCore + python -m utils.audit_multispec_dataset_bulletproof ^ + --input_path dataset/original/group ^ + --out_dir audit_raw_out ^ + --save-visuals --visual-every 10 +""" + +from __future__ import annotations + import argparse import csv +import hashlib import json import math +import shutil +import sys +import time import unicodedata from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Tuple, Any +from typing import Any, Dict, Iterable, List, Optional, Tuple import cv2 import numpy as np -import shutil try: from core.raw_processor_core import RawProcessorCore @@ -18,33 +80,9 @@ except Exception: # ============================================================ -# Auditoria final de dataset multiespectral OAK-FCC-3P -# ------------------------------------------------------------ -# Foco: tensor final pronto para o modelo, CHW float32: -# [R, G, B, RE, NIR] -# -# Gera: -# - audit_summary.json -# - audit_samples.csv -# - audit_by_class_channel.csv -# - audit_by_sample_class_channel.csv -# - audit_warnings.json -# - visuals/*.png com painéis de sanidade -# -# Layout esperado: -# dataset_root/ -# metas/*.json -# bins/*.raw ou *.bin -# masks/*.png/.tif/.npy -# previews/*.png opcional -# -# Também aceita apontar diretamente para dataset_root/metas etc. -# -# python -m utils.audit_dataset --input_path .\dataset\original\group\ --out_dir audit_multispec_out --save-visuals --visual-every 10 --build-fixed-dataset --save-rejected-previews --rejected-preview-source auto --rejected-preview-max-width 640 --clean-reject-low-corr-only-if-shift-bad --clean-max-abs-shift-px 22 --clean-max-dev-shift-px 7 --clean-min-edge-corr 0.045 --clean-min-target-pct 0.0025 -# +# Constantes do contrato atual # ============================================================ - CHANNELS = ["R", "G", "B", "RE", "NIR"] DERIVED = [ "NDVI", @@ -65,21 +103,16 @@ DEFAULT_CLASS_MAP = { 2: "erva", } -# Labelmap das mascaras coloridas, informado pelo projeto. -# Valores em RGB, como normalmente aparecem em ferramentas de anotacao. -# Como o OpenCV le PNG em BGR, a funcao decode_color_mask converte internamente. DEFAULT_MASK_COLOR_MAP_RGB = { (128, 0, 0): 0, # chao (0, 0, 128): 1, # cana (0, 128, 0): 2, # erva } -# Paleta de visualizacao em BGR para o painel GT mask. -# Mantem visual equivalente ao labelmap RGB acima. DEFAULT_VIS_PALETTE_BGR = { - 0: (0, 0, 128), # chao: RGB 128,0,0 - 1: (128, 0, 0), # cana: RGB 0,0,128 - 2: (0, 128, 0), # erva: RGB 0,128,0 + 0: (0, 0, 128), + 1: (128, 0, 0), + 2: (0, 128, 0), 255: (0, 0, 0), } @@ -87,7 +120,7 @@ EPS = 1e-6 # ============================================================ -# Utilidades básicas +# Helpers gerais # ============================================================ @@ -98,15 +131,36 @@ def ensure_dir(path: Path | str) -> Path: def load_json(path: Path) -> dict: - with open(path, "r", encoding="utf-8") as f: + with path.open("r", encoding="utf-8") as f: return json.load(f) def write_json(path: Path, data: Any): - with open(path, "w", encoding="utf-8") as f: + ensure_dir(path.parent) + with path.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) +def write_csv(path: Path, rows: List[Dict[str, Any]]): + ensure_dir(path.parent) + if not rows: + path.write_text("", encoding="utf-8") + return + + keys: List[str] = [] + seen = set() + for row in rows: + for k in row.keys(): + if k not in seen: + seen.add(k) + keys.append(k) + + with path.open("w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore") + w.writeheader() + w.writerows(rows) + + def safe_float(x: Any, default: float = 0.0) -> float: try: if x is None: @@ -119,15 +173,26 @@ def safe_float(x: Any, default: float = 0.0) -> float: return default +def safe_int(x: Any, default: int = 0) -> int: + try: + if x is None: + return default + return int(x) + except Exception: + return default + + +def cv_text(text: Any) -> str: + s = str(text) + s = unicodedata.normalize("NFKD", s) + return s.encode("ascii", "ignore").decode("ascii") + + def parse_class_map(text: Optional[str]) -> Dict[int, str]: if not text: return dict(DEFAULT_CLASS_MAP) - - out = {} - # Formatos aceitos: - # "0:chao,1:cana,2:erva" - # "0=chao,1=cana,2=erva" - for item in text.split(","): + out: Dict[int, str] = {} + for item in str(text).split(","): item = item.strip() if not item: continue @@ -138,44 +203,69 @@ def parse_class_map(text: Optional[str]) -> Dict[int, str]: 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: - """ - Converte lista simples separada por vírgula em set normalizado. - - Ex: - "chao" -> {"chao"} - "chao, chao_cana" -> {"chao", "chao_cana"} - """ if not text: return set() + return {x.strip().lower() for x in str(text).split(",") if x.strip()} - out = set() - for item in str(text).split(","): - item = item.strip() - if item: - out.add(item.lower()) + +def sha1_short(path: Path, max_bytes: int = 1024 * 1024) -> str: + try: + h = hashlib.sha1() + with path.open("rb") as f: + h.update(f.read(max_bytes)) + return h.hexdigest()[:12] + except Exception: + return "" + + +def safe_rel(path: Path, root: Path) -> str: + try: + return str(path.resolve().relative_to(root.resolve())).replace("\\", "/") + except Exception: + return str(path).replace("\\", "/") + + +def flatten_dict(d: Any, prefix: str = "", max_depth: int = 4) -> Dict[str, Any]: + out: Dict[str, Any] = {} + if max_depth <= 0 or not isinstance(d, dict): + return out + for k, v in d.items(): + key = f"{prefix}{k}" if not prefix else f"{prefix}.{k}" + if isinstance(v, dict): + out.update(flatten_dict(v, key, max_depth - 1)) + elif isinstance(v, (str, int, float, bool)) or v is None: + out[key] = v + elif isinstance(v, (list, tuple)): + if len(v) <= 8 and all(isinstance(x, (str, int, float, bool)) or x is None for x in v): + out[key] = json.dumps(list(v), ensure_ascii=False) + else: + out[key] = f"list[{len(v)}]" + else: + out[key] = str(type(v).__name__) return out +# ============================================================ +# Visualização +# ============================================================ + + def normalize_to_u8(x: np.ndarray, p_low: float = 1.0, p_high: float = 99.0) -> np.ndarray: arr = x.astype(np.float32, copy=False) finite = np.isfinite(arr) if not np.any(finite): return np.zeros(arr.shape, dtype=np.uint8) - vals = arr[finite] lo = np.percentile(vals, p_low) hi = np.percentile(vals, p_high) if hi <= lo + EPS: hi = lo + 1.0 - y = (arr - lo) / (hi - lo) - y = np.clip(y, 0, 1) - return (y * 255.0).astype(np.uint8) + return np.clip(y * 255.0, 0, 255).astype(np.uint8) def float01_to_u8(x: np.ndarray) -> np.ndarray: @@ -185,57 +275,40 @@ def float01_to_u8(x: np.ndarray) -> np.ndarray: 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: - chans = [normalize_to_u8(rgb[:, :, i]) for i in range(3)] - rgb_u8 = np.dstack(chans) + rgb_u8 = np.dstack([normalize_to_u8(rgb[:, :, i]) for i in range(3)]) else: rgb_u8 = float01_to_u8(rgb) return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR) def apply_colormap_gray(x: np.ndarray, stretch: bool = True) -> np.ndarray: - if stretch: - u8 = normalize_to_u8(x) - else: - u8 = float01_to_u8(x) + u8 = normalize_to_u8(x) if stretch else float01_to_u8(x) return cv2.applyColorMap(u8, cv2.COLORMAP_VIRIDIS) -def cv_text(text: Any) -> str: - """ - OpenCV putText nao lida bem com acentos/cedilha em muitos ambientes. - Converte qualquer texto para ASCII seguro. - """ - s = str(text) - s = unicodedata.normalize("NFKD", s) - s = s.encode("ascii", "ignore").decode("ascii") - return s - - def put_label(img: np.ndarray, title: str, subtitle: str = "") -> np.ndarray: out = img.copy() title = cv_text(title) subtitle = cv_text(subtitle) - cv2.rectangle(out, (0, 0), (out.shape[1], 58 if subtitle else 36), (0, 0, 0), -1) - cv2.putText(out, title, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2, cv2.LINE_AA) + header_h = 62 if subtitle else 38 + cv2.rectangle(out, (0, 0), (out.shape[1], header_h), (0, 0, 0), -1) + cv2.putText(out, title[:90], (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.63, (0, 255, 255), 2, cv2.LINE_AA) if subtitle: - cv2.putText(out, subtitle[:120], (10, 48), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA) + cv2.putText(out, subtitle[:130], (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.43, (255, 255, 255), 1, cv2.LINE_AA) return out -def resize_keep(img: np.ndarray, size: Tuple[int, int]) -> np.ndarray: - return cv2.resize(img, size, interpolation=cv2.INTER_AREA) - - -def make_grid(panels: List[Tuple[str, np.ndarray, str]], panel_w: int = 360) -> np.ndarray: - rendered = [] +def make_grid(panels: List[Tuple[str, np.ndarray, str]], panel_w: int = 380, cols: int = 3) -> np.ndarray: + rendered: List[np.ndarray] = [] for title, img, subtitle in panels: - scale = panel_w / img.shape[1] + if img.ndim == 2: + img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + scale = panel_w / max(1, img.shape[1]) panel_h = max(1, int(img.shape[0] * scale)) small = cv2.resize(img, (panel_w, panel_h), interpolation=cv2.INTER_AREA) rendered.append(put_label(small, title, subtitle)) - if not rendered: - return np.zeros((200, 400, 3), dtype=np.uint8) + return np.zeros((240, 480, 3), dtype=np.uint8) max_h = max(x.shape[0] for x in rendered) padded = [] @@ -245,18 +318,19 @@ def make_grid(panels: List[Tuple[str, np.ndarray, str]], panel_w: int = 360) -> img = np.vstack([img, pad]) padded.append(img) - cols = 3 - rows = [] gap_w = np.full((max_h, 12, 3), 25, dtype=np.uint8) - gap_h = np.full((12, cols * panel_w + (cols - 1) * 12, 3), 25, dtype=np.uint8) + row_w = cols * panel_w + (cols - 1) * 12 + gap_h = np.full((12, row_w, 3), 25, dtype=np.uint8) + rows = [] for i in range(0, len(padded), cols): row_imgs = padded[i:i + cols] while len(row_imgs) < cols: row_imgs.append(np.zeros_like(padded[0])) - row = np.hstack([row_imgs[0], gap_w, row_imgs[1], gap_w, row_imgs[2]]) + row = row_imgs[0] + for img in row_imgs[1:]: + row = np.hstack([row, gap_w, img]) rows.append(row) - canvas = rows[0] for r in rows[1:]: canvas = np.vstack([canvas, gap_h, r]) @@ -264,48 +338,25 @@ def make_grid(panels: List[Tuple[str, np.ndarray, str]], panel_w: int = 360) -> # ============================================================ -# Detecção de layout e leitura dos tensores/máscaras +# Dataset discovery / leitura # ============================================================ def is_dataset_root(path: Path) -> bool: - """ - Um dataset final válido tem, no mínimo: - root/metas - root/bins - - masks/ e previews/ são opcionais para a auditoria, embora masks/ seja - necessário para estatísticas por classe. - """ - return (path / "metas").is_dir() and (path / "bins").is_dir() + # original RAW: metas + bins + if (path / "metas").is_dir() and (path / "bins").is_dir(): + return True + # normalizado novo: metas + tensors + if (path / "metas").is_dir() and (path / "tensors").is_dir(): + return True + # dataset com metas + masks pode ser parcialmente auditável, mas precisa payload no meta + if (path / "metas").is_dir() and ((path / "masks").is_dir() or (path / "previews").is_dir()): + return True + return False def find_dataset_roots(path: Path) -> List[Path]: - """ - Detecta um ou vários datasets. - - Suporta: - 1) dataset direto: - root/metas - root/bins - root/masks - - 2) subpasta do dataset: - root/metas, root/bins etc, mas input_path=root/metas ou root/bins - - 3) super-root com grupos dentro: - group/chao/metas - group/chao/bins - group/chao_cana/metas - group/chao_cana/bins - ... - - Isso resolve o caso: - --input_path dataset/1024x640/group - quando group contém vários datasets filhos. - """ p = path.resolve() - candidates: List[Path] = [] if p.is_file(): candidates.extend([p.parent, p.parent.parent]) @@ -313,18 +364,11 @@ def find_dataset_roots(path: Path) -> List[Path]: candidates.extend([p, p.parent]) roots: List[Path] = [] - - # Primeiro tenta o próprio caminho ou pai direto. for c in candidates: - if c.name.lower() in ("metas", "bins", "masks", "previews"): - root = c.parent - else: - root = c - + root = c.parent if c.name.lower() in ("metas", "bins", "masks", "previews", "tensors") else c if is_dataset_root(root): roots.append(root) - # Se não achou, procura recursivamente datasets filhos. 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"): @@ -332,32 +376,21 @@ def find_dataset_roots(path: Path) -> List[Path]: if is_dataset_root(root): roots.append(root) - # Remove duplicados preservando ordem. unique: List[Path] = [] seen = set() for r in roots: rr = r.resolve() if rr not in seen: - unique.append(rr) seen.add(rr) - + unique.append(rr) if not unique: raise FileNotFoundError( f"Não consegui detectar dataset_root a partir de {path}. " - "Esperado root/metas e root/bins, ou um super-root contendo grupos com metas/bins." + "Esperado root/metas + root/bins ou root/tensors." ) - return unique -def find_dataset_root(path: Path) -> Path: - """ - Compatibilidade com chamadas antigas: retorna o primeiro root encontrado. - Para a auditoria principal, use find_dataset_roots(). - """ - return find_dataset_roots(path)[0] - - def list_meta_files(dataset_root: Path) -> List[Path]: metas = sorted((dataset_root / "metas").glob("*.json")) if not metas: @@ -365,64 +398,62 @@ def list_meta_files(dataset_root: Path) -> List[Path]: return metas -def find_sibling(dataset_root: Path, subdir: str, stem: str, exts: Tuple[str, ...]) -> Optional[Path]: - folder = dataset_root / subdir - if not folder.is_dir(): - return None - for ext in exts: - p = folder / f"{stem}{ext}" - if p.exists(): - return p - return None +def resolve_mask_path(dataset_root: Path, meta_path: Path, meta: Optional[dict] = None) -> Optional[Path]: + meta = meta or {} + candidates: List[Path] = [] + # Caminho explícito do normalizador novo. + for key in ("saved_mask_path", "mask_path"): + val = meta.get(key) + if val: + p = Path(str(val)) + candidates.extend([dataset_root / p, meta_path.parent / p, Path.cwd() / p]) -def resolve_mask_path(dataset_root: Path, meta_path: Path) -> Optional[Path]: - """ - Resolve mascara da amostra. + 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]) - Importante: o sample_name usado no relatorio pode receber prefixo do grupo - tipo 'chao__2026...', mas o arquivo real da mascara continua usando - meta_path.stem, sem prefixo. Esse foi o bug da rodada anterior. - """ real_stem = meta_path.stem masks_dir = dataset_root / "masks" - - candidates: List[Path] = [] - for ext in (".png", ".tif", ".tiff", ".npy"): + for ext in (".npy", ".png", ".tif", ".tiff"): candidates.append(masks_dir / f"{real_stem}{ext}") - for suffix in ("_mask", "_gt", "_label", "_labels", "_seg"): - for ext in (".png", ".tif", ".tiff", ".npy"): + for ext in (".npy", ".png", ".tif", ".tiff"): candidates.append(masks_dir / f"{real_stem}{suffix}{ext}") - if masks_dir.is_dir(): candidates.extend(sorted(masks_dir.glob(f"{real_stem}*.*"))) for p in candidates: - if p.exists() and p.suffix.lower() in (".png", ".tif", ".tiff", ".npy"): + p = Path(p) + if p.exists() and p.suffix.lower() in (".npy", ".png", ".tif", ".tiff"): return p + return None + +def resolve_tensor_npy_path(meta_path: Path, dataset_root: Path, meta: dict) -> Optional[Path]: + candidates: List[Path] = [] + for key in ("saved_tensor_path", "tensor_path"): + val = meta.get(key) + if val: + p = Path(str(val)) + candidates.extend([dataset_root / p, meta_path.parent / p, Path.cwd() / p]) + candidates.append(dataset_root / "tensors" / f"{meta_path.stem}.npy") + for p in candidates: + if p.exists() and p.suffix.lower() == ".npy": + return p return None def resolve_tensor_payload(meta_path: Path, dataset_root: Path, meta: dict) -> Optional[Path]: - """ - Resolve payload final já pronto, quando o dataset já contém MULTISPEC salvo. - Para RAW_BRUTO multi por câmera, use build_multispec_from_raw_native_multi(). - """ stem = meta_path.stem + candidates: List[Path] = [] bins = dataset_root / "bins" - - candidates = [] - saved_payload_path = meta.get("saved_payload_path") - if saved_payload_path: - sp = Path(saved_payload_path) - candidates.extend([ - bins / sp.name, - meta_path.parent / sp, - dataset_root / sp, - ]) - + val = meta.get("saved_payload_path") + if val: + p = Path(str(val)) + candidates.extend([bins / p.name, dataset_root / p, meta_path.parent / p, Path.cwd() / p]) candidates.extend([ bins / f"{stem}.raw", bins / f"{stem}.bin", @@ -431,7 +462,6 @@ def resolve_tensor_payload(meta_path: Path, dataset_root: Path, meta: dict) -> O bins / f"{stem}_offline_multispec.raw", bins / f"{stem}_offline_multispec.bin", ]) - for c in candidates: if c.exists(): return c @@ -439,250 +469,247 @@ def resolve_tensor_payload(meta_path: Path, dataset_root: Path, meta: dict) -> O def resolve_camera_payloads(meta_path: Path, dataset_root: Path, meta: dict) -> Dict[str, Path]: - """ - Resolve os .bin/.raw por câmera quando saved_payload_type='raw_native_multi'. - """ stem = meta_path.stem bins = dataset_root / "bins" - paths = {} - + paths: Dict[str, Path] = {} saved_payload_paths = meta.get("saved_payload_paths", {}) or {} + if not isinstance(saved_payload_paths, dict) or not saved_payload_paths: + # Fallback pelo padrão de nome. + for cam_id in ("CAM_A", "CAM_B", "CAM_C"): + for ext in (".bin", ".raw"): + p = bins / f"{stem}_{cam_id}{ext}" + if p.exists(): + paths[cam_id] = p + break + if paths: + return paths + raise FileNotFoundError(f"saved_payload_paths ausente e bins por câmera não encontrados para {meta_path.name}") + for cam_id, fname in saved_payload_paths.items(): - fp = Path(fname) + fp = Path(str(fname)) candidates = [ bins / fp.name, - meta_path.parent / fname, - dataset_root / fname, + meta_path.parent / fp, + dataset_root / fp, + Path.cwd() / fp, bins / f"{stem}_{cam_id}.bin", bins / f"{stem}_{cam_id}.raw", - bins / f"{stem}_{cam_id.lower()}.bin", - bins / f"{stem}_{cam_id.lower()}.raw", + bins / f"{stem}_{str(cam_id).lower()}.bin", + bins / f"{stem}_{str(cam_id).lower()}.raw", ] - found = None for c in candidates: if Path(c).exists(): found = Path(c) break - if found is None: raise FileNotFoundError(f"Payload bruto não encontrado para {cam_id} em {meta_path.name}: {fname}") - - paths[cam_id] = found - + paths[str(cam_id)] = found return paths -def resolve_module_params_path(meta_path: Path, dataset_root: Path, meta: dict) -> str: - """ - Tenta encontrar o module_params.json usado para reconstruir o tensor. - A ideia é usar exatamente o mesmo caminho salvo no meta quando existir. - """ - calib_path = meta.get("camera_params_json") or meta.get("module_params_json") or "calibration/module_params.json" - p = Path(calib_path) - - candidates = [] - 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, - Path.cwd() / "calibration" / "module_params.json", - ]) - +def resolve_module_params_path(meta_path: Path, dataset_root: Path, meta: dict) -> Optional[str]: + candidates: List[Path] = [] + for key in ("camera_params_json", "module_params_json"): + val = meta.get(key) + if val: + p = Path(str(val)) + if p.is_absolute(): + candidates.append(p) + else: + candidates.extend([Path.cwd() / p, meta_path.parent / p, dataset_root / p, dataset_root.parent / p, dataset_root.parent.parent / p]) + candidates.append(Path.cwd() / "calibration" / "module_params.json") for c in candidates: if c.exists(): return str(c) - - raise FileNotFoundError( - f"module_params.json não encontrado. Valor no meta={calib_path}. " - "Sem ele eu não consigo aplicar homografia/flat/radiometria igual ao pipeline final." - ) + return None -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, -): - """ - Reaproveita RawProcessorCore entre amostras. +@dataclass +class TensorLoadResult: + tensor: np.ndarray + meta: dict + payload_path: Path + source_kind: str + core_telemetry: Dict[str, Any] = field(default_factory=dict) - Isso evita recarregar flat-field e recriar caches de gain a cada imagem. - A chave considera tamanho, bayer e module_params.json. - """ - key = (int(sensor_width), int(sensor_height), str(bayer), str(Path(calib_path).resolve())) + +def get_raw_processor_core(core_cache: Dict[Tuple[int, int, str, str], Any], sensor_width: int, sensor_height: int, bayer: str, calib_path: str): + key = (int(sensor_width), int(sensor_height), str(bayer).upper(), str(Path(calib_path).resolve())) if key not in core_cache: if RawProcessorCore is None: - raise RuntimeError( - "Não consegui importar RawProcessorCore. Rode com python -m utils.audit_dataset " - "a partir da raiz do projeto, igual você fez, e confirme se core/raw_processor_core.py existe." - ) + raise RuntimeError("RawProcessorCore não importado. Rode a partir da raiz do projeto e confira core/raw_processor_core.py.") core_cache[key] = RawProcessorCore( sensor_width=int(sensor_width), sensor_height=int(sensor_height), - bayer_pattern=str(bayer), + bayer_pattern=str(bayer).upper(), calibration_json_path=str(calib_path), ) return core_cache[key] -def build_multispec_from_raw_native_multi(meta_path: Path, dataset_root: Path, meta: dict, core_cache: Dict[Tuple[int, int, str, str], Any]) -> Tuple[np.ndarray, Path]: - """ - Reconstrói o tensor final [R,G,B,RE,NIR] a partir do RAW_BRUTO multi por câmera. +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 - Usa o mesmo princípio do script visual antigo: - - saved_payload_paths - - saved_payload_shapes - - saved_payload_dtypes - - stream_meta.camera_info - - actual_camera_controls/startup_camera_controls - - module_params.json com homografia/flat/radiometria - """ + +def capture_core_telemetry(core: Any, normalized_meta: Optional[dict] = None) -> Dict[str, Any]: + out: Dict[str, Any] = {} + if core is not None: + for name, attr in ( + ("radiometric_normalization", "last_radiometric_normalization_result"), + ("patch_normalization", "last_patch_normalization_result"), + ("frame_quality", "last_frame_quality_result"), + ("fusion", "last_fusion_result"), + ("decode_perf", "last_decode_perf"), + ): + val = getattr(core, attr, None) + if val is not None: + try: + json.dumps(val, default=str) + out[name] = val + except Exception: + out[name] = str(val) + if normalized_meta: + processing = normalized_meta.get("processing", {}) if isinstance(normalized_meta.get("processing"), dict) else {} + # Meta normalizado antigo/novo já pode carregar essas telemetrias. + for src_key, dst_key in ( + ("radiometric_normalization_result", "radiometric_normalization"), + ("patch_normalization_result", "patch_normalization"), + ("frame_quality", "frame_quality"), + ("fusion_result", "fusion"), + ): + if src_key in processing and dst_key not in out: + out[dst_key] = processing.get(src_key) + return out + + +def build_multispec_from_raw_native_multi(meta_path: Path, dataset_root: Path, meta: dict, core_cache: Dict[Tuple[int, int, str, str], Any]) -> TensorLoadResult: saved_dtypes = meta.get("saved_payload_dtypes", {}) or {} saved_shapes = meta.get("saved_payload_shapes", {}) or {} cam_paths = resolve_camera_payloads(meta_path, dataset_root, meta) - frame = {} + frame: Dict[str, np.ndarray] = {} for cam_id, payload_path in cam_paths.items(): - saved_dtype = saved_dtypes.get(cam_id) - saved_shape = saved_shapes.get(cam_id) - if saved_dtype is None or saved_shape is None: - raise RuntimeError(f"Faltam dtype/shape para {cam_id} em {meta_path.name}") - - arr = np.fromfile(str(payload_path), dtype=np.dtype(saved_dtype)).reshape(tuple(saved_shape)) - frame[cam_id] = arr + dtype = saved_dtypes.get(cam_id) + shape = saved_shapes.get(cam_id) + if dtype is None or shape is None: + raise RuntimeError(f"Faltam saved_payload_dtypes/shapes para {cam_id} em {meta_path.name}") + frame[cam_id] = np.fromfile(str(payload_path), dtype=np.dtype(dtype)).reshape(tuple(shape)) sensor_width = int(meta.get("sensor_width", 1280)) sensor_height = int(meta.get("sensor_height", 800)) - bayer = meta.get("bayer_pattern", "RGGB") + bayer = str(meta.get("bayer_pattern", "BGGR")).upper() calib_path = resolve_module_params_path(meta_path, dataset_root, meta) + if calib_path is None: + raise FileNotFoundError(f"module_params.json não encontrado para {meta_path.name}") - core = get_raw_processor_core( - core_cache=core_cache, - sensor_width=sensor_width, - sensor_height=sensor_height, - bayer=bayer, - calib_path=calib_path, - ) - - stream_meta = meta.get("stream_meta", {}) or {} - processing_meta = dict(stream_meta) - - if meta.get("actual_camera_controls") is not None: - processing_meta["actual_camera_controls"] = meta.get("actual_camera_controls") - if meta.get("startup_camera_controls") is not None: - processing_meta["startup_camera_controls"] = meta.get("startup_camera_controls") - if meta.get("radiometric_last_result") is not None: - processing_meta["radiometric_last_result"] = meta.get("radiometric_last_result") - + core = get_raw_processor_core(core_cache, sensor_width, sensor_height, bayer, calib_path) + processing_meta = build_processing_meta_for_core(meta) tensor = core.build_infer_tensor_from_stream(frame, processing_meta, 5) if tensor is None: - raise RuntimeError(f"RawProcessorCore retornou tensor None para {meta_path.name}") + raise RuntimeError(f"RawProcessorCore retornou None para {meta_path.name}") tensor = np.asarray(tensor, dtype=np.float32) if tensor.ndim != 3: - raise RuntimeError(f"Tensor reconstruído inválido em {meta_path.name}: shape={tensor.shape}") + raise RuntimeError(f"Tensor reconstruído inválido: {meta_path.name} shape={tensor.shape}") if tensor.shape[0] != 5 and tensor.shape[-1] == 5: tensor = np.transpose(tensor, (2, 0, 1)) if tensor.shape[0] < 5: - raise RuntimeError(f"Tensor reconstruído precisa ter 5 canais, recebido shape={tensor.shape}") + raise RuntimeError(f"Tensor precisa de 5 canais, recebido {tensor.shape}") - # Retorna o primeiro payload bruto só como referência de origem no CSV. first_payload = next(iter(cam_paths.values())) - return np.ascontiguousarray(tensor[:5]), first_payload + return TensorLoadResult( + tensor=np.ascontiguousarray(tensor[:5]), + meta=meta, + payload_path=first_payload, + source_kind="raw_native_multi_reconstructed", + core_telemetry=capture_core_telemetry(core), + ) -def load_multispec_tensor(meta_path: Path, dataset_root: Path, core_cache: Dict[Tuple[int, int, str, str], Any]) -> Tuple[np.ndarray, dict, Path]: +def load_multispec_tensor(meta_path: Path, dataset_root: Path, core_cache: Dict[Tuple[int, int, str, str], Any]) -> TensorLoadResult: meta = load_json(meta_path) - saved_type = meta.get("saved_payload_type") + saved_type = str(meta.get("saved_payload_type") or "").lower() - # Caso 1: dataset original bruto por câmera. Aqui o script monta o tensor final. if saved_type == "raw_native_multi" or "saved_payload_paths" in meta: - tensor, source_payload = build_multispec_from_raw_native_multi(meta_path, dataset_root, meta, core_cache) - return tensor, meta, source_payload + 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), + ) - # Caso 2: dataset já normalizado/MULTISPEC salvo como payload único. payload_path = resolve_tensor_payload(meta_path, dataset_root, meta) if payload_path is None: raise FileNotFoundError(f"Payload tensor final não encontrado para {meta_path.name}") dtype = meta.get("saved_payload_dtype", "float32") - shape = meta.get("saved_payload_shape") - + shape = meta.get("saved_payload_shape") or meta.get("tensor_shape") or meta.get("shape") if shape is None: - # Alguns JSONs podem guardar isso em outro campo. - shape = meta.get("tensor_shape") or meta.get("shape") - - if shape is None: - raise RuntimeError( - f"Shape do tensor não encontrado em {meta_path.name}. " - "Esperado saved_payload_shape=[5,H,W]." - ) - - arr = np.fromfile(str(payload_path), dtype=np.dtype(dtype)).reshape(tuple(shape)) - arr = arr.astype(np.float32, copy=False) - + raise RuntimeError(f"Shape do tensor não encontrado em {meta_path.name}") + arr = np.fromfile(str(payload_path), dtype=np.dtype(dtype)).reshape(tuple(shape)).astype(np.float32, copy=False) if arr.ndim != 3: raise RuntimeError(f"Tensor inválido em {payload_path}: shape={arr.shape}") - if arr.shape[0] != 5 and arr.shape[-1] == 5: arr = np.transpose(arr, (2, 0, 1)) - if arr.shape[0] < 5: - raise RuntimeError(f"Tensor precisa ter 5 canais [R,G,B,RE,NIR], recebido shape={arr.shape}") + 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), + ) - return arr[:5], meta, payload_path + +# ============================================================ +# Máscaras +# ============================================================ def decode_color_mask(mask_img: np.ndarray, ignore_index: int) -> np.ndarray: - """ - Converte mascara colorida do labelmap para indices de classe. - - Seu labelmap atual em RGB: - chao = 128,0,0 -> classe 0 - cana = 0,0,128 -> classe 1 - erva = 0,128,0 -> classe 2 - - O OpenCV le PNG como BGR, entao fazemos a conversao RGB->BGR antes de comparar. - """ if mask_img.ndim == 2: return mask_img.astype(np.int32, copy=False) - - if mask_img.shape[2] == 4: - bgr = mask_img[:, :, :3] - else: - bgr = mask_img[:, :, :3] - + bgr = mask_img[:, :, :3] out = np.full(mask_img.shape[:2], ignore_index, dtype=np.int32) - for rgb_color, cls_id in DEFAULT_MASK_COLOR_MAP_RGB.items(): r, g, b = rgb_color bgr_color = np.array([b, g, r], dtype=np.uint8) hit = np.all(bgr == bgr_color, axis=2) out[hit] = int(cls_id) - - # Fallback: se por algum motivo a imagem ja veio em ordem RGB, tenta tambem RGB direto. - # Isso evita quebrar caso alguma leitura futura nao use cv2. + # fallback caso algum pipeline já entregue RGB for rgb_color, cls_id in DEFAULT_MASK_COLOR_MAP_RGB.items(): rgb_arr = np.array(rgb_color, dtype=np.uint8) hit = np.all(bgr == rgb_arr, axis=2) - # So preenche pixels ainda nao identificados para evitar troca em cores ambiguas. 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: @@ -690,9 +717,8 @@ def load_mask(mask_path: Optional[Path], target_hw: Tuple[int, int], ignore_inde else: mask_img = cv2.imread(str(mask_path), cv2.IMREAD_UNCHANGED) if mask_img is None: - raise RuntimeError(f"Falha ao ler mascara: {mask_path}") + raise RuntimeError(f"Falha ao ler máscara: {mask_path}") mask = decode_color_mask(mask_img, ignore_index) - mask = mask.astype(np.int32, copy=False) h, w = target_hw if mask.shape[:2] != (h, w): @@ -700,27 +726,53 @@ def load_mask(mask_path: Optional[Path], target_hw: Tuple[int, int], ignore_inde return mask -def mask_unique_summary(mask: Optional[np.ndarray], max_items: int = 20) -> str: +def mask_unique_summary(mask: Optional[np.ndarray], class_map: Dict[int, str], max_items: int = 20) -> str: if mask is None: return "none" vals, counts = np.unique(mask, return_counts=True) parts = [] for v, c in zip(vals[:max_items], counts[:max_items]): - cls_name = DEFAULT_CLASS_MAP.get(int(v), "ignore" if int(v) == 255 else "unk") - parts.append(f"{int(v)}:{cls_name}:{int(c)}") + vi = int(v) + name = class_map.get(vi, "ignore" if vi == 255 else "unk") + parts.append(f"{vi}:{name}:{int(c)}") if len(vals) > max_items: parts.append("...") return ",".join(parts) +def colorize_mask(mask: Optional[np.ndarray], class_map: Dict[int, str], target_hw: Tuple[int, int]) -> np.ndarray: + h, w = target_hw + out = np.zeros((h, w, 3), dtype=np.uint8) + if mask is None: + return out + palette = dict(DEFAULT_VIS_PALETTE_BGR) + for cls_id in np.unique(mask): + ci = int(cls_id) + if ci in palette: + out[mask == ci] = palette[ci] + elif ci in class_map: + rng = np.random.default_rng(ci) + out[mask == ci] = rng.integers(40, 220, size=3) + return out + + +def mask_edges_on_rgb(rgb_bgr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarray: + out = rgb_bgr.copy() + if mask is None: + return out + m = mask.astype(np.uint8) + edges = cv2.Canny(m, 0, 1) + out[edges > 0] = (0, 255, 255) + return out + + # ============================================================ -# Features espectrais e estatísticas +# Features / estatísticas # ============================================================ def compute_feature_maps(tensor: np.ndarray) -> Dict[str, np.ndarray]: r, g, b, re, nir = [tensor[i].astype(np.float32, copy=False) for i in range(5)] - features = { "R": r, "G": g, @@ -737,8 +789,6 @@ def compute_feature_maps(tensor: np.ndarray) -> Dict[str, np.ndarray]: "RE_over_G": re / (g + EPS), "G_minus_R": g - r, } - - # Evita explosões absurdas em razão quando denominador está quase zero. 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 @@ -748,25 +798,9 @@ def calc_stats(values: np.ndarray, raw01: bool = False) -> Dict[str, float]: v = values.astype(np.float32, copy=False) v = v[np.isfinite(v)] if v.size == 0: - return { - "count": 0, - "mean": 0.0, - "std": 0.0, - "min": 0.0, - "p01": 0.0, - "p05": 0.0, - "p25": 0.0, - "p50": 0.0, - "p75": 0.0, - "p95": 0.0, - "p99": 0.0, - "max": 0.0, - "iqr": 0.0, - "p95_p05": 0.0, - "dark_pct": 0.0, - "sat_pct": 0.0, - } - + return {k: 0.0 for k in ( + "count", "mean", "std", "min", "p01", "p05", "p25", "p50", "p75", "p95", "p99", "max", "iqr", "p95_p05", "dark_pct", "sat_pct", "over_1_pct", "under_0_pct", "nan_pct" + )} p = np.percentile(v, [1, 5, 25, 50, 75, 95, 99]) out = { "count": int(v.size), @@ -785,12 +819,15 @@ def calc_stats(values: np.ndarray, raw01: bool = False) -> Dict[str, float]: "p95_p05": float(p[5] - p[1]), "dark_pct": 0.0, "sat_pct": 0.0, + "over_1_pct": 0.0, + "under_0_pct": 0.0, + "nan_pct": 0.0, } - if raw01: out["dark_pct"] = float(np.mean(v <= 0.01) * 100.0) out["sat_pct"] = float(np.mean(v >= 0.99) * 100.0) - + out["over_1_pct"] = float(np.mean(v > 1.0) * 100.0) + out["under_0_pct"] = float(np.mean(v < 0.0) * 100.0) return out @@ -798,16 +835,13 @@ def calc_stats(values: np.ndarray, raw01: bool = False) -> Dict[str, float]: 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}) - pixel_count: int = 0 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.pixel_count += int(v.size) 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] @@ -817,15 +851,15 @@ class RunningFeatureStats: out = {} for f, vals in self.values.items(): arr = np.asarray(vals, dtype=np.float32) - raw01 = f in CHANNELS - s = calc_stats(arr, raw01=raw01) - s["total_pixels_seen"] = int(self.counts.get(f, 0)) - out[f] = s + st = calc_stats(arr, raw01=(f in CHANNELS)) + st["total_pixels_seen"] = int(self.counts.get(f, 0)) + st["sampled_values"] = int(arr.size) + out[f] = st return out # ============================================================ -# Alinhamento por borda/correlação +# Alinhamento / geometria # ============================================================ @@ -833,12 +867,10 @@ 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) - mag = cv2.magnitude(gx, gy) - return mag.astype(np.float32) + return cv2.magnitude(gx, gy).astype(np.float32) def estimate_shift_phase(a: np.ndarray, b: np.ndarray) -> Tuple[float, float, float]: - # dx, dy estimados entre mapas. Valores grandes sugerem desalinhamento residual. aa = normalize_to_u8(a).astype(np.float32) bb = normalize_to_u8(b).astype(np.float32) try: @@ -851,19 +883,18 @@ def estimate_shift_phase(a: np.ndarray, b: np.ndarray) -> Tuple[float, float, fl def edge_agreement(a: np.ndarray, b: np.ndarray) -> Dict[str, float]: ga = gradient_mag(a) gb = gradient_mag(b) - va = ga.reshape(-1) vb = gb.reshape(-1) if np.std(va) < EPS or np.std(vb) < EPS: corr = 0.0 else: corr = float(np.corrcoef(va, vb)[0, 1]) - dx, dy, resp = estimate_shift_phase(ga, gb) return { "edge_corr": corr, "phase_dx": dx, "phase_dy": dy, + "phase_mag": float(math.hypot(dx, dy)), "phase_response": resp, } @@ -871,49 +902,258 @@ def edge_agreement(a: np.ndarray, b: np.ndarray) -> Dict[str, float]: 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) - - # BGR: RGB edge em verde, RE em vermelho, NIR em azul. overlay = np.zeros((tensor.shape[1], tensor.shape[2], 3), dtype=np.uint8) - overlay[:, :, 1] = e_rgb - overlay[:, :, 2] = e_re - overlay[:, :, 0] = e_nir + overlay[:, :, 1] = e_rgb # verde + overlay[:, :, 2] = e_re # vermelho + overlay[:, :, 0] = e_nir # azul return overlay # ============================================================ -# Visualizações por amostra +# Core telemetry extraction # ============================================================ -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): - cls_id = int(cls_id) - if cls_id in palette: - out[mask == cls_id] = palette[cls_id] - elif cls_id in class_map: - rng = np.random.default_rng(cls_id) - out[mask == cls_id] = rng.integers(40, 220, size=3) - return out +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 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 +def extract_core_metrics(core_telemetry: Dict[str, Any]) -> Dict[str, Any]: + row: Dict[str, Any] = {} + rad = core_telemetry.get("radiometric_normalization") or {} + patch = core_telemetry.get("patch_normalization") or {} + quality = core_telemetry.get("frame_quality") or {} + fusion = core_telemetry.get("fusion") or {} + + if isinstance(rad, dict): + row["radnorm_present"] = True + row["radnorm_enabled"] = bool(rad.get("enabled", False)) + row["radnorm_applied"] = bool(rad.get("applied", False)) + row["radnorm_warning_count"] = len(rad.get("warnings", []) or []) + row["radnorm_warnings"] = " ; ".join([str(x) for x in (rad.get("warnings", []) or [])]) + summary = rad.get("summary", {}) if isinstance(rad.get("summary"), dict) else {} + row["radnorm_scale_min"] = safe_float(summary.get("scale_min"), 0.0) + row["radnorm_scale_max"] = safe_float(summary.get("scale_max"), 0.0) + row["radnorm_scale_mean"] = safe_float(summary.get("scale_mean"), 0.0) + by_role = rad.get("by_role", {}) if isinstance(rad.get("by_role"), dict) else {} + for role in ("rgb", "re", "nir"): + rr = by_role.get(role, {}) if isinstance(by_role.get(role), dict) else {} + row[f"radnorm_{role}_scale"] = safe_float(rr.get("scale_applied"), 0.0) + row[f"radnorm_{role}_actual_factor"] = safe_float(rr.get("actual_factor"), 0.0) + row[f"radnorm_{role}_reference_factor"] = safe_float(rr.get("reference_factor"), 0.0) + else: + row["radnorm_present"] = False + + if isinstance(patch, dict): + row["patchnorm_present"] = True + row["patchnorm_enabled"] = bool(patch.get("enabled", False)) + row["patchnorm_applied"] = bool(patch.get("applied", False)) + row["patchnorm_warning_count"] = len(patch.get("warnings", []) or []) + row["patchnorm_warnings"] = " ; ".join([str(x) for x in (patch.get("warnings", []) or [])]) + summ = patch.get("summary", {}) if isinstance(patch.get("summary"), dict) else {} + row["patchnorm_valid_channel_count"] = safe_int(summ.get("valid_channel_count"), 0) + row["patchnorm_max_white_sat_pct"] = safe_float(summ.get("max_white_sat_pct"), 0.0) + row["patchnorm_max_would_clip_pct"] = safe_float(summ.get("max_would_clip_pct"), 0.0) + row["patchnorm_scale_min_applied"] = safe_float(summ.get("scale_min_applied"), 0.0) + row["patchnorm_scale_max_applied"] = safe_float(summ.get("scale_max_applied"), 0.0) + else: + row["patchnorm_present"] = False + + if isinstance(quality, dict): + row["core_quality_present"] = True + row["core_quality_status"] = quality.get("status", "") + row["core_usable_for_training"] = bool(quality.get("usable_for_training", True)) + row["core_quality_reasons"] = " ; ".join([str(x) for x in (quality.get("reasons", []) or [])]) + metrics = quality.get("metrics", {}) if isinstance(quality.get("metrics"), dict) else {} + row["core_max_tensor_sat_pct"] = safe_float(metrics.get("max_tensor_sat_pct"), 0.0) + row["core_max_tensor_dark_pct"] = safe_float(metrics.get("max_tensor_dark_pct"), 0.0) + row["core_max_tensor_over_1_pct"] = safe_float(metrics.get("max_tensor_over_1_pct"), 0.0) + row["core_max_tensor_under_0_pct"] = safe_float(metrics.get("max_tensor_under_0_pct"), 0.0) + else: + row["core_quality_present"] = False + + if isinstance(fusion, dict): + row["fusion_present"] = True + row["fusion_ref_shape"] = json.dumps(fusion.get("ref_shape"), ensure_ascii=False) + row["fusion_output_shape"] = json.dumps(fusion.get("output_shape"), ensure_ascii=False) + row["fusion_crop_box"] = json.dumps(fusion.get("crop_box"), ensure_ascii=False) + row["fusion_crop_applied"] = bool(fusion.get("crop_applied", False)) + row["fusion_direct_fast"] = bool(fusion.get("direct_fusion_fast", False)) + profiles = fusion.get("homography_profiles_used", {}) + if isinstance(profiles, dict): + for role in ("re", "nir"): + pr = profiles.get(role, {}) if isinstance(profiles.get(role), dict) else {} + row[f"homography_{role}_profile"] = pr.get("profile", "") + row[f"homography_{role}_calib_size"] = json.dumps(pr.get("calib_size"), ensure_ascii=False) + perf = fusion.get("perf", {}) if isinstance(fusion.get("perf"), dict) else {} + row["fusion_total_ms"] = safe_float(perf.get("total_ms"), 0.0) + row["fusion_radnorm_ms"] = safe_float(perf.get("radnorm_ms"), 0.0) + row["fusion_flat_ms"] = safe_float(perf.get("flat_ms"), 0.0) + row["fusion_spatial_direct_ms"] = safe_float(perf.get("spatial_direct_ms"), 0.0) + else: + row["fusion_present"] = False + + return row + + +# ============================================================ +# Saúde por amostra / gates +# ============================================================ + + +@dataclass +class HealthResult: + score: float + status: str + approved: bool + warnings: List[str] + errors: List[str] + notes: List[str] + + +def evaluate_sample_health( + row: Dict[str, Any], + class_map: Dict[int, str], + args, +) -> HealthResult: + score = 100.0 + warnings: List[str] = [] + errors: List[str] = [] + notes: List[str] = [] + + def warn(msg: str, penalty: float): + nonlocal score + warnings.append(msg) + score -= penalty + + def err(msg: str, penalty: float): + nonlocal score + errors.append(msg) + score -= penalty + + if not bool(row.get("tensor_valid", True)): + err("tensor_invalid", 60) + + if not bool(row.get("mask_found", False)): + err("missing_mask", 35) + elif safe_float(row.get("mask_valid_pct"), 0.0) < args.min_valid_mask_pct: + err(f"mask_valid_pct_low:{safe_float(row.get('mask_valid_pct')):.2f}%", 25) + + for ch in CHANNELS: + sat = safe_float(row.get(f"{ch}_sat_pct"), 0.0) + dark = safe_float(row.get(f"{ch}_dark_pct"), 0.0) + dyn = safe_float(row.get(f"{ch}_p95_p05"), 0.0) + mean = safe_float(row.get(f"{ch}_mean"), 0.0) + over = safe_float(row.get(f"{ch}_over_1_pct"), 0.0) + under = safe_float(row.get(f"{ch}_under_0_pct"), 0.0) + + if sat >= args.bad_sat_pct: + err(f"{ch}:sat_bad:{sat:.2f}%", 18) + elif sat >= args.warn_sat_pct: + warn(f"{ch}:sat_warn:{sat:.2f}%", 5) + + if dark >= args.bad_dark_pct: + err(f"{ch}:dark_bad:{dark:.2f}%", 18) + elif dark >= args.warn_dark_pct: + warn(f"{ch}:dark_warn:{dark:.2f}%", 5) + + if dyn <= args.bad_low_dynamic: + err(f"{ch}:dynamic_bad:{dyn:.4f}", 14) + elif dyn <= args.warn_low_dynamic: + warn(f"{ch}:dynamic_warn:{dyn:.4f}", 4) + + if mean <= args.bad_mean_low or mean >= args.bad_mean_high: + warn(f"{ch}:mean_extreme:{mean:.4f}", 4) + + if over > 0.01: + warn(f"{ch}:over_1:{over:.3f}%", 4) + if under > 0.01: + warn(f"{ch}:under_0:{under:.3f}%", 4) + + # Alinhamento: alerta, não juiz absoluto. Canais espectrais podem ter textura diferente. + for role in ("re", "nir"): + mag = safe_float(row.get(f"{role}_phase_mag"), 0.0) + corr = safe_float(row.get(f"{role}_edge_corr"), 0.0) + resp = safe_float(row.get(f"{role}_phase_response"), 0.0) + if mag >= args.bad_shift_px and resp >= args.min_phase_response_for_shift_gate: + err(f"{role}:shift_bad:{mag:.2f}px resp={resp:.3f}", 16) + elif mag >= args.warn_shift_px and resp >= args.min_phase_response_for_shift_gate: + warn(f"{role}:shift_warn:{mag:.2f}px resp={resp:.3f}", 5) + if corr < args.bad_edge_corr: + warn(f"{role}:edge_corr_low:{corr:.3f}", 4) + elif corr < args.warn_edge_corr: + notes.append(f"{role}:edge_corr_warn:{corr:.3f}") + + # Core telemetry gates. + if row.get("radnorm_present") is True: + if bool(row.get("radnorm_enabled")) and not bool(row.get("radnorm_applied")): + if args.require_radnorm_applied: + err("radnorm_enabled_not_applied", 25) + else: + warn("radnorm_enabled_not_applied", 8) + if safe_int(row.get("radnorm_warning_count"), 0) > 0: + warn(f"radnorm_warnings:{row.get('radnorm_warnings', '')}", 6) + for role in ("rgb", "re", "nir"): + s = safe_float(row.get(f"radnorm_{role}_scale"), 0.0) + if s > 0: + if s < args.radnorm_scale_min_ok or s > args.radnorm_scale_max_ok: + warn(f"radnorm_{role}_scale_out:{s:.3f}", 6) + + if row.get("patchnorm_present") is True: + if bool(row.get("patchnorm_enabled")) and not bool(row.get("patchnorm_applied")): + warn("patchnorm_enabled_not_applied", 6) + if safe_int(row.get("patchnorm_warning_count"), 0) > 0: + warn(f"patchnorm_warnings:{row.get('patchnorm_warnings', '')}", 5) + if safe_float(row.get("patchnorm_max_would_clip_pct"), 0.0) >= args.bad_patch_clip_pct: + err(f"patchnorm_clip_bad:{safe_float(row.get('patchnorm_max_would_clip_pct')):.2f}%", 15) + + if row.get("core_quality_present") is True: + if str(row.get("core_quality_status", "")).lower() == "bad": + err(f"core_quality_bad:{row.get('core_quality_reasons','')}", 35) + elif str(row.get("core_quality_status", "")).lower() == "warning": + warn(f"core_quality_warning:{row.get('core_quality_reasons','')}", 6) + if row.get("core_usable_for_training") is False: + err("core_not_usable_for_training", 35) + + # Tamanho mínimo de classes relevantes, quando existir no grupo/nome. + total_px = max(1, safe_int(row.get("H"), 0) * safe_int(row.get("W"), 0)) + group = str(row.get("group", "")).lower() + if args.clean_min_target_pct > 0: + if "cana" in group: + pct = 100.0 * safe_float(row.get("pixels_cana"), 0.0) / total_px + if pct < args.clean_min_target_pct * 100.0: + warn(f"cana_pixels_low:{pct:.4f}%", 4) + if "erva" in group: + pct = 100.0 * safe_float(row.get("pixels_erva"), 0.0) / total_px + if pct < args.clean_min_target_pct * 100.0: + warn(f"erva_pixels_low:{pct:.4f}%", 4) + + score = float(max(0.0, min(100.0, score))) + if errors: + status = "bad" + elif score < args.health_warning_score or warnings: + status = "warning" + else: + status = "good" + + approved = status != "bad" + if args.reject_warnings: + approved = status == "good" + + return HealthResult(score=score, status=status, approved=approved, warnings=warnings, errors=errors, notes=notes) + + +# ============================================================ +# Visuais por amostra +# ============================================================ def make_sample_visual( @@ -922,6 +1162,7 @@ def make_sample_visual( mask: Optional[np.ndarray], class_map: Dict[int, str], sample_stats: Dict[str, Any], + health: Optional[HealthResult] = None, ) -> np.ndarray: features = compute_feature_maps(tensor) h, w = tensor.shape[1], tensor.shape[2] @@ -936,23 +1177,24 @@ def make_sample_visual( ratio = apply_colormap_gray(features["NIR_over_RE"], stretch=True) edge = make_edge_overlay(tensor) mask_bgr = colorize_mask(mask, class_map, (h, w)) - overlay_mask = rgb.copy() if mask is not None: overlay_mask = cv2.addWeighted(rgb, 0.65, mask_bgr, 0.35, 0) - mask_edges = mask_edges_on_rgb(rgb, mask) align = sample_stats.get("alignment", {}) re_align = align.get("RGBgray_vs_RE", {}) nir_align = align.get("RGBgray_vs_NIR", {}) + score_txt = "" + if health is not None: + score_txt = f"health={health.status} score={health.score:.1f}" panels = [ - ("RGB tensor", rgb, sample_name), - ("RGB stretch", rgb_stretch, "visual apenas para contraste"), - ("GT mask", mask_bgr, f"unique={mask_unique_summary(mask)}"), - ("Mask overlay", overlay_mask, "GT over RGB"), - ("Mask edges", mask_edges, "GT edges over RGB"), + ("RGB tensor", rgb, f"{sample_name} | {score_txt}"), + ("RGB stretch", rgb_stretch, "visual p1-p99"), + ("GT mask", mask_bgr, f"unique={mask_unique_summary(mask, class_map)}"), + ("Mask overlay", overlay_mask, "GT sobre RGB"), + ("Mask edges", mask_edges, "bordas GT sobre RGB"), ("RE", re, "canal 3 | stretch p1-p99"), ("NIR", nir, "canal 4 | stretch p1-p99"), ("NDVI", ndvi, "(NIR-R)/(NIR+R)"), @@ -962,318 +1204,37 @@ def make_sample_visual( ( "Edge overlay", edge, - f"G=RGB | R=RE | B=NIR | REcorr={safe_float(re_align.get('edge_corr')):.3f} NIRcorr={safe_float(nir_align.get('edge_corr')):.3f}", + f"G=RGB R=RE B=NIR | REcorr={safe_float(re_align.get('edge_corr')):.3f} NIRcorr={safe_float(nir_align.get('edge_corr')):.3f}", ), ] - return make_grid(panels, panel_w=380) + return make_grid(panels, panel_w=380, cols=3) # ============================================================ -# Auditoria principal +# Fixed dataset e revisão manual # ============================================================ -def resolve_rejected_preview_root(fixed_root: Path) -> Path: - # fixed_root normalmente é dataset/fixed/group - # queremos dataset/fixed/rejected_previews - if fixed_root.name == "group": - return fixed_root.parent / "rejected_previews" - return fixed_root / "_rejected_previews" - - -def find_original_preview(dataset_root: Path, real_stem: str) -> Optional[Path]: - previews_dir = dataset_root / "previews" - if not previews_dir.is_dir(): - return None - - for ext in (".png", ".jpg", ".jpeg", ".webp"): - p = previews_dir / f"{real_stem}{ext}" - if p.exists(): - return p - - # fallback caso tenha sufixo no nome - matches = [] - for ext in (".png", ".jpg", ".jpeg", ".webp"): - matches.extend(previews_dir.glob(f"{real_stem}*{ext}")) - - return matches[0] if matches else None - - -def resize_preview_max_width(img: np.ndarray, max_width: int) -> np.ndarray: - if img is None or img.size == 0: - return img - if max_width <= 0 or img.shape[1] <= max_width: - return img - - scale = max_width / img.shape[1] - new_w = int(img.shape[1] * scale) - new_h = int(img.shape[0] * scale) - return cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA) - - -def make_rejected_filename(row: Dict[str, Any]) -> str: - sample = str(row.get("sample", "sample")) - reasons = str(row.get("reject_reasons", "")) - - # Nome curto com principais motivos. - tags = [] - if "abs_shift" in reasons: - tags.append("absShift") - if "outlier" in reasons: - tags.append("outlier") - if "low_edge_corr" in reasons: - tags.append("lowCorr") - if "too_small" in reasons: - tags.append("smallTarget") - if "missing_mask" in reasons: - tags.append("noMask") - - tag = "_".join(tags) if tags else "rejected" - return f"{sample}__{tag}.png" - - -def draw_rejected_header(img: np.ndarray, row: Dict[str, Any]) -> np.ndarray: - if img is None or img.size == 0: - return img - - out = img.copy() - h, w = out.shape[:2] - - header_h = 92 - canvas = np.zeros((h + header_h, w, 3), dtype=np.uint8) - canvas[:header_h, :] = (20, 20, 20) - canvas[header_h:, :] = out - - sample = cv_text(row.get("sample", "")) - reasons = cv_text(row.get("reject_reasons", "")) - - re_mag = safe_float(row.get("re_shift_mag")) - nir_mag = safe_float(row.get("nir_shift_mag")) - max_dev = safe_float(row.get("max_shift_dev_px")) - re_corr = safe_float(row.get("re_edge_corr")) - nir_corr = safe_float(row.get("nir_edge_corr")) - - line1 = sample[:120] - line2 = f"RE={re_mag:.1f}px NIR={nir_mag:.1f}px dev={max_dev:.1f}px corrRE={re_corr:.3f} corrNIR={nir_corr:.3f}" - line3 = reasons[:150] - - cv2.putText(canvas, line1, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.58, (0, 255, 255), 2, cv2.LINE_AA) - cv2.putText(canvas, line2, (10, 52), cv2.FONT_HERSHEY_SIMPLEX, 0.50, (255, 255, 255), 1, cv2.LINE_AA) - cv2.putText(canvas, line3, (10, 78), cv2.FONT_HERSHEY_SIMPLEX, 0.43, (120, 220, 255), 1, cv2.LINE_AA) - - return canvas - - -def save_rejected_preview( - row: Dict[str, Any], - fixed_root: Path, - args, - tensor: Optional[np.ndarray] = None, - mask: Optional[np.ndarray] = None, - class_map: Optional[Dict[int, str]] = None, -): - rejected_root = resolve_rejected_preview_root(fixed_root) - group = str(row.get("group", "unknown")) - dataset_root = Path(str(row["dataset_root"])) - real_stem = str(row["real_stem"]) - - dst_dir = ensure_dir(rejected_root / group) - dst_path = dst_dir / make_rejected_filename(row) - - source_mode = str(args.rejected_preview_source) - - img = None - - # 1) preview original, se existir. - if source_mode in ("auto", "preview"): - preview_path = find_original_preview(dataset_root, real_stem) - if preview_path is not None: - img = cv2.imread(str(preview_path), cv2.IMREAD_COLOR) - - # 2) painel completo do audit, se já existir e o usuário pediu. - # Aqui é útil quando --save-visuals também estiver ligado. - if img is None and source_mode in ("auto", "audit_panel"): - # Procura por qualquer painel visual que contenha o sample no nome. - # Fica em out_dir/visuals, mas não temos out_dir aqui. Então esse modo - # fica mais útil se você passar painel diretamente no futuro. - pass - - # 3) RGB reconstruído do tensor, se disponível. - if img is None and tensor is not None and source_mode in ("auto", "rgb_tensor", "audit_panel"): - img = rgb_from_tensor(tensor, stretch=False) - - if mask is not None and class_map is not None: - mask_bgr = colorize_mask(mask, class_map, (tensor.shape[1], tensor.shape[2])) - img = cv2.addWeighted(img, 0.70, mask_bgr, 0.30, 0) - - if img is None: - # Último fallback: imagem preta com cabeçalho, para não perder rastreabilidade. - 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 robust_median_mad(values: List[float]) -> Tuple[float, float]: - arr = np.asarray([v for v in values if np.isfinite(v)], dtype=np.float32) - if arr.size == 0: - return 0.0, 1.0 - - med = float(np.median(arr)) - mad = float(np.median(np.abs(arr - med))) - - # Evita divisão por zero em grupos muito estáveis. - if mad < 0.5: - mad = 0.5 - - return med, mad - - -def safe_copy_file(src: Path, dst: Path): - if not src.exists(): - return - ensure_dir(dst.parent) - shutil.copy2(str(src), str(dst)) - - def resolve_fixed_out_root(dataset_roots: List[Path], args) -> Path: if args.fixed_out_root: return Path(args.fixed_out_root).resolve() - - # Esperado: - # dataset/original/group/chao - # dataset/original/group/chao_cana - # - # Queremos: - # dataset/fixed/group first = dataset_roots[0].resolve() - - # Se o root é .../original/group/chao, sobe 3: chao -> group -> original -> dataset - if first.parent.name == "group" and first.parent.parent.name == "original": - dataset_base = first.parent.parent.parent + 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" - - # Fallback seguro. return first.parent / "fixed" / "group" -def sample_shift_metrics(row: Dict[str, Any]) -> Dict[str, float]: - re_dx = safe_float(row.get("re_phase_dx")) - re_dy = safe_float(row.get("re_phase_dy")) - nir_dx = safe_float(row.get("nir_phase_dx")) - nir_dy = safe_float(row.get("nir_phase_dy")) - - return { - "re_dx": re_dx, - "re_dy": re_dy, - "nir_dx": nir_dx, - "nir_dy": nir_dy, - "re_mag": math.hypot(re_dx, re_dy), - "nir_mag": math.hypot(nir_dx, nir_dy), - } +def resolve_rejected_preview_root(fixed_root: Path) -> Path: + return fixed_root.parent / "rejected_previews" if fixed_root.name == "group" else fixed_root / "_rejected_previews" -def build_group_shift_baselines(sample_rows: List[Dict[str, Any]]) -> Dict[str, Dict[str, Tuple[float, float]]]: - grouped: Dict[str, Dict[str, List[float]]] = {} - - for row in sample_rows: - group = str(row.get("group", "unknown")) - grouped.setdefault(group, { - "re_dx": [], - "re_dy": [], - "nir_dx": [], - "nir_dy": [], - "re_mag": [], - "nir_mag": [], - }) - - m = sample_shift_metrics(row) - for k, v in m.items(): - grouped[group][k].append(v) - - baselines: Dict[str, Dict[str, Tuple[float, float]]] = {} - for group, vals in grouped.items(): - baselines[group] = {} - for k, arr in vals.items(): - baselines[group][k] = robust_median_mad(arr) - - return baselines - - -def classify_sample_for_training( - row: Dict[str, Any], - baselines: Dict[str, Dict[str, Tuple[float, float]]], - class_map: Dict[int, str], - args, -) -> Tuple[bool, List[str], Dict[str, float]]: - reasons: List[str] = [] - metrics = sample_shift_metrics(row) - - group = str(row.get("group", "unknown")) - base = baselines.get(group, {}) - - if not bool(row.get("mask_found")): - reasons.append("missing_mask") - - # Shift absoluto extremo. - if metrics["re_mag"] > args.clean_max_abs_shift_px: - reasons.append(f"re_abs_shift_high:{metrics['re_mag']:.2f}") - if metrics["nir_mag"] > args.clean_max_abs_shift_px: - reasons.append(f"nir_abs_shift_high:{metrics['nir_mag']:.2f}") - - # Desvio robusto por grupo. - max_dev = 0.0 - for k in ("re_dx", "re_dy", "nir_dx", "nir_dy", "re_mag", "nir_mag"): - med, mad = base.get(k, (0.0, 1.0)) - dev_abs = abs(metrics[k] - med) - max_dev = max(max_dev, dev_abs) - - if dev_abs > args.clean_max_dev_shift_px: - reasons.append(f"{k}_outlier:val={metrics[k]:.2f},med={med:.2f},dev={dev_abs:.2f}") - - re_corr = safe_float(row.get("re_edge_corr")) - nir_corr = safe_float(row.get("nir_edge_corr")) - low_corr = re_corr < args.clean_min_edge_corr or nir_corr < args.clean_min_edge_corr - shift_bad = max_dev > args.clean_max_dev_shift_px * 0.75 - - if low_corr: - if args.clean_reject_low_corr_only_if_shift_bad: - if shift_bad: - reasons.append(f"low_edge_corr_with_shift:re={re_corr:.3f},nir={nir_corr:.3f}") - else: - reasons.append(f"low_edge_corr:re={re_corr:.3f},nir={nir_corr:.3f}") - - # Filtro semântico opcional por presença mínima da classe alvo. - # Mantém chão puro sem exigir cana/erva. - min_target_pct = float(args.clean_min_target_pct) - if min_target_pct > 0: - h = int(row.get("H", 0) or 0) - w = int(row.get("W", 0) or 0) - total_px = max(1, h * w) - - group_lower = group.lower() - if "cana" in group_lower: - pct_cana = safe_float(row.get("pixels_cana")) / total_px - if pct_cana < min_target_pct: - reasons.append(f"cana_too_small:{pct_cana:.5f}") - - if "erva" in group_lower: - pct_erva = safe_float(row.get("pixels_erva")) / total_px - if pct_erva < min_target_pct: - reasons.append(f"erva_too_small:{pct_erva:.5f}") - - approved = len(reasons) == 0 - - extra = { - "re_shift_mag": metrics["re_mag"], - "nir_shift_mag": metrics["nir_mag"], - "max_shift_dev_px": max_dev, - "re_edge_corr": re_corr, - "nir_edge_corr": nir_corr, - } - - return approved, reasons, extra +def safe_copy_file(src: Path, dst: Path): + if not src or not Path(src).exists(): + return + ensure_dir(dst.parent) + shutil.copy2(str(src), str(dst)) def copy_sample_to_fixed(row: Dict[str, Any], fixed_root: Path, copy_previews: bool): @@ -1281,400 +1242,402 @@ def copy_sample_to_fixed(row: Dict[str, Any], fixed_root: Path, copy_previews: b group = str(row["group"]) real_stem = str(row["real_stem"]) meta_path = Path(str(row["meta_path"])) - dst_group = fixed_root / group - # Copia meta. safe_copy_file(meta_path, dst_group / "metas" / meta_path.name) - # Copia máscara. 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) - # Copia payloads do meta. - meta = load_json(meta_path) - - # Caso raw_native_multi: vários payloads. - saved_payload_paths = meta.get("saved_payload_paths", {}) or {} - if saved_payload_paths: - for _, fname in saved_payload_paths.items(): - src = dataset_root / "bins" / Path(fname).name - safe_copy_file(src, dst_group / "bins" / src.name) - - # Caso payload único. - saved_payload_path = meta.get("saved_payload_path") - if saved_payload_path: - src = dataset_root / "bins" / Path(saved_payload_path).name - safe_copy_file(src, dst_group / "bins" / src.name) - - # Fallback usando payload_path do CSV. payload_path = Path(str(row.get("payload_path", ""))) if str(payload_path) and payload_path.exists(): - safe_copy_file(payload_path, dst_group / "bins" / payload_path.name) + if payload_path.suffix.lower() == ".npy" or "tensors" in payload_path.parts: + safe_copy_file(payload_path, dst_group / "tensors" / payload_path.name) + else: + safe_copy_file(payload_path, dst_group / "bins" / payload_path.name) + + # Copia todos payloads RAW multi do meta, se existirem. + try: + meta = load_json(meta_path) + saved_payload_paths = meta.get("saved_payload_paths", {}) or {} + if isinstance(saved_payload_paths, dict): + for _, fname in saved_payload_paths.items(): + src = dataset_root / "bins" / Path(str(fname)).name + safe_copy_file(src, dst_group / "bins" / src.name) + saved_tensor_path = meta.get("saved_tensor_path") + if saved_tensor_path: + src = dataset_root / Path(str(saved_tensor_path)) + if src.exists(): + safe_copy_file(src, dst_group / "tensors" / src.name) + except Exception: + pass - # Preview opcional. if copy_previews: - previews_dir = dataset_root / "previews" - if previews_dir.is_dir(): - for ext in (".png", ".jpg", ".jpeg", ".webp"): - src = previews_dir / f"{real_stem}{ext}" - if src.exists(): - safe_copy_file(src, dst_group / "previews" / src.name) + for ext in (".png", ".jpg", ".jpeg", ".webp"): + src = dataset_root / "previews" / f"{real_stem}{ext}" + if src.exists(): + safe_copy_file(src, dst_group / "previews" / src.name) + + +def find_original_preview(dataset_root: Path, real_stem: str) -> Optional[Path]: + previews_dir = dataset_root / "previews" + for ext in (".png", ".jpg", ".jpeg", ".webp"): + p = previews_dir / f"{real_stem}{ext}" + if p.exists(): + return p + if previews_dir.is_dir(): + matches: List[Path] = [] + for ext in (".png", ".jpg", ".jpeg", ".webp"): + matches.extend(previews_dir.glob(f"{real_stem}*{ext}")) + if matches: + return matches[0] + return None + + +def resize_preview_max_width(img: np.ndarray, max_width: int) -> np.ndarray: + if img is None or img.size == 0 or max_width <= 0 or img.shape[1] <= max_width: + return img + scale = max_width / img.shape[1] + return cv2.resize(img, (int(img.shape[1] * scale), int(img.shape[0] * scale)), interpolation=cv2.INTER_AREA) + + +def draw_rejected_header(img: np.ndarray, row: Dict[str, Any]) -> np.ndarray: + out = img.copy() + h, w = out.shape[:2] + header_h = 116 + canvas = np.zeros((h + header_h, w, 3), dtype=np.uint8) + canvas[:header_h, :] = (20, 20, 20) + canvas[header_h:, :] = out + line1 = cv_text(str(row.get("sample", "")))[:140] + line2 = f"status={row.get('health_status','')} score={safe_float(row.get('health_score')):.1f} approved={row.get('approved_for_training','')}" + line3 = cv_text(str(row.get("reject_reasons", row.get("health_errors", ""))))[:170] + line4 = cv_text(str(row.get("health_warnings", "")))[:170] + cv2.putText(canvas, line1, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.58, (0, 255, 255), 2, cv2.LINE_AA) + cv2.putText(canvas, line2, (10, 52), cv2.FONT_HERSHEY_SIMPLEX, 0.50, (255, 255, 255), 1, cv2.LINE_AA) + cv2.putText(canvas, line3, (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.43, (120, 220, 255), 1, cv2.LINE_AA) + cv2.putText(canvas, line4, (10, 105), cv2.FONT_HERSHEY_SIMPLEX, 0.40, (160, 160, 255), 1, cv2.LINE_AA) + return canvas + + +def save_rejected_preview(row: Dict[str, Any], fixed_root: Path, args, tensor=None, mask=None, class_map=None, audit_panel=None): + rejected_root = resolve_rejected_preview_root(fixed_root) + group = str(row.get("group", "unknown")) + dataset_root = Path(str(row.get("dataset_root", "."))) + real_stem = str(row.get("real_stem", "sample")) + dst_dir = ensure_dir(rejected_root / group) + reason_tag = str(row.get("health_status", "rejected")) + dst_path = dst_dir / f"{row.get('sample', real_stem)}__{reason_tag}.png" + + img = None + mode = str(args.rejected_preview_source) + if mode in ("auto", "audit_panel") and audit_panel is not None: + img = audit_panel + if img is None and mode in ("auto", "preview"): + p = find_original_preview(dataset_root, real_stem) + if p is not None: + img = cv2.imread(str(p), cv2.IMREAD_COLOR) + if img is None and tensor is not None and mode in ("auto", "rgb_tensor", "audit_panel"): + img = rgb_from_tensor(tensor, stretch=False) + if mask is not None and class_map is not None: + mask_bgr = colorize_mask(mask, class_map, (tensor.shape[1], tensor.shape[2])) + img = cv2.addWeighted(img, 0.70, mask_bgr, 0.30, 0) + if img is None: + img = np.zeros((360, 640, 3), dtype=np.uint8) + img = resize_preview_max_width(img, int(args.rejected_preview_max_width)) + img = draw_rejected_header(img, row) + cv2.imwrite(str(dst_path), img) def manual_fit_to_width(img: np.ndarray, max_width: int) -> np.ndarray: if max_width <= 0 or img.shape[1] <= max_width: return img scale = max_width / img.shape[1] - return cv2.resize( - img, - (int(img.shape[1] * scale), int(img.shape[0] * scale)), - interpolation=cv2.INTER_AREA, - ) + return cv2.resize(img, (int(img.shape[1] * scale), int(img.shape[0] * scale)), interpolation=cv2.INTER_AREA) def draw_manual_review_bar(canvas: np.ndarray, row: Dict[str, Any], idx: int, total: int) -> np.ndarray: - """ - Adiciona uma faixa superior com comandos do modo manual. - """ h, w = canvas.shape[:2] - bar_h = 86 + bar_h = 112 out = np.zeros((h + bar_h, w, 3), dtype=np.uint8) out[:bar_h, :] = (18, 18, 18) out[bar_h:, :] = canvas - - sample = cv_text(row.get("sample", "")) - group = cv_text(row.get("group", "unknown")) - warnings_count = int(row.get("warnings_count", 0) or 0) - - line1 = f"[{idx}/{total}] group={group} | warnings={warnings_count} | {sample}" - line2 = "A/ENTER = aprovar | R/DEL/BACKSPACE = rejeitar | S = pular | Q/ESC = finalizar parcial" - line3 = cv_text(str(row.get("warning_text", "")))[:170] - - cv2.putText(out, line1[:170], (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.58, (0, 255, 255), 2, cv2.LINE_AA) - cv2.putText(out, line2, (10, 53), cv2.FONT_HERSHEY_SIMPLEX, 0.52, (255, 255, 255), 1, cv2.LINE_AA) + line1 = f"[{idx}/{total}] group={row.get('group')} | status={row.get('health_status')} score={safe_float(row.get('health_score')):.1f} | {row.get('sample')}" + line2 = "A/ENTER aprova | R rejeita | S pula | Q/ESC finaliza | 1 alinhamento | 2 mascara | 3 saturacao | 4 escuro | 5 classe | 6 duplicado" + line3 = cv_text(str(row.get("health_errors", "")))[:180] + line4 = cv_text(str(row.get("health_warnings", "")))[:180] + cv2.putText(out, cv_text(line1)[:180], (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.56, (0, 255, 255), 2, cv2.LINE_AA) + cv2.putText(out, cv_text(line2), (10, 53), cv2.FONT_HERSHEY_SIMPLEX, 0.49, (255, 255, 255), 1, cv2.LINE_AA) if line3: - cv2.putText(out, line3, (10, 78), cv2.FONT_HERSHEY_SIMPLEX, 0.42, (120, 220, 255), 1, cv2.LINE_AA) - + cv2.putText(out, line3, (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.42, (120, 220, 255), 1, cv2.LINE_AA) + if line4: + cv2.putText(out, line4, (10, 104), cv2.FONT_HERSHEY_SIMPLEX, 0.38, (160, 160, 255), 1, cv2.LINE_AA) return out -def manual_review_decision( - canvas: np.ndarray, - row: Dict[str, Any], - idx: int, - total: int, - args, -) -> str: - """ - Mostra o painel da amostra e espera a decisão humana. - - Retorna: - approved - rejected - skipped - quit - """ +def manual_review_decision(canvas: np.ndarray, row: Dict[str, Any], idx: int, total: int, args) -> Tuple[str, str]: window_name = str(args.manual_window_name) view = draw_manual_review_bar(canvas, row, idx, total) view = manual_fit_to_width(view, int(args.manual_window_width)) - cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) cv2.imshow(window_name, view) - + reason_map = { + ord("1"): "manual_reject_alignment", + ord("2"): "manual_reject_mask", + ord("3"): "manual_reject_saturation", + ord("4"): "manual_reject_dark_dynamic", + ord("5"): "manual_reject_wrong_class", + ord("6"): "manual_reject_duplicate_or_bad_frame", + } while True: key = cv2.waitKey(0) & 0xFF - - # A, Enter, Espaço: aprova if key in (ord("a"), ord("A"), 13, 32): - return "approved" - - # R, Delete, Backspace: rejeita + return "approved", "manual_approved" if key in (ord("r"), ord("R"), 8, 127): - return "rejected" - - # S: pula sem copiar para fixed. + return "rejected", "manual_rejected" + if key in reason_map: + return "rejected", reason_map[key] if key in (ord("s"), ord("S")): - return "skipped" - - # Q ou ESC: para o loop e gera relatórios parciais. + return "skipped", "manual_skipped" if key in (ord("q"), ord("Q"), 27): - return "quit" + return "quit", "manual_quit" -def build_fixed_dataset_from_manual_review( - sample_rows: List[Dict[str, Any]], - dataset_roots: List[Path], - args, -): - """ - Cria fixed/group usando decisões humanas coletadas no modo manual. - """ - fixed_root = resolve_fixed_out_root(dataset_roots, args) - ensure_dir(fixed_root) +# ============================================================ +# Diagnóstico global +# ============================================================ - manifest_rows = [] - approved_rows = [] - rejected_rows = [] - skipped_rows = [] - for row in sample_rows: - decision = str(row.get("manual_decision", "")) +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 - out_row = dict(row) - out_row["approved_for_training"] = decision == "approved" - out_row["reject_reasons"] = "manual_rejected" if decision == "rejected" else "" - out_row["manual_review"] = True - manifest_rows.append(out_row) +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 - if decision == "approved": - approved_rows.append(out_row) - copy_sample_to_fixed(out_row, fixed_root, copy_previews=args.clean_copy_previews) - elif decision == "rejected": - rejected_rows.append(out_row) + +def build_global_health(sample_rows: List[Dict[str, Any]], global_summary: Dict[str, Any], class_summary: Dict[str, Any], warnings: List[Dict[str, Any]], args) -> Dict[str, Any]: + n = len(sample_rows) + if n <= 0: + return {"status": "bad", "score": 0.0, "verdict": "Nenhuma amostra processada."} + approved = sum(1 for r in sample_rows if bool(r.get("approved_for_training"))) + bad = sum(1 for r in sample_rows if r.get("health_status") == "bad") + warning = sum(1 for r in sample_rows if r.get("health_status") == "warning") + mean_score = float(np.mean([safe_float(r.get("health_score")) for r in sample_rows])) + approval_pct = 100.0 * approved / n + bad_pct = 100.0 * bad / n + warning_pct = 100.0 * warning / n + + issues = [] + positives = [] + score = mean_score + + if approval_pct < args.dataset_min_approval_pct: + issues.append(f"approval_pct abaixo do mínimo: {approval_pct:.1f}% < {args.dataset_min_approval_pct:.1f}%") + score -= 15 + else: + positives.append(f"approval_pct bom: {approval_pct:.1f}%") + if bad_pct > args.dataset_max_bad_pct: + issues.append(f"bad_pct alto: {bad_pct:.1f}% > {args.dataset_max_bad_pct:.1f}%") + score -= 15 + if len(warnings) > 0: + positives.append(f"auditoria gerou rastreabilidade com {len(warnings)} avisos/exceções") + + for ch in CHANNELS: + st = global_summary.get(ch, {}) + dyn = safe_float(st.get("p95_p05"), 0.0) + sat = safe_float(st.get("sat_pct"), 0.0) + dark = safe_float(st.get("dark_pct"), 0.0) + if dyn < args.warn_low_dynamic: + issues.append(f"{ch}: dinâmica global baixa {dyn:.4f}") + score -= 4 + if sat > args.warn_sat_pct: + issues.append(f"{ch}: saturação global {sat:.2f}%") + score -= 4 + if dark > args.warn_dark_pct: + issues.append(f"{ch}: dark global {dark:.2f}%") + score -= 4 + + separability, ranking = build_separability(class_summary) + if ranking: + top = ranking[0] + if top["avg_effect_size_d"] >= args.min_top_feature_effect_size: + positives.append(f"separabilidade espectral útil: top={top['feature']} d≈{top['avg_effect_size_d']:.2f}") else: - skipped_rows.append(out_row) + issues.append(f"separabilidade espectral fraca: top={top['feature']} d≈{top['avg_effect_size_d']:.2f}") + score -= 8 - summary = { - "schema": "multispec_fixed_dataset_manual_v1", - "fixed_root": str(fixed_root), - "samples_total": len(sample_rows), - "samples_approved": len(approved_rows), - "samples_rejected": len(rejected_rows), - "samples_skipped": len(skipped_rows), - "approval_pct": 100.0 * len(approved_rows) / max(1, len(sample_rows)), - "approved_by_group": {}, - "rejected_by_group": {}, - "skipped_by_group": {}, - "manual_review": { - "enabled": True, - "window_width": args.manual_window_width, - "groups_except": args.groups_except, - }, - "rejected_previews": { - "enabled": bool(args.save_rejected_previews), - "root": str(resolve_rejected_preview_root(fixed_root)), - "source": args.rejected_preview_source, - "max_width": args.rejected_preview_max_width, - }, + score = float(max(0.0, min(100.0, score))) + if bad_pct > args.dataset_max_bad_pct or approval_pct < args.dataset_min_approval_pct: + status = "bad" + verdict = "Dataset NÃO está pronto para campo/treino oficial sem limpeza ou revisão." + elif issues or score < args.dataset_good_score: + status = "warning" + verdict = "Dataset utilizável com cautela; recomenda-se revisar avisos e rejeitados antes do treino oficial." + else: + status = "good" + verdict = "Dataset saudável para treino/campo dentro dos critérios configurados." + + return { + "status": status, + "score": score, + "verdict": verdict, + "samples_total": n, + "samples_approved": approved, + "approval_pct": approval_pct, + "bad_pct": bad_pct, + "warning_pct": warning_pct, + "mean_sample_health_score": mean_score, + "issues": issues, + "positives": positives, + "feature_separability": separability, + "feature_separability_ranking": ranking, } - for name, rows in ( - ("approved_by_group", approved_rows), - ("rejected_by_group", rejected_rows), - ("skipped_by_group", skipped_rows), - ): - for row in rows: - g = str(row.get("group", "unknown")) - summary[name][g] = summary[name].get(g, 0) + 1 - write_csv(fixed_root / "fixed_manifest.csv", manifest_rows) - write_csv(fixed_root / "fixed_approved.csv", approved_rows) - write_csv(fixed_root / "fixed_rejected.csv", rejected_rows) - write_csv(fixed_root / "fixed_skipped.csv", skipped_rows) - write_json(fixed_root / "fixed_summary.json", summary) - - print("\n========== FIXED DATASET MANUAL ==========") - print(f"Saída fixed/group : {fixed_root}") - print(f"Aprovadas : {len(approved_rows)} / {len(sample_rows)}") - print(f"Rejeitadas : {len(rejected_rows)} / {len(sample_rows)}") - print(f"Puladas : {len(skipped_rows)} / {len(sample_rows)}") - print(f"Manifest : {fixed_root / 'fixed_manifest.csv'}") - print("==========================================\n") - - -def build_fixed_dataset_from_audit( - sample_rows: List[Dict[str, Any]], - dataset_roots: List[Path], - class_map: Dict[int, str], - args, -): - fixed_root = resolve_fixed_out_root(dataset_roots, args) - ensure_dir(fixed_root) - - baselines = build_group_shift_baselines(sample_rows) - - manifest_rows = [] - approved_rows = [] - rejected_rows = [] - - for row in sample_rows: - approved, reasons, extra = classify_sample_for_training(row, baselines, class_map, args) - - out_row = dict(row) - out_row.update(extra) - out_row["approved_for_training"] = approved - out_row["reject_reasons"] = " ; ".join(reasons) - - manifest_rows.append(out_row) - - if approved: - approved_rows.append(out_row) - copy_sample_to_fixed(row, fixed_root, copy_previews=args.clean_copy_previews) - else: - rejected_rows.append(out_row) - if args.save_rejected_previews: - save_rejected_preview( - row=out_row, - fixed_root=fixed_root, - args=args, - tensor=None, - mask=None, - class_map=class_map, - ) - - summary = { - "schema": "multispec_fixed_dataset_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)), - "filters": { - "clean_max_abs_shift_px": args.clean_max_abs_shift_px, - "clean_max_dev_shift_px": args.clean_max_dev_shift_px, - "clean_min_edge_corr": args.clean_min_edge_corr, - "clean_min_target_pct": args.clean_min_target_pct, - "clean_reject_low_corr_only_if_shift_bad": args.clean_reject_low_corr_only_if_shift_bad, - }, - "group_shift_baselines": { - group: { - k: { - "median": float(v[0]), - "mad": float(v[1]), - } - for k, v in vals.items() - } - for group, vals in baselines.items() - }, - "approved_by_group": {}, - "rejected_by_group": {}, - "rejected_previews": { - "enabled": bool(args.save_rejected_previews), - "root": str(resolve_rejected_preview_root(fixed_root)), - "source": args.rejected_preview_source, - "max_width": args.rejected_preview_max_width, - }, - } - - for row in approved_rows: - g = str(row.get("group", "unknown")) - summary["approved_by_group"][g] = summary["approved_by_group"].get(g, 0) + 1 - - for row in rejected_rows: - g = str(row.get("group", "unknown")) - summary["rejected_by_group"][g] = summary["rejected_by_group"].get(g, 0) + 1 - - write_csv(fixed_root / "fixed_manifest.csv", manifest_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", summary) - - print("\n========== FIXED DATASET ==========") - print(f"Saída fixed/group : {fixed_root}") - print(f"Aprovadas : {len(approved_rows)} / {len(sample_rows)}") - print(f"Rejeitadas : {len(rejected_rows)} / {len(sample_rows)}") - print(f"Manifest : {fixed_root / 'fixed_manifest.csv'}") - print("===================================\n") +# ============================================================ +# Auditoria principal +# ============================================================ def audit_dataset(args): np.random.seed(args.seed) + t_start = time.time() dataset_roots = find_dataset_roots(Path(args.input_path)) - out_dir = ensure_dir(args.out_dir) - visuals_dir = ensure_dir(out_dir / "visuals") - - class_map = parse_class_map(args.class_map) - - # Monta uma lista única de amostras: (dataset_root, meta_path) - groups_except = parse_csv_set(getattr(args, "groups_except", "")) + groups_except = parse_csv_set(args.groups_except) if groups_except: dataset_roots = [r for r in dataset_roots if r.name.lower() not in groups_except] + out_dir = ensure_dir(args.out_dir) + visuals_dir = ensure_dir(out_dir / "visuals") + class_map = parse_class_map(args.class_map) + entries: List[Tuple[Path, Path]] = [] for root in dataset_roots: for meta_path in list_meta_files(root): entries.append((root, meta_path)) - if args.manual_start_index and args.manual_start_index > 1: entries = entries[int(args.manual_start_index) - 1:] - if args.limit and args.limit > 0: entries = entries[:args.limit] global_acc = RunningFeatureStats() class_acc: Dict[int, RunningFeatureStats] = {cls: RunningFeatureStats() for cls in class_map.keys()} - - sample_rows = [] - by_sample_class_channel_rows = [] - warnings = [] + sample_rows: List[Dict[str, Any]] = [] + by_sample_class_feature_rows: List[Dict[str, Any]] = [] + core_rows: List[Dict[str, Any]] = [] + warnings: List[Dict[str, Any]] = [] core_cache: Dict[Tuple[int, int, str, str], Any] = {} print(f"[INFO] dataset_roots={len(dataset_roots)}") - for root in dataset_roots: - print(f" - {root}") + for r in dataset_roots: + print(f" - {r}") print(f"[INFO] amostras={len(entries)}") - print(f"[INFO] classes={class_map}") print(f"[INFO] out_dir={out_dir}") - if groups_except: - print(f"[INFO] groups_except={sorted(groups_except)}") if args.manual_review: - print("[MANUAL] Teclas: A/ENTER/ESPAÇO aprova | R/DEL/BACKSPACE rejeita | S pula | Q/ESC finaliza parcial") + print("[MANUAL] A/ENTER aprova | R rejeita | S pula | Q sai | 1..6 rejeita com motivo") - manual_stop_requested = False + manual_stop = False for idx, (dataset_root, meta_path) in enumerate(entries, start=1): - # Prefixa com o nome do grupo para evitar colisão de nomes entre subdatasets. group_name = dataset_root.name stem = f"{group_name}__{meta_path.stem}" + canvas = None try: - tensor, meta, payload_path = load_multispec_tensor(meta_path, dataset_root, core_cache) - h, w = tensor.shape[1], tensor.shape[2] - mask_path = resolve_mask_path(dataset_root, meta_path) + load_result = load_multispec_tensor(meta_path, dataset_root, core_cache) + tensor = load_result.tensor + meta = load_result.meta + payload_path = load_result.payload_path + core_telemetry = load_result.core_telemetry + + h, w = int(tensor.shape[1]), int(tensor.shape[2]) + features = compute_feature_maps(tensor) + mask_path = resolve_mask_path(dataset_root, meta_path, meta) mask = load_mask(mask_path, (h, w), args.ignore_index) - features = compute_feature_maps(tensor) + # 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) - # Estatísticas globais da amostra. - sample_feature_stats = {} + sample_feature_stats: Dict[str, Dict[str, float]] = {} for fname, fmap in features.items(): - raw01 = fname in CHANNELS - sample_feature_stats[fname] = calc_stats(fmap.reshape(-1), raw01=raw01) + st = calc_stats(fmap.reshape(-1), raw01=(fname in CHANNELS)) + sample_feature_stats[fname] = st global_acc.add(fname, fmap.reshape(-1), max_samples=args.max_pixels_per_feature) - # Estatísticas por classe da amostra. - class_pixel_counts = {} + class_pixel_counts: Dict[str, int] = {name: 0 for name in class_map.values()} + mask_valid_pct = 0.0 if mask is not None: valid_mask = mask != args.ignore_index + mask_valid_pct = float(np.mean(valid_mask) * 100.0) for cls_id, cls_name in class_map.items(): cm = (mask == cls_id) & valid_mask n = int(np.sum(cm)) class_pixel_counts[cls_name] = n if n < args.min_class_pixels: continue - for fname, fmap in features.items(): vals = fmap[cm] class_acc[cls_id].add(fname, vals, max_samples=args.max_pixels_per_feature) - s = calc_stats(vals, raw01=(fname in CHANNELS)) - by_sample_class_channel_rows.append({ + st = calc_stats(vals, raw01=(fname in CHANNELS)) + by_sample_class_feature_rows.append({ "sample": stem, + "group": group_name, "class_id": cls_id, "class_name": cls_name, "feature": fname, - **s, + **st, }) else: - warnings.append({ - "sample": stem, - "type": "missing_mask", - "message": "Mascara nao encontrada; estatistica por classe ignorada.", - }) + warnings.append({"sample": stem, "type": "missing_mask", "message": "Máscara ausente."}) - # Alinhamento por bordas. 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 = { @@ -1682,35 +1645,9 @@ def audit_dataset(args): "RGBgray_vs_NIR": edge_agreement(rgb_gray, nir), "RE_vs_NIR": edge_agreement(re, nir), } + core_metrics = extract_core_metrics(core_telemetry) - # Heurísticas de alerta. - sample_warn = [] - for ch in CHANNELS: - st = sample_feature_stats[ch] - if st["sat_pct"] > args.warn_sat_pct: - sample_warn.append(f"{ch}: saturação alta {st['sat_pct']:.2f}%") - if st["dark_pct"] > args.warn_dark_pct: - sample_warn.append(f"{ch}: pixels escuros alto {st['dark_pct']:.2f}%") - if st["p95_p05"] < args.warn_low_dynamic: - sample_warn.append(f"{ch}: baixa dinâmica p95-p05={st['p95_p05']:.4f}") - - for pair_name, al in alignment.items(): - dx = abs(safe_float(al.get("phase_dx"))) - dy = abs(safe_float(al.get("phase_dy"))) - corr = safe_float(al.get("edge_corr")) - if dx > args.warn_shift_px or dy > args.warn_shift_px: - sample_warn.append(f"{pair_name}: possível shift residual dx={dx:.2f}, dy={dy:.2f}") - if corr < args.warn_edge_corr: - sample_warn.append(f"{pair_name}: baixa correlação de borda {corr:.3f}") - - if sample_warn: - warnings.append({ - "sample": stem, - "type": "sample_warning", - "messages": sample_warn, - }) - - row = { + row: Dict[str, Any] = { "idx": idx, "sample": stem, "group": group_name, @@ -1718,90 +1655,118 @@ def audit_dataset(args): "dataset_root": str(dataset_root), "meta_path": str(meta_path), "payload_path": str(payload_path), + "payload_sha1_head": sha1_short(payload_path), + "source_kind": load_result.source_kind, "mask_path": str(mask_path) if mask_path else "", "mask_found": bool(mask_path is not None), "mask_shape": str(mask.shape) if mask is not None else "", - "mask_unique": mask_unique_summary(mask), + "mask_unique": mask_unique_summary(mask, class_map), + "mask_valid_pct": mask_valid_pct, "H": h, "W": w, - "warnings_count": len(sample_warn), - "warning_text": " ; ".join(sample_warn), - "re_edge_corr": alignment["RGBgray_vs_RE"]["edge_corr"], - "nir_edge_corr": alignment["RGBgray_vs_NIR"]["edge_corr"], - "re_phase_dx": alignment["RGBgray_vs_RE"]["phase_dx"], - "re_phase_dy": alignment["RGBgray_vs_RE"]["phase_dy"], - "nir_phase_dx": alignment["RGBgray_vs_NIR"]["phase_dx"], - "nir_phase_dy": alignment["RGBgray_vs_NIR"]["phase_dy"], + "tensor_shape": str(tensor.shape), + "tensor_dtype": str(tensor.dtype), + "tensor_valid": bool(tensor_valid), + "tensor_nan_pct": tensor_nan_pct, + "meta_ts": meta.get("ts") or get_nested(meta, "source_capture_meta.ts", ""), + "sync_ok": get_nested(meta, "stream_meta.sync_ok", get_nested(meta, "source_capture_meta.stream_meta.sync_ok", "")), + "sync_dt_ms": get_nested(meta, "stream_meta.sync_dt_ms", get_nested(meta, "source_capture_meta.stream_meta.sync_dt_ms", "")), + "camera_params_json": meta.get("camera_params_json", ""), + "schema": meta.get("schema", ""), } + row.update(core_metrics) + + row["re_edge_corr"] = alignment["RGBgray_vs_RE"]["edge_corr"] + row["nir_edge_corr"] = alignment["RGBgray_vs_NIR"]["edge_corr"] + row["renir_edge_corr"] = alignment["RE_vs_NIR"]["edge_corr"] + for key, al in (("re", alignment["RGBgray_vs_RE"]), ("nir", alignment["RGBgray_vs_NIR"]), ("renir", alignment["RE_vs_NIR"])): + row[f"{key}_phase_dx"] = al["phase_dx"] + row[f"{key}_phase_dy"] = al["phase_dy"] + row[f"{key}_phase_mag"] = al["phase_mag"] + row[f"{key}_phase_response"] = al["phase_response"] for fname in ALL_FEATURES: st = sample_feature_stats[fname] - row[f"{fname}_mean"] = st["mean"] - row[f"{fname}_std"] = st["std"] - row[f"{fname}_p50"] = st["p50"] - row[f"{fname}_p95"] = st["p95"] - row[f"{fname}_p95_p05"] = st["p95_p05"] + for k in ("mean", "std", "min", "p01", "p05", "p50", "p95", "p99", "max", "p95_p05", "iqr"): + row[f"{fname}_{k}"] = st.get(k, 0.0) if fname in CHANNELS: - row[f"{fname}_dark_pct"] = st["dark_pct"] - row[f"{fname}_sat_pct"] = st["sat_pct"] + for k in ("dark_pct", "sat_pct", "over_1_pct", "under_0_pct"): + row[f"{fname}_{k}"] = st.get(k, 0.0) for cls_name, count in class_pixel_counts.items(): row[f"pixels_{cls_name}"] = count + row[f"pct_{cls_name}"] = 100.0 * count / max(1, h * w) - sample_rows.append(row) + 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) - sample_stats_for_visual = { - "alignment": alignment, - "features": sample_feature_stats, - } + if health.warnings or health.errors: + warnings.append({ + "sample": stem, + "type": "sample_health", + "status": health.status, + "score": health.score, + "warnings": health.warnings, + "errors": health.errors, + }) + sample_stats_for_visual = {"alignment": alignment, "features": sample_feature_stats} should_save_visual = args.save_visuals and ( - args.visual_every <= 1 or idx % args.visual_every == 0 or len(sample_warn) > 0 + args.visual_every <= 1 or idx % args.visual_every == 0 or health.status != "good" ) - canvas = None if should_save_visual or args.manual_review: - canvas = make_sample_visual(stem, tensor, mask, class_map, sample_stats_for_visual) - + canvas = make_sample_visual(stem, tensor, mask, class_map, sample_stats_for_visual, health=health) if should_save_visual and canvas is not None: cv2.imwrite(str(visuals_dir / f"{idx:05d}_{stem}.png"), canvas) if args.manual_review and canvas is not None: - decision = manual_review_decision(canvas, row, idx, len(entries), args) + decision, reason = manual_review_decision(canvas, row, idx, len(entries), args) if decision == "quit": - manual_stop_requested = True + manual_stop = True row["manual_decision"] = "skipped" - row["manual_note"] = "manual_quit_here" + row["manual_reason"] = reason else: row["manual_decision"] = decision - row["manual_note"] = "" + 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 decision == "rejected" and args.save_rejected_previews: - fixed_root = resolve_fixed_out_root(dataset_roots, args) - rejected_row = dict(row) - rejected_row["reject_reasons"] = "manual_rejected" - save_rejected_preview( - row=rejected_row, - fixed_root=fixed_root, - args=args, - tensor=tensor, - mask=mask, - class_map=class_map, - ) + 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) - print(f"[MANUAL] {idx}/{len(entries)} | {stem} | decisão={row.get('manual_decision')}") - if manual_stop_requested: - break + sample_rows.append(row) + + core_row = { + "idx": idx, + "sample": stem, + "group": group_name, + "source_kind": load_result.source_kind, + **core_metrics, + } + core_rows.append(core_row) if idx % args.print_every == 0 or idx == len(entries): - print(f"[OK] {idx}/{len(entries)} | {stem} | warnings={len(sample_warn)}") + print(f"[OK] {idx}/{len(entries)} | {stem} | health={row['health_status']} score={row['health_score']:.1f}") + if manual_stop: + break except Exception as e: msg = str(e) - warnings.append({ - "sample": stem, - "type": "exception", - "message": msg, - }) + warnings.append({"sample": stem, "type": "exception", "message": msg}) print(f"[ERRO] {idx}/{len(entries)} | {stem}: {msg}") if args.stop_on_error: raise @@ -1809,196 +1774,99 @@ def audit_dataset(args): if args.manual_review: cv2.destroyAllWindows() - # Resumos finais. global_summary = global_acc.summarize() class_summary = { - str(cls_id): { - "class_name": class_map[cls_id], - "features": acc.summarize(), - } + str(cls_id): {"class_name": class_map[cls_id], "features": acc.summarize()} for cls_id, acc in class_acc.items() } - - diagnosis = build_diagnosis(global_summary, class_summary, sample_rows, warnings, args) + by_group_rows = summarize_by_group(sample_rows) + global_health = build_global_health(sample_rows, global_summary, class_summary, warnings, args) summary = { - "schema": "multispec_dataset_audit_v1", + "schema": "multispec_dataset_audit_bulletproof_v1", + "created_at_unix": time.time(), + "elapsed_s": time.time() - t_start, + "input_path": str(args.input_path), + "out_dir": str(out_dir), "dataset_roots": [str(r) for r in dataset_roots], "samples_processed": len(sample_rows), "channels": CHANNELS, "derived_features": DERIVED, "class_map": {str(k): v for k, v in class_map.items()}, + "thresholds": vars(args), + "global_health": global_health, "global_summary": global_summary, "class_summary": class_summary, - "diagnosis": diagnosis, } write_json(out_dir / "audit_summary.json", summary) + write_json(out_dir / "audit_health.json", global_health) write_json(out_dir / "audit_warnings.json", warnings) write_csv(out_dir / "audit_samples.csv", sample_rows) - write_class_channel_csv(out_dir / "audit_by_class_channel.csv", class_summary) - write_csv(out_dir / "audit_by_sample_class_channel.csv", by_sample_class_channel_rows) + write_csv(out_dir / "audit_by_group.csv", by_group_rows) + write_csv(out_dir / "audit_by_sample_class_feature.csv", by_sample_class_feature_rows) + write_csv(out_dir / "audit_core_telemetry.csv", core_rows) + + class_feature_rows: List[Dict[str, Any]] = [] + for cls_id, item in class_summary.items(): + for feature, st in item["features"].items(): + class_feature_rows.append({"class_id": cls_id, "class_name": item["class_name"], "feature": feature, **st}) + write_csv(out_dir / "audit_by_class_channel.csv", class_feature_rows) + + readme = f"""Auditoria Multiespectral - Bulletproof + +Veredito: {global_health.get('status')} | score={safe_float(global_health.get('score')):.1f} +{global_health.get('verdict')} + +Arquivos principais: +- audit_summary.json: resumo completo, thresholds e estatísticas globais. +- audit_health.json: veredito direto do dataset. +- audit_samples.csv: saúde e métricas por amostra. +- audit_by_group.csv: resumo por grupo. +- audit_by_class_channel.csv: estatísticas por classe e feature. +- audit_by_sample_class_feature.csv: estatísticas por amostra/classe/feature. +- audit_core_telemetry.csv: telemetria extraída do RawProcessorCore/meta normalizado. +- audit_warnings.json: avisos e exceções detalhados. +- visuals/: painéis visuais de amostras selecionadas ou com problemas. + +Observação: +A métrica de phase correlation entre RGB/RE/NIR é tratada como alerta, não verdade absoluta. +Canais espectrais podem ter textura diferente do RGB mesmo quando a homografia está boa. +""" + (out_dir / "audit_manifest_readme.txt").write_text(readme, encoding="utf-8") if args.build_fixed_dataset: - if args.manual_review: - build_fixed_dataset_from_manual_review( - sample_rows=sample_rows, - dataset_roots=dataset_roots, - args=args, - ) - else: - build_fixed_dataset_from_audit( - sample_rows=sample_rows, - dataset_roots=dataset_roots, - class_map=class_map, - args=args, - ) + fixed_root = resolve_fixed_out_root(dataset_roots, args) + ensure_dir(fixed_root) + approved_rows = [r for r in sample_rows if bool(r.get("approved_for_training"))] + rejected_rows = [r for r in sample_rows if not bool(r.get("approved_for_training"))] + for r in approved_rows: + copy_sample_to_fixed(r, fixed_root, copy_previews=args.clean_copy_previews) + write_csv(fixed_root / "fixed_manifest.csv", sample_rows) + write_csv(fixed_root / "fixed_approved.csv", approved_rows) + write_csv(fixed_root / "fixed_rejected.csv", rejected_rows) + write_json(fixed_root / "fixed_summary.json", { + "schema": "multispec_fixed_dataset_bulletproof_v1", + "fixed_root": str(fixed_root), + "samples_total": len(sample_rows), + "samples_approved": len(approved_rows), + "samples_rejected": len(rejected_rows), + "approval_pct": 100.0 * len(approved_rows) / max(1, len(sample_rows)), + "global_health": global_health, + }) + print(f"[FIXED] fixed/group: {fixed_root} | aprovadas={len(approved_rows)} rejeitadas={len(rejected_rows)}") print("\n========== AUDITORIA FINALIZADA ==========") print(f"Amostras processadas : {len(sample_rows)}") - print(f"Warnings : {len(warnings)}") + print(f"Warnings/exceções : {len(warnings)}") + print(f"Status dataset : {global_health.get('status')} | score={safe_float(global_health.get('score')):.1f}") + print(f"Veredito : {global_health.get('verdict')}") print(f"Resumo : {out_dir / 'audit_summary.json'}") print(f"CSV amostras : {out_dir / 'audit_samples.csv'}") - print(f"CSV classes/canais : {out_dir / 'audit_by_class_channel.csv'}") + print(f"CSV grupos : {out_dir / 'audit_by_group.csv'}") print(f"Visuais : {visuals_dir}") print("==========================================\n") - - - -def write_csv(path: Path, rows: List[Dict[str, Any]]): - if not rows: - with open(path, "w", encoding="utf-8", newline="") as f: - f.write("") - return - - keys = [] - for row in rows: - for k in row.keys(): - if k not in keys: - keys.append(k) - - with open(path, "w", encoding="utf-8", newline="") as f: - writer = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore") - writer.writeheader() - writer.writerows(rows) - - -def write_class_channel_csv(path: Path, class_summary: Dict[str, Any]): - rows = [] - for cls_id, item in class_summary.items(): - cls_name = item["class_name"] - for feature, stats in item["features"].items(): - rows.append({ - "class_id": cls_id, - "class_name": cls_name, - "feature": feature, - **stats, - }) - write_csv(path, rows) - - -# ============================================================ -# Diagnóstico automático simples -# ============================================================ - - -def build_diagnosis( - global_summary: Dict[str, Dict[str, float]], - class_summary: Dict[str, Any], - sample_rows: List[Dict[str, Any]], - warnings: List[Dict[str, Any]], - args, -) -> Dict[str, Any]: - notes = [] - risks = [] - positives = [] - - # Sanidade global dos canais. - for ch in CHANNELS: - st = global_summary.get(ch, {}) - sat = safe_float(st.get("sat_pct")) - dark = safe_float(st.get("dark_pct")) - dyn = safe_float(st.get("p95_p05")) - mean = safe_float(st.get("mean")) - - if sat > args.warn_sat_pct: - risks.append(f"{ch}: saturação global alta ({sat:.2f}%).") - if dark > args.warn_dark_pct: - risks.append(f"{ch}: muitos pixels escuros globalmente ({dark:.2f}%).") - if dyn < args.warn_low_dynamic: - risks.append(f"{ch}: baixa dinâmica global p95-p05={dyn:.4f}.") - if 0.02 < mean < 0.98 and dyn >= args.warn_low_dynamic: - positives.append(f"{ch}: média/dinâmica globais parecem utilizáveis (mean={mean:.3f}, p95-p05={dyn:.3f}).") - - # Alinhamento médio. - if sample_rows: - re_corr = np.mean([safe_float(r.get("re_edge_corr")) for r in sample_rows]) - nir_corr = np.mean([safe_float(r.get("nir_edge_corr")) for r in sample_rows]) - re_shift = np.mean([math.hypot(safe_float(r.get("re_phase_dx")), safe_float(r.get("re_phase_dy"))) for r in sample_rows]) - nir_shift = np.mean([math.hypot(safe_float(r.get("nir_phase_dx")), safe_float(r.get("nir_phase_dy"))) for r in sample_rows]) - - notes.append(f"Correlação média de bordas RGB-RE={re_corr:.3f}, RGB-NIR={nir_corr:.3f}.") - notes.append(f"Shift médio estimado RGB-RE={re_shift:.2f}px, RGB-NIR={nir_shift:.2f}px.") - - if re_shift > args.warn_shift_px: - risks.append(f"RE: shift médio estimado alto ({re_shift:.2f}px). Verificar homografia/fusão.") - if nir_shift > args.warn_shift_px: - risks.append(f"NIR: shift médio estimado alto ({nir_shift:.2f}px). Verificar homografia/fusão.") - if re_corr < args.warn_edge_corr: - risks.append(f"RE: correlação média de borda baixa ({re_corr:.3f}). Pode indicar desalinhamento ou textura espectral muito diferente.") - if nir_corr < args.warn_edge_corr: - risks.append(f"NIR: correlação média de borda baixa ({nir_corr:.3f}). Pode indicar desalinhamento ou textura espectral muito diferente.") - - # Separabilidade simples por classes. - separability = {} - class_items = list(class_summary.items()) - for feature in ALL_FEATURES: - vals = [] - for cls_id, item in class_items: - st = item["features"].get(feature, {}) - vals.append((item["class_name"], safe_float(st.get("mean")), safe_float(st.get("std")))) - - sep_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 - sep_rows.append({ - "pair": f"{a_name}_vs_{b_name}", - "effect_size_d": d, - "mean_a": a_mean, - "mean_b": b_mean, - }) - separability[feature] = sep_rows - - # Destaca features com maior separação média. - ranking = [] - for feature, rows in separability.items(): - if rows: - avg_d = float(np.mean([r["effect_size_d"] for r in rows])) - ranking.append((feature, avg_d)) - ranking.sort(key=lambda x: x[1], reverse=True) - - notes.append("Ranking simples de separabilidade média por feature: " + ", ".join([f"{f}={d:.2f}" for f, d in ranking[:8]])) - - for f, d in ranking[:5]: - if d > 0.5: - positives.append(f"{f}: mostra separabilidade média interessante entre classes (d≈{d:.2f}).") - - if warnings: - risks.append(f"Foram gerados {len(warnings)} avisos/exceções. Ver audit_warnings.json.") - - return { - "positives": positives, - "risks": risks, - "notes": notes, - "feature_separability": separability, - "feature_separability_ranking": [{"feature": f, "avg_effect_size_d": d} for f, d in ranking], - } - # ============================================================ # CLI @@ -2006,78 +1874,69 @@ def build_diagnosis( def main(): - parser = argparse.ArgumentParser( - description="Auditoria final do tensor multiespectral [R,G,B,RE,NIR] pronto para treinamento." - ) - parser.add_argument("--input_path", required=True, help="Caminho para dataset_root, metas, bins, masks ou arquivo dentro do dataset.") - parser.add_argument("--out_dir", default="audit_multispec_out", help="Pasta de saída da auditoria.") - parser.add_argument("--class-map", default="0:chao,1:cana,2:erva", help="Mapa de classes. Ex: 0:chao,1:cana,2:erva") - parser.add_argument("--ignore-index", type=int, default=255, help="Valor ignorado na máscara.") - parser.add_argument("--limit", type=int, default=0, help="Limita número de amostras para teste rápido. 0 = todas.") - parser.add_argument("--save-visuals", action="store_true", help="Salva painéis visuais por amostra.") - parser.add_argument("--visual-every", type=int, default=10, help="Salva visual a cada N amostras. Amostras com warning sempre são salvas.") - parser.add_argument("--print-every", type=int, default=10, help="Mostra progresso a cada N amostras.") - parser.add_argument("--min-class-pixels", type=int, default=50, help="Mínimo de pixels por classe para estatística por amostra.") - parser.add_argument("--max-pixels-per-feature", type=int, default=25000, help="Amostragem máxima de pixels por feature/amostra para acumuladores.") - parser.add_argument("--warn-sat-pct", type=float, default=1.0, help="Alerta se canal tiver saturação acima deste percentual.") - parser.add_argument("--warn-dark-pct", type=float, default=35.0, help="Alerta se canal tiver pixels <=0.01 acima deste percentual.") - parser.add_argument("--warn-low-dynamic", type=float, default=0.03, help="Alerta se p95-p05 do canal for menor que isso.") - parser.add_argument("--warn-shift-px", type=float, default=3.0, help="Alerta se shift estimado por phase correlation passar disso.") - parser.add_argument("--warn-edge-corr", type=float, default=0.08, help="Alerta se correlação de borda for menor que isso.") + parser = argparse.ArgumentParser(description="Auditoria parruda do tensor multiespectral [R,G,B,RE,NIR].") + parser.add_argument("--input_path", required=True) + parser.add_argument("--out_dir", default="audit_multispec_out") + parser.add_argument("--class-map", default="0:chao,1:cana,2:erva") + parser.add_argument("--ignore-index", type=int, default=255) + parser.add_argument("--limit", type=int, default=0) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--stop-on-error", action="store_true") + parser.add_argument("--print-every", type=int, default=10) + parser.add_argument("--groups-except", default="") - parser.add_argument("--build-fixed-dataset", action="store_true", - help="Cria uma cópia filtrada do dataset em fixed/group, mantendo apenas amostras aprovadas para treino.") + # 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) - parser.add_argument("--fixed-out-root", default="", - help="Raiz de saída do dataset filtrado. Se vazio, usa dataset_root/../../fixed/group.") + # Stats/performance + parser.add_argument("--min-class-pixels", type=int, default=50) + parser.add_argument("--max-pixels-per-feature", type=int, default=25000) - parser.add_argument("--clean-max-abs-shift-px", type=float, default=22.0, - help="Reprova amostras com shift absoluto extremo em RE ou NIR.") + # 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") - parser.add_argument("--clean-max-dev-shift-px", type=float, default=7.0, - help="Reprova amostras cujo shift foge muito da mediana robusta do grupo.") + # 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) - parser.add_argument("--clean-min-edge-corr", type=float, default=0.045, - help="Correlação mínima de borda para aprovar. Valor baixo porque canais espectrais naturalmente diferem do RGB.") + # 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) - parser.add_argument("--clean-reject-low-corr-only-if-shift-bad", action="store_true", - help="Só reprova baixa correlação se também houver desvio geométrico alto.") - - parser.add_argument("--clean-min-target-pct", type=float, default=0.0025, - help="Percentual mínimo da classe alvo em grupos com cana/erva. 0.0025 = 0.25%%. Use 0 para desativar.") - - parser.add_argument("--clean-copy-previews", action="store_true", - help="Copia previews quando existirem.") - - parser.add_argument("--save-rejected-previews", action="store_true", - help="Salva PNGs leves das amostras rejeitadas em fixed/rejected_previews para inspeção visual.") - - parser.add_argument("--rejected-preview-source", default="auto", - choices=["auto", "preview", "audit_panel", "rgb_tensor"], - help="Fonte do preview dos rejeitados: preview original, painel audit, RGB reconstruído ou automático.") - - parser.add_argument("--rejected-preview-max-width", type=int, default=640, - help="Largura máxima dos previews rejeitados salvos.") - - parser.add_argument("--manual-review", action="store_true", - help="Ativa revisão manual interativa. Mostra cada painel visual e espera aprovar/rejeitar.") - - parser.add_argument("--manual-window-width", type=int, default=1500, - help="Largura máxima da janela de revisão manual. Use 0 para não redimensionar.") - - parser.add_argument("--manual-window-name", default="Audit Dataset - Revisao Manual", - help="Nome da janela OpenCV usada na revisão manual.") - - parser.add_argument("--manual-start-index", type=int, default=1, - help="Índice inicial da fila manual. Útil para retomar uma revisão interrompida.") - - parser.add_argument("--groups-except", default="", - help="Lista de grupos para ignorar, separados por vírgula. Ex: chao,chao_cana") + # Fixed dataset + parser.add_argument("--build-fixed-dataset", action="store_true") + parser.add_argument("--fixed-out-root", default="") + parser.add_argument("--clean-copy-previews", action="store_true") + parser.add_argument("--clean-min-target-pct", type=float, default=0.0025) + parser.add_argument("--save-rejected-previews", action="store_true") + parser.add_argument("--rejected-preview-source", default="auto", choices=["auto", "preview", "audit_panel", "rgb_tensor"]) + parser.add_argument("--rejected-preview-max-width", type=int, default=900) args = parser.parse_args() - audit_dataset(args)