#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ _5_augmentation_raw_oak.py Augmentation multiespectral para OAK-FCC-3 usando os .bin RAW das câmeras, máscaras e metadados do dataset. Objetivo: - Ler amostras em dataset/original/group// - Carregar RGB/RE/NIR a partir dos .bin RAW10 packed - Aplicar augmentation geométrico sincronizado em todos os canais + máscara - Aplicar augmentation radiométrico/físico nos RAWs - Salvar nova amostra em dataset/augmented/group// - Gerar preview RGB novo para inspeção visual Estrutura esperada de entrada: dataset/original/group// bins/ masks/ metas/ previews/ # opcional Estrutura de saída: dataset/augmented/group// bins/ masks/ metas/ previews/ Exemplos: python _5_augmentation_raw_oak.py --copies 3 --clear-dst python _5_augmentation_raw_oak.py ^ --copies 3 ^ --groups chao,chao_cana,chao_erva,chao_cana_erva ^ --clear-dst python _5_augmentation_raw_oak.py ^ --group-copies chao:1,chao_cana:3,chao_erva:3,chao_cana_erva:5 ^ --clear-dst python _5_augmentation_raw_oak.py --dry-run Observações importantes: - Este script NÃO augmenta preview PNG como fonte principal. - O preview é somente consequência visual do RGB RAW augmentado. - A geometria é sempre igual em RGB/RE/NIR/mask. - Radiometria usa mesma base global + pequenas variações por banda/câmera. - O script tenta ser tolerante a nomes de arquivo, mas espera que base da amostra esteja preservada nos nomes dos bins/masks/metas. """ import argparse import copy import json import math import os import random import shutil from dataclasses import dataclass, asdict from pathlib import Path from typing import Dict, List, Optional, Tuple, Any import cv2 import numpy as np from PIL import Image try: from core.raw_processor_core import RawProcessorCore _HAS_RAW_PROCESSOR_CORE = True except Exception: RawProcessorCore = None _HAS_RAW_PROCESSOR_CORE = False _CORE_PREVIEW_CACHE = {} # ============================================================ # Configuração base # ============================================================ IMG_EXTS = (".png", ".jpg", ".jpeg") META_EXTS = (".json",) BIN_EXTS = (".bin",) ROLE_ALIASES = { "rgb": ["rgb", "color", "cor", "cam_a", "cama", "CAM_A"], "re": ["re", "rededge", "red_edge", "red-edge", "cam_b", "camb", "CAM_B"], "nir": ["nir", "ir", "infra", "infrared", "cam_c", "camc", "CAM_C"], } CAM_KEYS = ["CAM_A", "CAM_B", "CAM_C", "cam_a", "cam_b", "cam_c", "cam0", "cam1", "cam2"] DEFAULT_BIT_DEPTH = 10 DEFAULT_BAYER_PATTERN = "BGGR" DEFAULT_RAW_FORMAT = "RAW10_PACKED" # ============================================================ # Data classes # ============================================================ @dataclass class CameraBin: role: str cam_key: str path: Path width: int height: int bit_depth: int = DEFAULT_BIT_DEPTH raw_format: str = DEFAULT_RAW_FORMAT bayer_pattern: str = DEFAULT_BAYER_PATTERN data: Optional[np.ndarray] = None @dataclass class AugParams: seed: int # Geometria, igual para tudo flip_h: bool shift_x_frac: float shift_y_frac: float scale: float rotate_deg: float perspective: bool perspective_strength: float # Radiometria base exposure_mult: float gamma: float # Ganhos por papel espectral rgb_channel_gain: Tuple[float, float, float] re_gain: float nir_gain: float # Efeitos físicos leves shadow_enabled: bool shadow_strength: float shadow_angle_deg: float highlight_enabled: bool highlight_strength: float noise_enabled: bool noise_sigma_dn: float blur_enabled: bool blur_kernel: int # ============================================================ # Utilitários gerais # ============================================================ def read_config_model() -> str: """Tenta ler config.json para manter compatibilidade com seus scripts atuais.""" cfg = Path("config.json") if not cfg.exists(): return "." try: with cfg.open("r", encoding="utf-8") as f: config = json.load(f) return str(config.get("camera", ".")) except Exception: return "." def read_config_value(key: str, default=None): cfg = Path("config.json") if not cfg.exists(): return default try: with cfg.open("r", encoding="utf-8") as f: config = json.load(f) return config.get(key, default) except Exception: return default def ensure_dir(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) def clear_dir(path: Path) -> None: if path.exists(): shutil.rmtree(path) ensure_dir(path) def normalizar_base(stem: str) -> str: """Remove sufixos comuns para casar preview/mask/meta/bin.""" suffixes = [ "_rgb", "_RGB", "_Rgb", "_color", "_COLOR", "_re", "_RE", "_rededge", "_red_edge", "_nir", "_NIR", "_cam_a", "_cam_b", "_cam_c", "_CAM_A", "_CAM_B", "_CAM_C", "_cam0", "_cam1", "_cam2", "_CAM0", "_CAM1", "_CAM2", "_image", "_img", "_frame", "_preview", "_previews", "_mask", "_masks", "_seg", "_SEG", "_segment", "_segmentacao", "_meta", "_metadata", ] out = stem changed = True while changed: changed = False for sfx in suffixes: if out.endswith(sfx): out = out[: -len(sfx)] changed = True break return out def list_groups(src_root: Path) -> List[str]: if not src_root.exists(): return [] groups = [] for p in sorted(src_root.iterdir()): if not p.is_dir(): continue if (p / "bins").is_dir() and (p / "masks").is_dir() and (p / "metas").is_dir(): groups.append(p.name) return groups def map_files_by_base(folder: Path, exts: Tuple[str, ...]) -> Dict[str, Path]: by_base: Dict[str, Path] = {} if not folder.is_dir(): return by_base priority = { ".json": 0, ".png": 0, ".bin": 0, ".jpg": 1, ".jpeg": 2, } for p in sorted(folder.iterdir()): if not p.is_file(): continue ext = p.suffix.lower() if ext not in exts: continue base = normalizar_base(p.stem) if base not in by_base: by_base[base] = p else: cur_ext = by_base[base].suffix.lower() if priority.get(ext, 99) < priority.get(cur_ext, 99): by_base[base] = p return by_base 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 load_mask(path: Path) -> np.ndarray: im = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) if im is None: raise FileNotFoundError(path) if im.ndim == 2: return im if im.shape[2] == 4: im = cv2.cvtColor(im, cv2.COLOR_BGRA2RGBA) else: im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB) return im def save_mask(path: Path, mask: np.ndarray) -> None: ensure_dir(path.parent) if mask.ndim == 2: Image.fromarray(mask).save(path) else: Image.fromarray(mask).save(path) # ============================================================ # RAW10 pack/unpack # ============================================================ def unpack_raw10_packed(buf: bytes, width: int, height: int) -> np.ndarray: """ Desempacota MIPI RAW10 packed no padrão mais comum usado por câmeras/DepthAI: Para cada grupo de 5 bytes: byte0 = bits [9:2] do pixel 0 byte1 = bits [9:2] do pixel 1 byte2 = bits [9:2] do pixel 2 byte3 = bits [9:2] do pixel 3 byte4 = bits [1:0] dos 4 pixels, empilhados em pares de bits Retorna uint16 com valores 0..1023. Importante: - A versão anterior tratava byte0..byte3 como bits baixos e byte4 como bits altos. Isso embaralha o RAW e gera exatamente aquele padrão de ruído/colorido sem imagem. """ expected_pixels = width * height arr = np.frombuffer(buf, dtype=np.uint8) expected_bytes = (expected_pixels * 10 + 7) // 8 if arr.size < expected_bytes: raise ValueError( f"RAW10 menor que esperado: bytes={arr.size}, esperado>={expected_bytes}, " f"shape={width}x{height}" ) arr = arr[:expected_bytes] groups = arr.size // 5 main = arr[: groups * 5].reshape(-1, 5).astype(np.uint16) p0 = (main[:, 0] << 2) | ((main[:, 4] >> 0) & 0x03) p1 = (main[:, 1] << 2) | ((main[:, 4] >> 2) & 0x03) p2 = (main[:, 2] << 2) | ((main[:, 4] >> 4) & 0x03) p3 = (main[:, 3] << 2) | ((main[:, 4] >> 6) & 0x03) out = np.empty(groups * 4, dtype=np.uint16) out[0::4] = p0 out[1::4] = p1 out[2::4] = p2 out[3::4] = p3 if out.size < expected_pixels: raise ValueError(f"RAW10 gerou pixels insuficientes: {out.size} < {expected_pixels}") return out[:expected_pixels].reshape(height, width) def pack_raw10_packed(raw: np.ndarray) -> bytes: """ Empacota uint16 0..1023 para MIPI RAW10 packed no mesmo padrão do unpack: byte0..byte3 = bits altos [9:2] byte4 = bits baixos [1:0] de p0,p1,p2,p3 """ flat = np.asarray(raw, dtype=np.uint16).reshape(-1) flat = np.clip(flat, 0, 1023).astype(np.uint16) pad = (-flat.size) % 4 if pad: flat = np.pad(flat, (0, pad), mode="edge") p = flat.reshape(-1, 4) out = np.empty((p.shape[0], 5), dtype=np.uint8) out[:, 0] = ((p[:, 0] >> 2) & 0xFF).astype(np.uint8) out[:, 1] = ((p[:, 1] >> 2) & 0xFF).astype(np.uint8) out[:, 2] = ((p[:, 2] >> 2) & 0xFF).astype(np.uint8) out[:, 3] = ((p[:, 3] >> 2) & 0xFF).astype(np.uint8) out[:, 4] = ( ((p[:, 0] & 0x03) << 0) | ((p[:, 1] & 0x03) << 2) | ((p[:, 2] & 0x03) << 4) | ((p[:, 3] & 0x03) << 6) ).astype(np.uint8) return out.reshape(-1).tobytes() def pack_raw10_packed_array(raw: np.ndarray) -> np.ndarray: """ RAW16 0..1023 -> ndarray uint8 RAW10 packed shape=(height, packed_width). Esse é o formato esperado pelo RawProcessorCore.decode_stream_cameras. """ h, w = raw.shape[:2] packed = np.frombuffer(pack_raw10_packed(raw), dtype=np.uint8) packed_w = int(math.ceil(int(w) * 10 / 8)) return packed.reshape(int(h), packed_w) def read_raw_bin(path: Path, width: int, height: int, raw_format: str) -> np.ndarray: raw_format_u = (raw_format or "").upper() buf = path.read_bytes() if "RAW10" in raw_format_u or "PACKED" in raw_format_u: return unpack_raw10_packed(buf, width, height) # Fallback: RAW16 little-endian com valores possivelmente 0..1023. arr = np.frombuffer(buf, dtype=np.uint16) expected = width * height if arr.size < expected: raise ValueError(f"RAW16 menor que esperado em {path}: {arr.size} < {expected}") return arr[:expected].reshape(height, width) def write_raw_bin(path: Path, raw: np.ndarray, raw_format: str) -> None: ensure_dir(path.parent) raw_format_u = (raw_format or "").upper() raw10 = np.clip(np.rint(raw), 0, 1023).astype(np.uint16) if "RAW10" in raw_format_u or "PACKED" in raw_format_u: path.write_bytes(pack_raw10_packed(raw10)) else: path.write_bytes(raw10.astype(np.uint16).tobytes()) # ============================================================ # Metadados e descoberta de bins # ============================================================ def _text_has_any(text: str, needles: List[str]) -> bool: t = text.lower() return any(n.lower() in t for n in needles) def role_from_text(text: str) -> Optional[str]: for role, aliases in ROLE_ALIASES.items(): if _text_has_any(text, aliases): return role return None def normalize_role(role: Optional[str], cam_key: Optional[str] = None) -> Optional[str]: if role: r = str(role).lower().strip() if r in ("rgb", "color", "cor"): return "rgb" if r in ("re", "rededge", "red_edge", "red-edge"): return "re" if r in ("nir", "ir", "infrared", "infra"): return "nir" if cam_key: ck = str(cam_key).upper() # Convenção atual mais provável: CAM_A=RGB, CAM_B=RE, CAM_C=NIR. if ck == "CAM_A": return "rgb" if ck == "CAM_B": return "re" if ck == "CAM_C": return "nir" return None def extract_camera_info(meta: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: """ Tenta extrair camera_info de vários formatos possíveis. Retorna dict cam_key -> info. """ candidates = [] for key in ["camera_info", "cameras", "camera_infos", "payload_sources_info"]: if isinstance(meta.get(key), dict): candidates.append(meta[key]) # Algumas versões guardam dentro de meta["capture"] ou meta["meta"] for parent_key in ["capture", "meta", "frame_meta"]: parent = meta.get(parent_key) if isinstance(parent, dict): for key in ["camera_info", "cameras", "camera_infos"]: if isinstance(parent.get(key), dict): candidates.append(parent[key]) if candidates: cam_info = candidates[0] out = {} for k, v in cam_info.items(): if isinstance(v, dict): out[str(k)] = v return out # Fallback mínimo caso o meta já tenha campos diretos. out = {} for ck in CAM_KEYS: if isinstance(meta.get(ck), dict): out[ck] = meta[ck] return out def get_int_any(d: Dict[str, Any], keys: List[str], default: Optional[int] = None) -> Optional[int]: for k in keys: if k in d and d[k] is not None: try: return int(d[k]) except Exception: pass return default def get_str_any(d: Dict[str, Any], keys: List[str], default: str = "") -> str: for k in keys: if k in d and d[k] is not None: return str(d[k]) return default def find_bin_for_camera(bins_dir: Path, base: str, role: str, cam_key: str) -> Optional[Path]: """ Localiza o bin de uma câmera por base + role/cam_key. É tolerante a nomes tipo: base_CAM_A.bin base_rgb.bin base_cam0.bin base_arquivo_CAM_A.bin """ if not bins_dir.is_dir(): return None role_aliases = ROLE_ALIASES.get(role, [role]) cam_key_variants = {cam_key, cam_key.upper(), cam_key.lower()} candidates = [] for p in sorted(bins_dir.glob("*.bin")): name = p.stem name_l = name.lower() base_l = base.lower() if not name_l.startswith(base_l): continue score = 0 if name_l == base_l: score += 1 if any(v.lower() in name_l for v in cam_key_variants): score += 10 if any(a.lower() in name_l for a in role_aliases): score += 12 # Compatibilidade com cam0/cam1/cam2 caso necessário. if role == "rgb" and "cam0" in name_l: score += 3 if role == "re" and "cam1" in name_l: score += 3 if role == "nir" and "cam2" in name_l: score += 3 if score > 0: candidates.append((score, p)) if not candidates: return None candidates.sort(key=lambda x: x[0], reverse=True) return candidates[0][1] def discover_sample_cameras(meta: Dict[str, Any], bins_dir: Path, base: str) -> Dict[str, CameraBin]: cam_info = extract_camera_info(meta) out: Dict[str, CameraBin] = {} # Primeiro caminho: usar camera_info do meta. for cam_key, info in cam_info.items(): role = normalize_role(info.get("role") or info.get("papel") or info.get("type"), cam_key) if role not in ("rgb", "re", "nir"): role = role_from_text(cam_key) or role_from_text(json.dumps(info, ensure_ascii=False)) if role not in ("rgb", "re", "nir"): continue width = get_int_any(info, ["width", "w", "sensor_width", "cols", "shape_w"]) height = get_int_any(info, ["height", "h", "sensor_height", "rows", "shape_h"]) if width is None or height is None: # Fallback para campos globais do meta. width = get_int_any(meta, ["width", "sensor_width", "raw_width", "w"]) height = get_int_any(meta, ["height", "sensor_height", "raw_height", "h"]) if width is None or height is None: continue bit_depth = get_int_any(info, ["bit_depth", "bits", "raw_bit_depth"], DEFAULT_BIT_DEPTH) raw_format = get_str_any(info, ["raw_format", "format", "encoding"], DEFAULT_RAW_FORMAT) bayer_pattern = get_str_any(info, ["bayer_pattern", "bayer", "pattern"], DEFAULT_BAYER_PATTERN) bin_path = find_bin_for_camera(bins_dir, base, role, cam_key) if bin_path is None: continue out[role] = CameraBin( role=role, cam_key=str(cam_key), path=bin_path, width=int(width), height=int(height), bit_depth=int(bit_depth or DEFAULT_BIT_DEPTH), raw_format=raw_format or DEFAULT_RAW_FORMAT, bayer_pattern=bayer_pattern or DEFAULT_BAYER_PATTERN, ) # Segundo caminho: se o meta não ajudou, tenta descobrir por nome. if len(out) < 3: global_w = get_int_any(meta, ["width", "sensor_width", "raw_width", "w"]) global_h = get_int_any(meta, ["height", "sensor_height", "raw_height", "h"]) for role, cam_key in [("rgb", "CAM_A"), ("re", "CAM_B"), ("nir", "CAM_C")]: if role in out: continue if global_w is None or global_h is None: continue bin_path = find_bin_for_camera(bins_dir, base, role, cam_key) if bin_path is None: continue out[role] = CameraBin( role=role, cam_key=cam_key, path=bin_path, width=int(global_w), height=int(global_h), bit_depth=DEFAULT_BIT_DEPTH, raw_format=DEFAULT_RAW_FORMAT, bayer_pattern=DEFAULT_BAYER_PATTERN, ) return out # ============================================================ # Preview RGB simples a partir de RAW Bayer # ============================================================ def _normalize_preview_channel(x: np.ndarray, lo_p: float = 1.0, hi_p: float = 99.5, gamma: float = 1.0 / 2.2) -> np.ndarray: """ Normalização visual robusta para preview, não científica. Transforma um canal RAW/float em uint8 bonito para inspeção humana. """ y = x.astype(np.float32) lo = float(np.percentile(y, lo_p)) hi = float(np.percentile(y, hi_p)) if hi <= lo: hi = lo + 1.0 y = np.clip((y - lo) / (hi - lo), 0.0, 1.0) if gamma and abs(gamma - 1.0) > 1e-6: y = np.power(y, gamma) return np.clip(y * 255.0, 0, 255).astype(np.uint8) def _get_bayer_cv2_code_like_core(bayer_pattern: str | None, algorithm: str = "ea") -> int: """ Replica o mapeamento usado pelo RawProcessorCore._get_bayer_cv2_code. Observação importante: O mapeamento OpenCV parece invertido à primeira vista, mas é exatamente o contrato usado no core atual para gerar o RGB do treinamento/inferência. """ p = str(bayer_pattern or DEFAULT_BAYER_PATTERN or "RGGB").upper() algo = str(algorithm or "ea").lower() if algo in ("bilinear", "linear", "fast", "normal"): code_map = { "BGGR": cv2.COLOR_BayerRG2RGB, "RGGB": cv2.COLOR_BayerBG2RGB, "GRBG": cv2.COLOR_BayerGR2RGB, "GBRG": cv2.COLOR_BayerGB2RGB, } elif algo in ("ea", "edge_aware", "edge-aware"): code_map = { "BGGR": cv2.COLOR_BayerRG2RGB_EA, "RGGB": cv2.COLOR_BayerBG2RGB_EA, "GRBG": cv2.COLOR_BayerGR2RGB_EA, "GBRG": cv2.COLOR_BayerGB2RGB_EA, } else: raise ValueError(f"demosaic_algorithm inválido: {algorithm}") if p not in code_map: raise ValueError(f"Padrão Bayer não suportado para demosaic: {p}") return code_map[p] def demosaic_raw10_to_rgb01(raw: np.ndarray, bayer_pattern: str = DEFAULT_BAYER_PATTERN, algorithm: str = "ea") -> np.ndarray: """ RAW Bayer 0..1023 -> RGB HWC float32 0..1 usando o mesmo mapeamento do core. """ raw_u16 = np.clip(raw, 0, 1023).astype(np.uint16) cv_code = _get_bayer_cv2_code_like_core(bayer_pattern, algorithm=algorithm) rgb16 = cv2.cvtColor(raw_u16, cv_code) rgb = rgb16.astype(np.float32) / 1023.0 return np.clip(rgb, 0.0, 1.0).astype(np.float32, copy=False) def remosaic_rgb01_to_bayer_raw10(rgb: np.ndarray, bayer_pattern: str = DEFAULT_BAYER_PATTERN) -> np.ndarray: """ RGB HWC float32 0..1 -> mosaico Bayer RAW10 uint16. Isso é necessário porque NÃO podemos aplicar warp diretamente no mosaico Bayer. O fluxo correto para augmentar RGB RAW é: raw Bayer -> demosaic RGB -> augmentation -> remosaic Bayer -> pack RAW10. Não é uma reconstrução óptica perfeita, mas preserva o contrato RAW10 Bayer para o normalize/RawProcessorCore e evita o xadrez colorido causado por interpolar diretamente o mosaico. """ rgb = np.clip(rgb.astype(np.float32), 0.0, 1.0) h, w = rgb.shape[:2] raw = np.empty((h, w), dtype=np.float32) p = str(bayer_pattern or DEFAULT_BAYER_PATTERN or "RGGB").upper() if p == "RGGB": raw[0::2, 0::2] = rgb[0::2, 0::2, 0] # R raw[0::2, 1::2] = rgb[0::2, 1::2, 1] # G raw[1::2, 0::2] = rgb[1::2, 0::2, 1] # G raw[1::2, 1::2] = rgb[1::2, 1::2, 2] # B elif p == "BGGR": raw[0::2, 0::2] = rgb[0::2, 0::2, 2] # B raw[0::2, 1::2] = rgb[0::2, 1::2, 1] # G raw[1::2, 0::2] = rgb[1::2, 0::2, 1] # G raw[1::2, 1::2] = rgb[1::2, 1::2, 0] # R elif p == "GRBG": raw[0::2, 0::2] = rgb[0::2, 0::2, 1] # G raw[0::2, 1::2] = rgb[0::2, 1::2, 0] # R raw[1::2, 0::2] = rgb[1::2, 0::2, 2] # B raw[1::2, 1::2] = rgb[1::2, 1::2, 1] # G elif p == "GBRG": raw[0::2, 0::2] = rgb[0::2, 0::2, 1] # G raw[0::2, 1::2] = rgb[0::2, 1::2, 2] # B raw[1::2, 0::2] = rgb[1::2, 0::2, 0] # R raw[1::2, 1::2] = rgb[1::2, 1::2, 1] # G else: raise ValueError(f"Padrão Bayer não suportado para remosaic: {p}") return np.clip(np.rint(raw * 1023.0), 0, 1023).astype(np.uint16) def rgb01_to_preview_rgb8(rgb: np.ndarray) -> np.ndarray: rgb = np.clip(rgb.astype(np.float32), 0.0, 1.0) rgb8 = np.zeros(rgb.shape, dtype=np.uint8) for c in range(3): rgb8[:, :, c] = _normalize_preview_channel(rgb[:, :, c], lo_p=1.0, hi_p=99.5, gamma=1.0 / 2.2) return rgb8 def debayer_raw10_to_rgb8(raw: np.ndarray, bayer_pattern: str = DEFAULT_BAYER_PATTERN) -> np.ndarray: """ Preview visual a partir do RAW Bayer, usando o mesmo mapeamento Bayer do core. """ rgb01 = demosaic_raw10_to_rgb01(raw, bayer_pattern=bayer_pattern, algorithm="ea") return rgb01_to_preview_rgb8(rgb01) def _resolve_module_params_path(meta: Dict[str, Any]) -> Optional[str]: candidates = [] for key in ("camera_params_json", "module_params_json"): v = meta.get(key) if v: candidates.append(str(v)) stream_meta = meta.get("stream_meta") if isinstance(meta.get("stream_meta"), dict) else None if stream_meta: for key in ("camera_params_json", "module_params_json"): v = stream_meta.get(key) if v: candidates.append(str(v)) cfg_module = read_config_value("module_params_json", None) if cfg_module: candidates.append(str(cfg_module)) candidates.append("calibration/module_params.json") for c in candidates: p = Path(c) if p.is_file(): return str(p) return None def _build_processing_meta_for_core(meta: Dict[str, Any], cameras: Dict[str, CameraBin]) -> Dict[str, Any]: """ Monta o meta que o RawProcessorCore espera no build_infer_tensor_from_stream. O validador faz isso a partir de stream_meta + camera_info. Aqui criamos/atualizamos esse pacote usando os bins augmentados. """ stream_meta = copy.deepcopy(meta.get("stream_meta") if isinstance(meta.get("stream_meta"), dict) else {}) camera_info = stream_meta.get("camera_info") if not isinstance(camera_info, dict): camera_info = copy.deepcopy(meta.get("camera_info") if isinstance(meta.get("camera_info"), dict) else {}) camera_frames = stream_meta.get("camera_frames") if not isinstance(camera_frames, dict): camera_frames = copy.deepcopy(meta.get("camera_frames") if isinstance(meta.get("camera_frames"), dict) else {}) for role, cam in cameras.items(): info = dict(camera_info.get(cam.cam_key, {}) or {}) info.update({ "role": role, "width": int(cam.width), "height": int(cam.height), "bit_depth": int(cam.bit_depth), "raw_format": cam.raw_format or DEFAULT_RAW_FORMAT, "packed": True, "channels": 1, "bayer_pattern": cam.bayer_pattern or DEFAULT_BAYER_PATTERN, }) camera_info[cam.cam_key] = info camera_frames[cam.cam_key] = dict(info) stream_meta["frame_type"] = "RAW_BRUTO" stream_meta["camera_info"] = camera_info stream_meta["camera_frames"] = camera_frames stream_meta["payload_sources"] = [cameras[r].cam_key for r in ("rgb", "re", "nir") if r in cameras] # A normalização radiométrica do core busca frame_controls no próprio meta. # Preservamos os controles originais se existirem. for key in ("frame_controls", "actual_camera_controls", "startup_camera_controls"): if key not in stream_meta and isinstance(meta.get(key), dict): stream_meta[key] = copy.deepcopy(meta[key]) return stream_meta def get_preview_core_cached(sensor_width: int, sensor_height: int, bayer: str, calib_path: str): """ Reutiliza o RawProcessorCore entre amostras para evitar recarregar flat-field e recriar caches de gain/remap toda hora. """ if not _HAS_RAW_PROCESSOR_CORE: return None key = ( int(sensor_width), int(sensor_height), str(bayer).upper(), str(Path(calib_path).resolve()) if calib_path else "", ) core = _CORE_PREVIEW_CACHE.get(key) if core is not None: return core core = RawProcessorCore( sensor_width=int(sensor_width), sensor_height=int(sensor_height), bayer_pattern=str(bayer).upper(), calibration_json_path=calib_path, ) _CORE_PREVIEW_CACHE[key] = core return core def build_core_preview_from_augmented_raws(meta: Dict[str, Any], cameras: Dict[str, CameraBin]) -> Optional[np.ndarray]: """ Gera o preview salvo passando os bins augmentados pelo RawProcessorCore, igual ao check_saved_files faz para montar o MULTISPEC final. Saída: RGB uint8 do tensor final [R,G,B], pronto para salvar com PIL. """ if not _HAS_RAW_PROCESSOR_CORE: return None required = ("rgb", "re", "nir") if any(r not in cameras or cameras[r].data is None for r in required): return None sensor_width = int(meta.get("sensor_width") or cameras["rgb"].width) sensor_height = int(meta.get("sensor_height") or cameras["rgb"].height) bayer = str(meta.get("bayer_pattern") or cameras["rgb"].bayer_pattern or DEFAULT_BAYER_PATTERN) calib_path = _resolve_module_params_path(meta) if not calib_path: return None frame = {} for role in required: cam = cameras[role] frame[cam.cam_key] = pack_raw10_packed_array(cam.data) processing_meta = _build_processing_meta_for_core(meta, cameras) core = get_preview_core_cached( sensor_width=sensor_width, sensor_height=sensor_height, bayer=bayer, calib_path=calib_path, ) if core is None: return None tensor = core.build_infer_tensor_from_stream(frame, processing_meta, 5) if tensor is None or not isinstance(tensor, np.ndarray) or tensor.ndim != 3 or tensor.shape[0] < 3: return None rgb_hwc = np.transpose(tensor[:3].astype(np.float32), (1, 2, 0)) rgb8 = np.clip(rgb_hwc * 255.0, 0, 255).astype(np.uint8) return rgb8 def build_beauty_preview_from_augmented_raws(cameras: Dict[str, CameraBin]) -> np.ndarray: """ Fallback visual a partir dos RAWs augmentados, sem passar pelo core. Preferimos build_core_preview_from_augmented_raws sempre que possível. """ rgb_cam = cameras.get("rgb") if rgb_cam is None or rgb_cam.data is None: raise RuntimeError("Sem RAW RGB para gerar preview.") return debayer_raw10_to_rgb8(rgb_cam.data, rgb_cam.bayer_pattern) def save_preview_from_augmented_raws(path: Path, cameras: Dict[str, CameraBin], meta: Optional[Dict[str, Any]] = None) -> str: ensure_dir(path.parent) method = "fallback_cam_a_debayer_preview" rgb8 = None if meta is not None: try: rgb8 = build_core_preview_from_augmented_raws(meta, cameras) if rgb8 is not None: method = "raw_processor_core_multispec_rgb_final" except Exception as e: print(f"[WARN] Preview via RawProcessorCore falhou em {path.name}: {e}. Usando fallback CAM_A.") rgb8 = None if rgb8 is None: rgb8 = build_beauty_preview_from_augmented_raws(cameras) Image.fromarray(rgb8).save(path) return method # ============================================================ # Augmentation # ============================================================ def sample_aug_params(rng: random.Random, seed_value: int) -> AugParams: # Geometria conservadora. perspective = rng.random() < 0.15 blur_enabled = rng.random() < 0.15 return AugParams( seed=seed_value, flip_h=rng.random() < 0.50, shift_x_frac=rng.uniform(-0.015, 0.015), shift_y_frac=rng.uniform(-0.015, 0.015), scale=rng.uniform(0.92, 1.08), rotate_deg=rng.uniform(-4.0, 4.0), perspective=perspective, perspective_strength=rng.uniform(0.002, 0.012) if perspective else 0.0, exposure_mult=rng.uniform(0.75, 1.30), gamma=rng.uniform(0.92, 1.08), rgb_channel_gain=( rng.uniform(0.92, 1.08), rng.uniform(0.92, 1.08), rng.uniform(0.92, 1.08), ), re_gain=rng.uniform(0.85, 1.20), nir_gain=rng.uniform(0.85, 1.20), shadow_enabled=rng.random() < 0.25, shadow_strength=rng.uniform(0.12, 0.35), shadow_angle_deg=rng.uniform(0.0, 180.0), highlight_enabled=rng.random() < 0.15, highlight_strength=rng.uniform(0.05, 0.18), noise_enabled=rng.random() < 0.30, noise_sigma_dn=rng.uniform(1.5, 6.0), blur_enabled=blur_enabled, blur_kernel=3 if blur_enabled else 1, ) def build_affine_matrix(width: int, height: int, p: AugParams) -> np.ndarray: cx = width * 0.5 cy = height * 0.5 M = cv2.getRotationMatrix2D((cx, cy), p.rotate_deg, p.scale) M[0, 2] += p.shift_x_frac * width M[1, 2] += p.shift_y_frac * height return M def warp_image(img: np.ndarray, M: np.ndarray, out_size: Tuple[int, int], is_mask: bool) -> np.ndarray: interp = cv2.INTER_NEAREST if is_mask else cv2.INTER_LINEAR return cv2.warpAffine( img, M, out_size, flags=interp, borderMode=cv2.BORDER_REFLECT_101, ) def build_perspective_matrix(width: int, height: int, p: AugParams, rng: random.Random) -> Optional[np.ndarray]: if not p.perspective: return None s = p.perspective_strength dx = width * s dy = height * s src = np.float32([ [0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1], ]) dst = src + np.float32([ [rng.uniform(-dx, dx), rng.uniform(-dy, dy)], [rng.uniform(-dx, dx), rng.uniform(-dy, dy)], [rng.uniform(-dx, dx), rng.uniform(-dy, dy)], [rng.uniform(-dx, dx), rng.uniform(-dy, dy)], ]) return cv2.getPerspectiveTransform(src, dst) def warp_perspective(img: np.ndarray, H: np.ndarray, out_size: Tuple[int, int], is_mask: bool) -> np.ndarray: interp = cv2.INTER_NEAREST if is_mask else cv2.INTER_LINEAR return cv2.warpPerspective( img, H, out_size, flags=interp, borderMode=cv2.BORDER_REFLECT_101, ) def apply_geometry_raw(raw: np.ndarray, p: AugParams, rng: random.Random) -> np.ndarray: h, w = raw.shape[:2] out = raw if p.flip_h: out = cv2.flip(out, 1) M = build_affine_matrix(w, h, p) out = warp_image(out, M, (w, h), is_mask=False) H = build_perspective_matrix(w, h, p, rng) if H is not None: out = warp_perspective(out, H, (w, h), is_mask=False) return out def apply_geometry_mask(mask: np.ndarray, p: AugParams, rng: random.Random) -> np.ndarray: h, w = mask.shape[:2] out = mask if p.flip_h: out = cv2.flip(out, 1) M = build_affine_matrix(w, h, p) out = warp_image(out, M, (w, h), is_mask=True) H = build_perspective_matrix(w, h, p, rng) if H is not None: out = warp_perspective(out, H, (w, h), is_mask=True) return out def make_spatial_light_map(shape: Tuple[int, int], p: AugParams) -> np.ndarray: h, w = shape yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) xx = (xx / max(w - 1, 1)) - 0.5 yy = (yy / max(h - 1, 1)) - 0.5 light = np.ones((h, w), dtype=np.float32) if p.shadow_enabled: theta = math.radians(p.shadow_angle_deg) direction = math.cos(theta) * xx + math.sin(theta) * yy direction = (direction - direction.min()) / max(direction.max() - direction.min(), 1e-6) shadow = 1.0 - p.shadow_strength * direction light *= shadow if p.highlight_enabled: # Mancha larga e suave, simulando região de sol/reflexo. cx = np.random.uniform(-0.25, 0.25) cy = np.random.uniform(-0.25, 0.25) sigma = np.random.uniform(0.20, 0.42) d2 = (xx - cx) ** 2 + (yy - cy) ** 2 blob = np.exp(-d2 / (2 * sigma * sigma)) light *= 1.0 + p.highlight_strength * blob return np.clip(light, 0.50, 1.45).astype(np.float32) def apply_gamma_raw(raw: np.ndarray, gamma: float) -> np.ndarray: if abs(gamma - 1.0) < 1e-3: return raw x = np.clip(raw.astype(np.float32) / 1023.0, 0, 1) x = np.power(x, gamma) return x * 1023.0 def apply_bayer_channel_gains(raw: np.ndarray, gains_rgb: Tuple[float, float, float], pattern: str) -> np.ndarray: """ Aplica ganhos aproximados por canal em mosaico Bayer. Para preview/treino, isso simula variação de balanço/cor no sensor RGB. """ out = raw.astype(np.float32).copy() r_gain, g_gain, b_gain = gains_rgb pat = (pattern or DEFAULT_BAYER_PATTERN).upper() if pat == "RGGB": out[0::2, 0::2] *= r_gain out[0::2, 1::2] *= g_gain out[1::2, 0::2] *= g_gain out[1::2, 1::2] *= b_gain elif pat == "BGGR": out[0::2, 0::2] *= b_gain out[0::2, 1::2] *= g_gain out[1::2, 0::2] *= g_gain out[1::2, 1::2] *= r_gain elif pat == "GRBG": out[0::2, 0::2] *= g_gain out[0::2, 1::2] *= r_gain out[1::2, 0::2] *= b_gain out[1::2, 1::2] *= g_gain elif pat == "GBRG": out[0::2, 0::2] *= g_gain out[0::2, 1::2] *= b_gain out[1::2, 0::2] *= r_gain out[1::2, 1::2] *= g_gain else: out *= float(np.mean(gains_rgb)) return out def apply_radiometry_rgb01(rgb: np.ndarray, p: AugParams, rng_np: np.random.Generator, light_map_cache: Dict[Tuple[int, int], np.ndarray]) -> np.ndarray: """ Radiometria para RGB já demosaicado. Evita mexer no mosaico Bayer diretamente. """ h, w = rgb.shape[:2] x = np.clip(rgb.astype(np.float32), 0.0, 1.0) x *= np.float32(p.exposure_mult) gains = np.array(p.rgb_channel_gain, dtype=np.float32).reshape(1, 1, 3) x *= gains key = (h, w) if key not in light_map_cache: light_map_cache[key] = make_spatial_light_map((h, w), p) x *= light_map_cache[key][:, :, None] if abs(p.gamma - 1.0) > 1e-3: x = np.power(np.clip(x, 0.0, 1.0), p.gamma) if p.blur_enabled and p.blur_kernel >= 3: x = cv2.GaussianBlur(x, (p.blur_kernel, p.blur_kernel), 0) if p.noise_enabled: # Converte sigma DN RAW10 para escala 0..1. sigma01 = float(p.noise_sigma_dn) / 1023.0 noise = rng_np.normal(0.0, sigma01, size=x.shape).astype(np.float32) x += noise return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False) def apply_radiometry_raw(raw: np.ndarray, role: str, p: AugParams, cam: CameraBin, rng_np: np.random.Generator, light_map_cache: Dict[Tuple[int, int], np.ndarray]) -> np.ndarray: h, w = raw.shape[:2] x = raw.astype(np.float32) # Base global de exposição: muda todo mundo junto. x *= p.exposure_mult # Variação por papel espectral/canal. # OBS: RGB não deve passar por aqui, porque RGB Bayer precisa ser demosaicado # antes de qualquer warp/radiometria por canal. if role == "re": x *= p.re_gain elif role == "nir": x *= p.nir_gain # Mesmo mapa espacial de luz por tamanho, para manter coerência física. key = (h, w) if key not in light_map_cache: light_map_cache[key] = make_spatial_light_map((h, w), p) x *= light_map_cache[key] # Gamma leve, opcional. Em RAW científico puro, gamma seria discutível. # Mantemos bem pequeno para simular resposta/exposição não ideal. x = apply_gamma_raw(x, p.gamma) if p.blur_enabled and p.blur_kernel >= 3: x = cv2.GaussianBlur(x, (p.blur_kernel, p.blur_kernel), 0) if p.noise_enabled: noise = rng_np.normal(0.0, p.noise_sigma_dn, size=x.shape).astype(np.float32) x += noise return np.clip(x, 0, 1023).astype(np.uint16) # ============================================================ # Processamento de amostra/grupo # ============================================================ def validate_mask_colors(mask_before: np.ndarray, mask_after: np.ndarray, name: str) -> None: if mask_before.ndim == 2 or mask_after.ndim == 2: before_colors = int(np.unique(mask_before.reshape(-1)).size) after_colors = int(np.unique(mask_after.reshape(-1)).size) else: before_colors = len(set(map(tuple, mask_before.reshape(-1, mask_before.shape[2])))) after_colors = len(set(map(tuple, mask_after.reshape(-1, mask_after.shape[2])))) if after_colors > max(before_colors * 3, 64): print( f"[WARN] {name}: máscara ganhou muitas cores. " f"antes={before_colors}, depois={after_colors}. Confira interpolação NEAREST." ) def save_aug_meta( meta: Dict[str, Any], out_path: Path, base: str, new_base: str, params: AugParams, cameras: Dict[str, CameraBin], preview_method: Optional[str] = None, ) -> None: out = copy.deepcopy(meta) out["synthetic"] = True out["augmentation"] = { "script": "_5_augmentation_raw_oak.py", "parent_base": base, "new_base": new_base, "params": asdict(params), } # Deixa o JSON compatível com o check_saved_files / validador. out["saved_payload_type"] = "raw_native_multi" out["saved_preview_method"] = preview_method or "unknown" out["saved_payload_paths"] = {} out["saved_payload_shapes"] = {} out["saved_payload_dtypes"] = {} stream_meta = copy.deepcopy(out.get("stream_meta") if isinstance(out.get("stream_meta"), dict) else {}) camera_info = stream_meta.get("camera_info") if isinstance(stream_meta.get("camera_info"), dict) else {} camera_frames = stream_meta.get("camera_frames") if isinstance(stream_meta.get("camera_frames"), dict) else {} out["augmented_bins"] = {} for role, cam in cameras.items(): packed_w = int(math.ceil(int(cam.width) * 10 / 8)) fname = f"{new_base}_{cam.cam_key}.bin" out["saved_payload_paths"][cam.cam_key] = fname out["saved_payload_shapes"][cam.cam_key] = [int(cam.height), int(packed_w)] out["saved_payload_dtypes"][cam.cam_key] = "uint8" cam_info = dict(camera_info.get(cam.cam_key, {}) or {}) cam_info.update({ "role": role, "width": int(cam.width), "height": int(cam.height), "bit_depth": int(cam.bit_depth), "raw_format": cam.raw_format or DEFAULT_RAW_FORMAT, "packed": True, "channels": 1, "bayer_pattern": cam.bayer_pattern or DEFAULT_BAYER_PATTERN, }) camera_info[cam.cam_key] = cam_info camera_frames[cam.cam_key] = dict(cam_info) out["augmented_bins"][role] = { "cam_key": cam.cam_key, "filename": fname, "role": role, "width": int(cam.width), "height": int(cam.height), "bit_depth": int(cam.bit_depth), "raw_format": cam.raw_format, "bayer_pattern": cam.bayer_pattern, } stream_meta["frame_type"] = "RAW_BRUTO" stream_meta["camera_info"] = camera_info stream_meta["camera_frames"] = camera_frames stream_meta["payload_sources"] = [cameras[r].cam_key for r in ("rgb", "re", "nir") if r in cameras] out["stream_meta"] = stream_meta save_json(out_path, out) def process_one_sample( base: str, group_name: str, src_group: Path, dst_group: Path, mask_path: Path, meta_path: Path, copies: int, rng_global: random.Random, dry_run: bool = False, ) -> Tuple[int, int]: bins_dir = src_group / "bins" meta = load_json(meta_path) cameras = discover_sample_cameras(meta, bins_dir, base) required = ["rgb", "re", "nir"] missing = [r for r in required if r not in cameras] if missing: print(f"[WARN] [{group_name}] {base}: câmeras ausentes {missing}. Pulando.") return 0, 1 mask = load_mask(mask_path) # Carrega todos os RAWs uma vez. for role, cam in cameras.items(): cam.data = read_raw_bin(cam.path, cam.width, cam.height, cam.raw_format) generated = 0 errors = 0 out_bins = dst_group / "bins" out_masks = dst_group / "masks" out_metas = dst_group / "metas" out_previews = dst_group / "previews" for i in range(copies): seed_value = rng_global.randint(0, 2**31 - 1) rng = random.Random(seed_value) rng_np = np.random.default_rng(seed_value) params = sample_aug_params(rng, seed_value) new_base = f"{base}_aug_{i:02d}" try: if dry_run: generated += 1 continue # Mesma geometria para mask e raws. mask_aug = apply_geometry_mask(mask, params, random.Random(seed_value + 1000)) validate_mask_colors(mask, mask_aug, f"{new_base}_mask") light_map_cache: Dict[Tuple[int, int], np.ndarray] = {} cams_out: Dict[str, CameraBin] = {} for role in required: cam = cameras[role] assert cam.data is not None if role == "rgb": # RGB RAW é mosaico Bayer. Não podemos aplicar warp direto nele, # porque isso mistura pixels R/G/B antes do demosaic e cria ruído colorido. # Fluxo correto offline: # RAW Bayer -> RGB linear -> geometria/radiometria -> mosaico Bayer -> RAW10 rgb01 = demosaic_raw10_to_rgb01( cam.data, bayer_pattern=cam.bayer_pattern, algorithm="ea", ) rgb_geom = rgb01 if params.flip_h: rgb_geom = cv2.flip(rgb_geom, 1) h_rgb, w_rgb = rgb_geom.shape[:2] M_rgb = build_affine_matrix(w_rgb, h_rgb, params) rgb_geom = warp_image(rgb_geom, M_rgb, (w_rgb, h_rgb), is_mask=False) H_rgb = build_perspective_matrix(w_rgb, h_rgb, params, random.Random(seed_value + 1000)) if H_rgb is not None: rgb_geom = warp_perspective(rgb_geom, H_rgb, (w_rgb, h_rgb), is_mask=False) rgb_aug = apply_radiometry_rgb01( rgb_geom, params, rng_np, light_map_cache, ) raw_aug = remosaic_rgb01_to_bayer_raw10( rgb_aug, bayer_pattern=cam.bayer_pattern, ) else: raw_geom = apply_geometry_raw(cam.data, params, random.Random(seed_value + 1000)) raw_aug = apply_radiometry_raw(raw_geom, role, params, cam, rng_np, light_map_cache) out_cam = copy.deepcopy(cam) out_cam.path = out_bins / f"{new_base}_{cam.cam_key}.bin" out_cam.data = raw_aug cams_out[role] = out_cam write_raw_bin(out_cam.path, raw_aug, cam.raw_format) # Máscara com mesmo nome-base. mask_ext = mask_path.suffix.lower() if mask_path.suffix.lower() in IMG_EXTS else ".png" save_mask(out_masks / f"{new_base}{mask_ext}", mask_aug) # Preview visual usando o mesmo caminho do validador/check_saved_files: # bins augmentados -> RawProcessorCore -> MULTISPEC final -> RGB final. # Se o core falhar, cai no fallback CAM_A e avisa no console. preview_method = save_preview_from_augmented_raws( out_previews / f"{new_base}.png", cams_out, meta=meta, ) # Meta novo, com mesmo stem do preview/mask para manter o layout dataset limpo. save_aug_meta( meta=meta, out_path=out_metas / f"{new_base}.json", base=base, new_base=new_base, params=params, cameras=cams_out, preview_method=preview_method, ) generated += 1 except Exception as e: errors += 1 print(f"[ERRO] [{group_name}] {new_base}: {e}") return generated, errors def parse_group_copies(text: Optional[str]) -> Dict[str, int]: """ Ex: chao:1,chao_cana:3,chao_erva:3,chao_cana_erva:5 """ out: Dict[str, int] = {} if not text: return out for part in text.split(","): part = part.strip() if not part: continue if ":" not in part: raise ValueError(f"group-copies inválido: {part}. Use grupo:N") g, n = part.split(":", 1) out[g.strip()] = int(n.strip()) return out def process_group( src_root: Path, dst_root: Path, group_name: str, copies: int, limit: Optional[int], seed: int, dry_run: bool, ) -> Tuple[int, int, int]: src_group = src_root / group_name dst_group = dst_root / group_name mask_map = map_files_by_base(src_group / "masks", IMG_EXTS) meta_map = map_files_by_base(src_group / "metas", META_EXTS) bases = sorted(set(mask_map.keys()) & set(meta_map.keys())) if limit is not None and limit > 0 and limit < len(bases): rng_select = random.Random(seed) bases = sorted(rng_select.sample(bases, limit)) print(f"[INFO] [{group_name}] limit={limit}, amostras selecionadas={len(bases)}") if not bases: print(f"[WARN] [{group_name}] Nenhum par mask/meta encontrado.") return 0, 0, 0 ensure_dir(dst_group / "bins") ensure_dir(dst_group / "masks") ensure_dir(dst_group / "metas") ensure_dir(dst_group / "previews") rng_global = random.Random(seed + abs(hash(group_name)) % 1000000) total_generated = 0 total_errors = 0 for idx, base in enumerate(bases, start=1): gen, err = process_one_sample( base=base, group_name=group_name, src_group=src_group, dst_group=dst_group, mask_path=mask_map[base], meta_path=meta_map[base], copies=copies, rng_global=rng_global, dry_run=dry_run, ) total_generated += gen total_errors += err if idx % 25 == 0 or idx == len(bases): print( f"[INFO] [{group_name}] {idx}/{len(bases)} amostras | " f"gerados={total_generated} | erros={total_errors}" ) print( f"[OK] Grupo '{group_name}' concluído: " f"originais={len(bases)} | copies={copies} | gerados={total_generated} | erros={total_errors}" ) return len(bases), total_generated, total_errors # ============================================================ # CLI # ============================================================ def main() -> None: dataset_base = Path("dataset") default_src = dataset_base / "original" / "group" if not default_src.exists(): # Compatibilidade com script antigo que usava "originals". alt = dataset_base / "originals" / "group" if alt.exists(): default_src = alt default_dst = dataset_base / "augmented" / "group" ap = argparse.ArgumentParser( description="Augmentation multiespectral RAW10 packed para dataset OAK-FCC-3." ) ap.add_argument("--copies", type=int, default=3, help="Cópias augmentadas por amostra, se --group-copies não sobrescrever.") ap.add_argument("--group-copies", type=str, default=None, help="Cópias por grupo. Ex: chao:1,chao_cana:3,chao_erva:3,chao_cana_erva:5") ap.add_argument("--groups", type=str, default=None, help="Lista de grupos separados por vírgula.") ap.add_argument("--src-root", type=str, default=str(default_src), help="Raiz dos grupos originais.") ap.add_argument("--dst-root", type=str, default=str(default_dst), help="Raiz dos grupos augmentados.") ap.add_argument("--limit", type=int, default=None, help="Quantidade máxima de amostras originais por grupo.") ap.add_argument("--seed", type=int, default=42, help="Seed geral para reproducibilidade.") ap.add_argument("--clear-dst", action="store_true", help="Apaga dst-root antes de gerar.") ap.add_argument("--dry-run", action="store_true", help="Simula sem salvar arquivos.") args = ap.parse_args() src_root = Path(args.src_root) dst_root = Path(args.dst_root) group_copies = parse_group_copies(args.group_copies) print("==============================================") print("Augmentation RAW OAK-FCC-3") print(f"MODEL/DATASET : {dataset_base}") print(f"SRC_ROOT : {src_root}") print(f"DST_ROOT : {dst_root}") print(f"copies : {args.copies}") print(f"group_copies : {group_copies if group_copies else '{}'}") print(f"limit : {args.limit}") print(f"seed : {args.seed}") print(f"dry_run : {args.dry_run}") print("==============================================") if not src_root.exists(): raise SystemExit(f"[ERRO] src-root não encontrado: {src_root}") if args.clear_dst and not args.dry_run: print(f"[INFO] Limpando destino: {dst_root}") clear_dir(dst_root) else: ensure_dir(dst_root) groups = list_groups(src_root) if args.groups: wanted = {g.strip() for g in args.groups.split(",") if g.strip()} groups = [g for g in groups if g in wanted] if not groups: raise SystemExit("[WARN] Nenhum grupo válido encontrado.") print(f"Grupos encontrados: {', '.join(groups)}") total_originals = 0 total_generated = 0 total_errors = 0 for group_name in groups: copies = group_copies.get(group_name, args.copies) if copies <= 0: print(f"[INFO] [{group_name}] copies={copies}. Pulando.") continue n_orig, n_gen, n_err = process_group( src_root=src_root, dst_root=dst_root, group_name=group_name, copies=copies, limit=args.limit, seed=args.seed, dry_run=args.dry_run, ) total_originals += n_orig total_generated += n_gen total_errors += n_err print("\n==============================================") print("Resumo final") print(f"Originais processados : {total_originals}") print(f"Amostras geradas : {total_generated}") print(f"Erros/Pulos : {total_errors}") print(f"Destino : {dst_root}") print("==============================================") if __name__ == "__main__": main()