#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ _6_normalize_oak_fcc3_originals.py Converte dataset/originals/group para tensores finais de treino usando RawProcessorCore + module_params.json. Entrada: dataset/originals/group// bins/ _CAM_A.bin _CAM_B.bin _CAM_C.bin metas/ .json masks/ .png previews/ .png Saída: dataset//group// tensors/ .npy # float32 CHW conforme config["input_channels"] masks/ .npy # uint8/uint16 HW com IDs de classe .png # debug visual dos IDs metas/ .json # meta do tensor normalizado previews/ .png # preview RGB do tensor final Também salva: backup////norm_stats.json Uso: python _6_normalize_oak_fcc3_originals.py ^ --src-root dataset/originals/group ^ --out-root dataset ^ --module-params calibration/module_params.json ^ --clear-dst """ from __future__ import annotations import argparse import csv import json import os import shutil import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple import cv2 import numpy as np from core.raw_processor_core import RawProcessorCore from helpers import carregar_labelmap_completo, converter_mask_rgb_para_ids, _infer_ignore_id # ============================================================ # Config # ============================================================ with open("config.json", "r", encoding="utf-8") as f: config = json.load(f) DEFAULT_RES = tuple(config.get("resolucao", [512, 512])) # (W, H) DEFAULT_RAW_SIZE = tuple(config.get("raw_size", [1280, 800])) # (W, H) DEFAULT_MODULE_PARAMS = config.get("module_params_json") DEFAULT_DATASET_BASE = "dataset" # Ordem física produzida pelo RawProcessorCore. Estes cinco canais sempre # existem antes da seleção/derivação dos canais que entrarão no modelo. SOURCE_CHANNEL_NAMES = ["R", "G", "B", "RE", "NIR"] # Canais derivados suportados pelo normalizador. Novos índices devem ser # implementados em build_model_input_tensor() e adicionados aqui. DERIVED_CHANNEL_NAMES = ["NDVI", "NDRE"] SUPPORTED_INPUT_CHANNELS = SOURCE_CHANNEL_NAMES + DERIVED_CHANNEL_NAMES def get_model_input_channels(cfg: dict) -> List[str]: """Resolve e valida, na ordem, os canais finais de entrada do modelo. Compatibilidade: - config novo: input_channels declara tudo explicitamente; - config antigo sem input_channels: usa os primeiros ``channels`` canais físicos e, quando use_ndvi=True, acrescenta NDVI. """ configured = cfg.get("input_channels") if configured is None: raw_count = int(cfg.get("channels", len(SOURCE_CHANNEL_NAMES))) raw_count = max(1, min(len(SOURCE_CHANNEL_NAMES), raw_count)) names = SOURCE_CHANNEL_NAMES[:raw_count] if bool(cfg.get("use_ndvi", False)) and "NDVI" not in names: names.append("NDVI") elif isinstance(configured, str): names = [x.strip().upper() for x in configured.split(",") if x.strip()] else: names = [str(x).strip().upper() for x in configured if str(x).strip()] if not names: raise RuntimeError("config.input_channels não pode ser vazio.") duplicates = sorted({name for name in names if names.count(name) > 1}) if duplicates: raise RuntimeError(f"Canais duplicados em config.input_channels: {duplicates}") invalid = [name for name in names if name not in SUPPORTED_INPUT_CHANNELS] if invalid: raise RuntimeError( f"Canais inválidos em config.input_channels: {invalid}. " f"Suportados: {SUPPORTED_INPUT_CHANNELS}" ) configured_count = cfg.get("channels") if configured_count is not None and int(configured_count) != len(names): raise RuntimeError( "config.channels incompatível com config.input_channels: " f"channels={configured_count}, input_channels={names} ({len(names)} canais)." ) return names MODEL_INPUT_CHANNELS = get_model_input_channels(config) _CORE_CACHE: Dict[tuple, RawProcessorCore] = {} def get_or_create_core( raw_size: Tuple[int, int], bayer_pattern: str, module_params_path: Optional[str], ) -> RawProcessorCore: raw_w, raw_h = raw_size bayer = str(bayer_pattern or "BGGR").upper() key = ( int(raw_w), int(raw_h), bayer, str(Path(module_params_path).resolve()) if module_params_path else None, ) core = _CORE_CACHE.get(key) if core is not None: return core print( "[CORE_CACHE] criando RawProcessorCore " f"raw={raw_w}x{raw_h} bayer={bayer} module={module_params_path}" ) core = RawProcessorCore( sensor_width=int(raw_w), sensor_height=int(raw_h), bayer_pattern=bayer, calibration_json_path=module_params_path, ) _CORE_CACHE[key] = core return core @dataclass class SampleBundle: group: str base: str meta_path: Path mask_path: Path preview_path: Optional[Path] bin_paths: Dict[str, Path] # ============================================================ # Helpers gerais # ============================================================ def ensure_dir(path: Path): path.mkdir(parents=True, exist_ok=True) def maybe_clear_dir(path: Path): if path.exists(): shutil.rmtree(path) ensure_dir(path) def load_json(path: Path) -> dict: with path.open("r", encoding="utf-8") as f: return json.load(f) def save_json(path: Path, data: dict): ensure_dir(path.parent) with path.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) 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 list_groups(src_root: Path) -> List[str]: if not src_root.is_dir(): return [] groups = [] for p in sorted(src_root.iterdir()): if not p.is_dir(): continue if (p / "bins").is_dir() and (p / "metas").is_dir() and (p / "masks").is_dir(): groups.append(p.name) return groups def find_preview(previews_dir: Path, base: str) -> Optional[Path]: for ext in (".png", ".jpg", ".jpeg"): p = previews_dir / f"{base}{ext}" if p.exists(): return p return None def find_sample_bins(bins_dir: Path, base: str) -> Dict[str, Path]: out = {} for cam_id in ("CAM_A", "CAM_B", "CAM_C"): p = bins_dir / f"{base}_{cam_id}.bin" if p.exists(): out[cam_id] = p return out def collect_samples_from_group(group_dir: Path) -> List[SampleBundle]: group = group_dir.name metas_dir = group_dir / "metas" masks_dir = group_dir / "masks" previews_dir = group_dir / "previews" bins_dir = group_dir / "bins" samples = [] if not metas_dir.is_dir(): return samples for meta_path in sorted(metas_dir.glob("*.json")): base = meta_path.stem mask_path = masks_dir / f"{base}.png" if not mask_path.exists(): print(f"[WARN] [{group}] sem mask para {base}. Pulando.") continue bin_paths = find_sample_bins(bins_dir, base) if not all(cam in bin_paths for cam in ("CAM_A", "CAM_B", "CAM_C")): print(f"[WARN] [{group}] bins incompletos para {base}: {list(bin_paths.keys())}. Pulando.") continue samples.append( SampleBundle( group=group, base=base, meta_path=meta_path, mask_path=mask_path, preview_path=find_preview(previews_dir, base), bin_paths=bin_paths, ) ) return samples def copy_json_safe(obj): try: return json.loads(json.dumps(obj, default=str)) except Exception: return None # ============================================================ # Module params / tensor # ============================================================ def resolve_module_params_path(meta: dict, meta_path: Path, cli_module_params: Optional[str]) -> Optional[str]: candidates = [] if cli_module_params: candidates.append(cli_module_params) if meta.get("camera_params_json"): candidates.append(meta.get("camera_params_json")) if DEFAULT_MODULE_PARAMS: candidates.append(DEFAULT_MODULE_PARAMS) candidates.append("calibration/module_params.json") for c in candidates: if not c: continue p = Path(str(c)) if p.is_file(): return str(p) # relativo ao diretório atual p2 = Path.cwd() / p if p2.is_file(): return str(p2) # relativo à pasta do meta p3 = meta_path.parent / p if p3.is_file(): return str(p3) print("[WARN] module_params.json não encontrado. RawProcessorCore vai usar defaults.") return None def load_frame_from_saved_bins(sample: SampleBundle, meta: dict) -> Dict[str, np.ndarray]: """ Monta frame no contrato do RawProcessorCore.decode_stream_cameras: frame = { "CAM_A": ndarray RAW10 packed 2D, "CAM_B": ndarray RAW10 packed 2D, "CAM_C": ndarray RAW10 packed 2D, } Usa saved_payload_dtypes/saved_payload_shapes do JSON. """ saved_dtypes = meta.get("saved_payload_dtypes", {}) or {} saved_shapes = meta.get("saved_payload_shapes", {}) or {} frame = {} for cam_id, bin_path in sample.bin_paths.items(): dtype = saved_dtypes.get(cam_id) shape = saved_shapes.get(cam_id) if dtype is None or shape is None: raise RuntimeError(f"Faltam saved_payload_dtypes/shapes para {cam_id} em {sample.base}") arr = np.fromfile(str(bin_path), dtype=np.dtype(dtype)).reshape(tuple(shape)) frame[cam_id] = arr return frame def build_processing_meta(meta: dict) -> dict: """ O RawProcessorCore precisa de: frame_type = RAW_BRUTO camera_info/camera_frames por CAM_A/B/C actual_camera_controls para radiometric_normalization """ stream_meta = dict(meta.get("stream_meta", {}) or {}) # Garante o frame_type esperado pelo core. stream_meta["frame_type"] = "RAW_BRUTO" # Em algumas capturas o camera_info está fora do stream_meta. if "camera_info" not in stream_meta and isinstance(meta.get("camera_info"), dict): stream_meta["camera_info"] = meta.get("camera_info") if meta.get("actual_camera_controls") is not None: stream_meta["actual_camera_controls"] = meta.get("actual_camera_controls") if meta.get("startup_camera_controls") is not None: stream_meta["startup_camera_controls"] = meta.get("startup_camera_controls") if meta.get("camera_controls") is not None: stream_meta["camera_controls"] = meta.get("camera_controls") return stream_meta def build_tensor_from_sample( sample: SampleBundle, meta: dict, res: Tuple[int, int], raw_size: Tuple[int, int], module_params_path: Optional[str], ) -> Tuple[np.ndarray, np.ndarray, dict]: bayer = str(meta.get("bayer_pattern") or meta.get("bayer") or "BGGR").upper() core = get_or_create_core( raw_size=raw_size, bayer_pattern=bayer, module_params_path=module_params_path, ) frame = load_frame_from_saved_bins(sample, meta) processing_meta = build_processing_meta(meta) source_tensor = core.build_infer_tensor_from_stream( frame=frame, meta=processing_meta, channels_expected=5, target_size=res, ) source_tensor = np.ascontiguousarray(source_tensor.astype(np.float32, copy=False)) if source_tensor.ndim != 3 or source_tensor.shape[0] != len(SOURCE_CHANNEL_NAMES): raise RuntimeError( "RawProcessorCore retornou tensor incompatível: " f"esperado CHW com {len(SOURCE_CHANNEL_NAMES)} canais " f"{SOURCE_CHANNEL_NAMES}, veio shape={source_tensor.shape}." ) tensor = build_model_input_tensor( source_tensor=source_tensor, requested_channels=MODEL_INPUT_CHANNELS, derived_config=config.get("derived_channels", {}) or {}, ) info = { "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)), } # source_tensor é devolvido apenas para gerar o preview RGB. O .npy salvo # e as estatísticas pertencem sempre ao tensor final de entrada do modelo. return tensor, source_tensor, info def build_model_input_tensor( source_tensor: np.ndarray, requested_channels: List[str], derived_config: Optional[dict] = None, ) -> np.ndarray: """Monta o CHW final combinando bandas físicas e índices espectrais. Os índices são calculados antes da normalização estatística do dataset: NDVI = (NIR - R) / (NIR + R) NDRE = (NIR - RE) / (NIR + RE) Denominadores próximos de zero resultam em 0.0. O resultado é limitado por padrão a [-1, 1] e posteriormente receberá mean/std próprios. """ derived_config = derived_config or {} eps = float(derived_config.get("epsilon", 1e-6)) clip_min = float(derived_config.get("clip_min", -1.0)) clip_max = float(derived_config.get("clip_max", 1.0)) if not np.isfinite(eps) or eps <= 0.0: raise RuntimeError(f"derived_channels.epsilon inválido: {eps}") if not np.isfinite(clip_min) or not np.isfinite(clip_max) or clip_min >= clip_max: raise RuntimeError( "Faixa inválida para derived_channels: " f"clip_min={clip_min}, clip_max={clip_max}" ) source = np.nan_to_num( np.asarray(source_tensor, dtype=np.float32), nan=0.0, posinf=1.0, neginf=0.0, ) available = { name: source[index] for index, name in enumerate(SOURCE_CHANNEL_NAMES) } r = available["R"] re = available["RE"] nir = available["NIR"] def normalized_difference(a: np.ndarray, b: np.ndarray) -> np.ndarray: denominator = a + b result = np.zeros_like(a, dtype=np.float32) np.divide( a - b, denominator, out=result, where=np.abs(denominator) > eps, ) return np.clip(result, clip_min, clip_max).astype(np.float32, copy=False) # Calcula somente o que foi solicitado para evitar trabalho desnecessário. if "NDVI" in requested_channels: available["NDVI"] = normalized_difference(nir, r) if "NDRE" in requested_channels: available["NDRE"] = normalized_difference(nir, re) tensor = np.stack( [available[name] for name in requested_channels], axis=0, ) return np.ascontiguousarray(tensor.astype(np.float32, copy=False)) # ============================================================ # Máscara / preview # ============================================================ def load_mask_ids_aligned_to_tensor( mask_path: Path, cor_para_id: dict, ignore_id: int, res: Tuple[int, int], fusion_result: dict | None, ) -> np.ndarray: bgr = cv2.imread(str(mask_path), cv2.IMREAD_COLOR) if bgr is None: raise RuntimeError(f"Falha ao abrir mask: {mask_path}") rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) ids = converter_mask_rgb_para_ids(rgb, cor_para_id, ignore_id) # A mask foi feita no espaço RGB/CAM_A. # Então primeiro garantimos que ela está no mesmo tamanho da referência RGB. if isinstance(fusion_result, dict): ref_shape = fusion_result.get("ref_shape") if isinstance(ref_shape, list) and len(ref_shape) == 2: ref_h, ref_w = int(ref_shape[0]), int(ref_shape[1]) if ids.shape[:2] != (ref_h, ref_w): ids = cv2.resize( ids, (ref_w, ref_h), interpolation=cv2.INTER_NEAREST, ) crop_box = fusion_result.get("crop_box") if crop_box is not None: x0, y0, x1, y1 = [int(v) for v in crop_box] h, w = ids.shape[:2] x0 = max(0, min(w - 1, x0)) x1 = max(x0 + 1, min(w, x1)) y0 = max(0, min(h - 1, y0)) y1 = max(y0 + 1, min(h, y1)) ids = ids[y0:y1, x0:x1] # Por fim, leva para a resolução final do tensor. ids_res = cv2.resize( ids, res, interpolation=cv2.INTER_NEAREST, ) return ids_res def tensor_to_preview_bgr(source_tensor: np.ndarray) -> np.ndarray: """Gera preview a partir do RGB físico, independente dos canais do modelo.""" rgb = np.transpose(source_tensor[:3].astype(np.float32), (1, 2, 0)) rgb_u8 = np.clip(rgb * 255.0, 0, 255).astype(np.uint8) return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR) def save_bgr(path: Path, bgr: np.ndarray): ensure_dir(path.parent) ok = cv2.imwrite(str(path), bgr) if not ok: raise RuntimeError(f"Falha ao salvar preview: {path}") def get_class_ids_from_config() -> dict: heads = config.get("heads", {}) or {} semantic = heads.get("semantic", {}) or {} classes = semantic.get("classes", {}) or {} return { "chao": int(classes.get("chao", 0)), "cana": int(classes.get("cana", 1)), "erva": int(classes.get("erva", 2)), } def build_head_masks_from_semantic( mask_ids: np.ndarray, class_ids: dict, ignore_id: int = 255, ) -> dict: """ Gera máscaras auxiliares para multi-head a partir da máscara semântica alinhada. Entrada: mask_ids: HW com IDs semânticos. Saída: { "semantic": HW uint8, "vegetation": HW uint8 com 0/1/ignore, "cana": HW uint8 com 0/1/ignore } """ chao_id = int(class_ids.get("chao", 0)) cana_id = int(class_ids.get("cana", 1)) erva_id = int(class_ids.get("erva", 2)) semantic = mask_ids.astype(np.uint8, copy=True) ignore_mask = semantic == ignore_id vegetation = np.zeros_like(semantic, dtype=np.uint8) vegetation[(semantic == cana_id) | (semantic == erva_id)] = 1 vegetation[ignore_mask] = ignore_id cana = np.zeros_like(semantic, dtype=np.uint8) cana[semantic == cana_id] = 1 cana[ignore_mask] = ignore_id return { "semantic": semantic, "vegetation": vegetation, "cana": cana, } def save_mask_npy_and_debug(mask: np.ndarray, npy_path: Path, png_path: Path): ensure_dir(npy_path.parent) ensure_dir(png_path.parent) mask = mask.astype(np.uint8, copy=False) np.save(str(npy_path), mask) # Debug visual: # 0 -> preto # 1 -> cinza claro # 2 -> mais claro, quando existir na semântica # 255 -> branco debug = mask.copy() debug_vis = np.zeros_like(debug, dtype=np.uint8) debug_vis[debug == 0] = 0 debug_vis[debug == 1] = 120 debug_vis[debug == 2] = 220 debug_vis[debug == 255] = 255 cv2.imwrite(str(png_path), debug_vis) # ============================================================ # Stats # ============================================================ class RunningStats: def __init__(self): self.sum = None self.sumsq = None self.pixels = 0 def update(self, tensor: np.ndarray): c, h, w = tensor.shape if self.sum is None: self.sum = np.zeros(c, dtype=np.float64) self.sumsq = np.zeros(c, dtype=np.float64) elif len(self.sum) != c: raise RuntimeError( f"Quantidade de canais mudou durante o normalize: {len(self.sum)} -> {c}" ) flat = tensor.reshape(c, -1).astype(np.float64) self.sum += flat.sum(axis=1) self.sumsq += (flat ** 2).sum(axis=1) self.pixels += h * w def result(self, channel_names: List[str]): if self.sum is None or self.pixels <= 0: return None mean = self.sum / self.pixels var = (self.sumsq / self.pixels) - mean ** 2 std = np.sqrt(np.maximum(var, 1e-6)) return { "channels": channel_names, "mean": mean.tolist(), "std": std.tolist(), "pixels": int(self.pixels), } # ============================================================ # Processamento # ============================================================ def process_group( group_name: str, src_root: Path, output_root: Path, res: Tuple[int, int], raw_size: Tuple[int, int], module_params_arg: Optional[str], cor_para_id: dict, ignore_id: int, stats: RunningStats, dataset_root: Path, skip_bad_quality: bool, ) -> List[dict]: group_dir = src_root / group_name samples = collect_samples_from_group(group_dir) out_group = output_root / group_name out_tensors = out_group / "tensors" out_masks = out_group / "masks" out_masks_vegetation = out_group / "masks_vegetation" out_masks_cana = out_group / "masks_cana" out_metas = out_group / "metas" out_previews = out_group / "previews" for d in ( out_tensors, out_masks, out_masks_vegetation, out_masks_cana, out_metas, out_previews, ): ensure_dir(d) rows = [] errors = 0 skipped_quality = 0 print(f"\n[GRUPO] {group_name} | samples={len(samples)}") for sample in samples: try: meta = load_json(sample.meta_path) module_params_path = resolve_module_params_path(meta, sample.meta_path, module_params_arg) tensor, source_tensor, processing_info = build_tensor_from_sample( sample=sample, meta=meta, res=res, raw_size=raw_size, module_params_path=module_params_path, ) frame_quality = processing_info.get("frame_quality") or {} if skip_bad_quality and isinstance(frame_quality, dict): if frame_quality.get("usable_for_training") is False: skipped_quality += 1 print(f"[SKIP-QUALITY] {sample.base}: {frame_quality.get('reasons')}") continue mask_ids = load_mask_ids_aligned_to_tensor( mask_path=sample.mask_path, cor_para_id=cor_para_id, ignore_id=ignore_id, res=res, fusion_result=processing_info.get("fusion_result"), ) if mask_ids.shape[:2] != tensor.shape[1:]: raise RuntimeError( f"Shape mask/tensor incompatível: mask={mask_ids.shape}, tensor={tensor.shape}" ) # Salva tensor e mask tensor_path = out_tensors / f"{sample.base}.npy" preview_path = out_previews / f"{sample.base}.png" meta_out_path = out_metas / f"{sample.base}.json" np.save(str(tensor_path), tensor) class_ids = get_class_ids_from_config() head_masks = build_head_masks_from_semantic( mask_ids=mask_ids, class_ids=class_ids, ignore_id=ignore_id, ) mask_path = out_masks / f"{sample.base}.npy" mask_debug_path = out_masks / f"{sample.base}.png" mask_vegetation_path = out_masks_vegetation / f"{sample.base}.npy" mask_vegetation_debug_path = out_masks_vegetation / f"{sample.base}.png" mask_cana_path = out_masks_cana / f"{sample.base}.npy" mask_cana_debug_path = out_masks_cana / f"{sample.base}.png" save_mask_npy_and_debug( head_masks["semantic"], mask_path, mask_debug_path, ) save_mask_npy_and_debug( head_masks["vegetation"], mask_vegetation_path, mask_vegetation_debug_path, ) save_mask_npy_and_debug( head_masks["cana"], mask_cana_path, mask_cana_debug_path, ) preview_bgr = tensor_to_preview_bgr(source_tensor) save_bgr(preview_path, preview_bgr) out_meta = { "schema": "oak_fcc3_normalized_tensor_v2", "source_group": sample.group, "source_base": sample.base, "source_meta": safe_rel(sample.meta_path, dataset_root), "source_bins": {k: safe_rel(v, dataset_root) for k, v in sample.bin_paths.items()}, "source_mask": safe_rel(sample.mask_path, dataset_root), "source_preview": safe_rel(sample.preview_path, dataset_root) if sample.preview_path else None, "frame_type": "MULTISPEC", "saved_payload_type": "tensor_npy", "saved_tensor_path": safe_rel(tensor_path, dataset_root), "saved_mask_path": safe_rel(mask_path, dataset_root), "head_masks": { "semantic": { "path": safe_rel(mask_path, dataset_root), "shape": list(head_masks["semantic"].shape), "classes": { "chao": int(class_ids["chao"]), "cana": int(class_ids["cana"]), "erva": int(class_ids["erva"]), }, "ignore_index": int(ignore_id), }, "vegetation": { "path": safe_rel(mask_vegetation_path, dataset_root), "shape": list(head_masks["vegetation"].shape), "classes": { "background": 0, "vegetation": 1, }, "positive_from_semantic": ["cana", "erva"], "ignore_index": int(ignore_id), }, "cana": { "path": safe_rel(mask_cana_path, dataset_root), "shape": list(head_masks["cana"].shape), "classes": { "not_cana": 0, "cana": 1, }, "positive_from_semantic": ["cana"], "ignore_index": int(ignore_id), }, }, "saved_preview_path": safe_rel(preview_path, dataset_root), "saved_payload_dtype": str(tensor.dtype), "saved_payload_shape": list(tensor.shape), "mask_shape": list(mask_ids.shape), "source_channels": SOURCE_CHANNEL_NAMES, "channels": MODEL_INPUT_CHANNELS, "derived_channels": { "enabled": [ name for name in MODEL_INPUT_CHANNELS if name in DERIVED_CHANNEL_NAMES ], "config": copy_json_safe(config.get("derived_channels", {}) or {}), }, "resolution": { "width": int(res[0]), "height": int(res[1]), }, "processing": processing_info, "camera_params_json": module_params_path, "source_capture_meta": { "ts": meta.get("ts"), "sensor_width": meta.get("sensor_width"), "sensor_height": meta.get("sensor_height"), "bayer_pattern": meta.get("bayer_pattern"), "actual_camera_controls": meta.get("actual_camera_controls"), "startup_camera_controls": meta.get("startup_camera_controls"), "radiometric_last_result": meta.get("radiometric_last_result"), "stream_meta": meta.get("stream_meta"), }, } save_json(meta_out_path, out_meta) stats.update(tensor) rows.append({ "group": sample.group, "base": sample.base, "tensor": str(tensor_path), "mask": str(mask_path), "mask_vegetation": str(mask_vegetation_path), "mask_cana": str(mask_cana_path), "meta": str(meta_out_path), "preview": str(preview_path), "quality_status": frame_quality.get("status") if isinstance(frame_quality, dict) else None, }) print(f"[OK] {group_name}/{sample.base} tensor={list(tensor.shape)}") except Exception as e: errors += 1 print(f"[ERRO] {group_name}/{sample.base}: {e}") print(f"[RESUMO] {group_name}: ok={len(rows)} | erros={errors} | skip_quality={skipped_quality}") return rows def write_manifest(path: Path, rows: List[dict]): ensure_dir(path.parent) fieldnames = [ "group", "base", "tensor", "mask", "mask_vegetation", "mask_cana", "meta", "preview", "quality_status", ] with path.open("w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=fieldnames) w.writeheader() w.writerows(rows) def write_norm_stats(stats_path: Path, stats: dict): ensure_dir(stats_path.parent) with stats_path.open("w", encoding="utf-8") as f: json.dump(stats, f, ensure_ascii=False, indent=2) def main(): ap = argparse.ArgumentParser( description="Normaliza RAW_BRUTO OAK-FCC-3 para tensor MULTISPEC final de treino." ) ap.add_argument( "--src-roots", default="dataset/original/group;dataset/augmented/group", help="Raízes de entrada separadas por ';'. Ex: dataset/original/group;dataset/augmented/group" ) ap.add_argument("--out-root", default="dataset") ap.add_argument("--module-params", default=DEFAULT_MODULE_PARAMS) ap.add_argument("--res", default=f"{DEFAULT_RES[0]}x{DEFAULT_RES[1]}", help="Resolução final WxH.") ap.add_argument("--raw-size", default=f"{DEFAULT_RAW_SIZE[0]}x{DEFAULT_RAW_SIZE[1]}", help="Tamanho RAW real WxH.") ap.add_argument("--groups", default=None, help="Grupos separados por vírgula.") ap.add_argument("--clear-dst", action="store_true") ap.add_argument("--skip-bad-quality", action="store_true") ap.add_argument("--manifest", default="") ap.add_argument("--stats-out", default="") args = ap.parse_args() src_roots = [ Path(x.strip()) for x in str(args.src_roots).split(";") if x.strip() ] out_dataset_root = Path(args.out_root) valid_src_roots = [] for src_root in src_roots: if src_root.is_dir(): valid_src_roots.append(src_root) else: print(f"[WARN] src-root não encontrado, ignorando: {src_root}") if not valid_src_roots: raise SystemExit(f"[ERRO] Nenhum src-root válido encontrado: {src_roots}") try: res_w, res_h = [int(x) for x in args.res.lower().split("x")] raw_w, raw_h = [int(x) for x in args.raw_size.lower().split("x")] except Exception: raise SystemExit("[ERRO] Use --res WxH e --raw-size WxH. Ex: --res 512x512 --raw-size 1280x800") res = (res_w, res_h) raw_size = (raw_w, raw_h) output_root = out_dataset_root / f"{res_w}x{res_h}" / "group" if args.clear_dst: print(f"[INFO] Limpando destino: {output_root}") maybe_clear_dir(output_root) labelmap_path = out_dataset_root / "labelmap.txt" if not labelmap_path.exists(): raise SystemExit(f"[ERRO] labelmap não encontrado: {labelmap_path}") cor_para_id, _, _, ignore_rgb = carregar_labelmap_completo(str(labelmap_path)) ignore_id = _infer_ignore_id(ignore_rgb, 255) groups_by_root = [] for src_root in valid_src_roots: groups = list_groups(src_root) if args.groups: want = {g.strip() for g in args.groups.split(",") if g.strip()} groups = [g for g in groups if g in want] if groups: groups_by_root.append((src_root, groups)) if not groups_by_root: raise SystemExit("[ERRO] Nenhum grupo encontrado.") print("============================================") print("Normalize OAK-FCC-3") print(f"SRC : {[str(x) for x in valid_src_roots]}") print(f"OUT : {output_root}") print(f"MODULE PARAM : {args.module_params}") print(f"RES : {res}") print(f"RAW SIZE : {raw_size}") print(f"SOURCE CH : {SOURCE_CHANNEL_NAMES}") print(f"MODEL CH : {MODEL_INPUT_CHANNELS} ({len(MODEL_INPUT_CHANNELS)})") print("GROUPS :") for root, groups in groups_by_root: print(f" - {root}: {', '.join(groups)}") print(f"SKIP BAD : {args.skip_bad_quality}") print("============================================") running_stats = RunningStats() all_rows = [] for src_root, all_groups in groups_by_root: for group_name in all_groups: rows = process_group( group_name=group_name, src_root=src_root, output_root=output_root, res=res, raw_size=raw_size, module_params_arg=args.module_params, cor_para_id=cor_para_id, ignore_id=ignore_id, stats=running_stats, dataset_root=out_dataset_root, skip_bad_quality=args.skip_bad_quality, ) all_rows.extend(rows) manifest_path = Path(args.manifest) if args.manifest else output_root / "normalize_manifest.csv" write_manifest(manifest_path, all_rows) stats = running_stats.result(MODEL_INPUT_CHANNELS) if stats is not None: stats_path = ( Path(args.stats_out) if args.stats_out else Path("backup") / config.get("modelo", "modelo") / config.get("model_name", "model") / config.get("stats_source_tag", "oak_fcc3") / "norm_stats.json" ) write_norm_stats(stats_path, stats) # Também salva uma cópia junto do dataset normalizado. write_norm_stats(output_root / "norm_stats.json", stats) print("\n📊 STATS:") print(json.dumps(stats, ensure_ascii=False, indent=2)) print(f"[OK] norm_stats backup : {stats_path}") print(f"[OK] norm_stats dataset: {output_root / 'norm_stats.json'}") print("\n============================================") print("Normalize finalizado") print(f"Total samples: {len(all_rows)}") print(f"Manifest : {manifest_path}") print("============================================") if __name__ == "__main__": main()