import argparse import json import math import unicodedata from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import cv2 import numpy as np # ============================================================ # Dataset Alignment Browser V2 # ------------------------------------------------------------ # Objetivo: # Navegar no dataset multiespectral e comparar: # 1) space=final -> tensor final usado no treino/inferencia # 2) space=native -> dados decodificados nativos, antes da homografia/crop/fusao # # Tambem testa alinhamento dinamico por bordas com prioridades: # - global : usa a imagem toda # - largest_blob : usa o maior blob de bordas fortes # - gt_target : usa mascara GT de cana/erva, se existir # # Requisitos: # - Colocar este arquivo no mesmo projeto onde existe utils/audit_dataset_manual.py # - Rodar a partir da raiz do projeto, por exemplo: # python -m utils.dataset_alignment_browser_v2 --input_path .\dataset\original\group\ --groups-except chao --space native # ============================================================ _AUDIT_IMPORT_ERROR = None try: from utils.audit_dataset_manual import ( find_dataset_roots, list_meta_files, load_multispec_tensor, resolve_mask_path, load_mask, parse_csv_set, load_json, resolve_camera_payloads, resolve_module_params_path, get_raw_processor_core, ) except Exception as e1: try: from audit_dataset_manual import ( find_dataset_roots, list_meta_files, load_multispec_tensor, resolve_mask_path, load_mask, parse_csv_set, load_json, resolve_camera_payloads, resolve_module_params_path, get_raw_processor_core, ) except Exception as e2: _AUDIT_IMPORT_ERROR = (e1, e2) find_dataset_roots = None list_meta_files = None load_multispec_tensor = None resolve_mask_path = None load_mask = None parse_csv_set = None load_json = None resolve_camera_payloads = None resolve_module_params_path = None get_raw_processor_core = None EPS = 1e-6 IGNORE_INDEX = 255 # ============================================================ # Utilidades gerais # ============================================================ def ensure_imports_ok(): if find_dataset_roots is None: msg = ( "Nao consegui importar funcoes do audit_dataset_manual.py.\n" "Coloque este script no mesmo projeto do auditor e rode a partir da raiz do projeto.\n" ) if _AUDIT_IMPORT_ERROR: msg += f"\nImport error 1: {_AUDIT_IMPORT_ERROR[0]}\nImport error 2: {_AUDIT_IMPORT_ERROR[1]}" raise RuntimeError(msg) def cv_text(text: Any) -> str: s = str(text) s = unicodedata.normalize("NFKD", s) s = s.encode("ascii", "ignore").decode("ascii") return s 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[:2], 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 = (arr - lo) / (hi - lo) y = np.clip(y, 0.0, 1.0) return (y * 255.0).astype(np.uint8) def float01_to_u8(x: np.ndarray) -> np.ndarray: return np.clip(x.astype(np.float32) * 255.0, 0, 255).astype(np.uint8) def rgb_hwc_to_bgr(rgb: np.ndarray, stretch: bool = False) -> np.ndarray: rgb = np.asarray(rgb, dtype=np.float32) if stretch: chans = [normalize_to_u8(rgb[:, :, i]) for i in range(3)] rgb_u8 = np.dstack(chans) else: rgb_u8 = float01_to_u8(rgb) return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR) def rgb_from_tensor(tensor: np.ndarray, stretch: bool = False) -> np.ndarray: rgb = np.transpose(tensor[:3], (1, 2, 0)).astype(np.float32) return rgb_hwc_to_bgr(rgb, stretch=stretch) def gray_from_rgb_hwc(rgb: np.ndarray) -> np.ndarray: rgb = np.asarray(rgb, dtype=np.float32) return (0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]).astype(np.float32) def gray_from_tensor_rgb(tensor: np.ndarray) -> np.ndarray: r, g, b = [tensor[i].astype(np.float32, copy=False) for i in range(3)] return (0.299 * r + 0.587 * g + 0.114 * b).astype(np.float32) def resize_to(img: np.ndarray, hw: Tuple[int, int], interp: int = cv2.INTER_LINEAR) -> np.ndarray: h, w = int(hw[0]), int(hw[1]) if img.shape[:2] == (h, w): return img return cv2.resize(img, (w, h), interpolation=interp) def gradient_mag(x: np.ndarray) -> np.ndarray: u8 = normalize_to_u8(x) gx = cv2.Sobel(u8, cv2.CV_32F, 1, 0, ksize=3) gy = cv2.Sobel(u8, cv2.CV_32F, 0, 1, ksize=3) return cv2.magnitude(gx, gy).astype(np.float32) def edge_binary(x: np.ndarray, low: int = 60, high: int = 140) -> np.ndarray: return cv2.Canny(normalize_to_u8(x), low, high) def colorize_gray(x: np.ndarray, cmap: int = cv2.COLORMAP_VIRIDIS) -> np.ndarray: return cv2.applyColorMap(normalize_to_u8(x), cmap) def put_label(img: np.ndarray, title: str, subtitle: str = "") -> np.ndarray: out = img.copy() title = cv_text(title) subtitle = cv_text(subtitle) hbox = 58 if subtitle else 36 cv2.rectangle(out, (0, 0), (out.shape[1], hbox), (0, 0, 0), -1) cv2.putText(out, title, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (0, 255, 255), 2, cv2.LINE_AA) if subtitle: cv2.putText(out, subtitle[:165], (10, 48), cv2.FONT_HERSHEY_SIMPLEX, 0.43, (255, 255, 255), 1, cv2.LINE_AA) return out def resize_keep(img: np.ndarray, target_w: int) -> np.ndarray: scale = float(target_w) / float(img.shape[1]) target_h = max(1, int(img.shape[0] * scale)) return cv2.resize(img, (target_w, target_h), interpolation=cv2.INTER_AREA) def make_grid(panels: List[Tuple[str, np.ndarray, str]], panel_w: int = 410, cols: int = 3) -> np.ndarray: rendered: List[np.ndarray] = [] for title, img, subtitle in panels: if img.ndim == 2: img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) small = resize_keep(img, panel_w) rendered.append(put_label(small, title, subtitle)) if not rendered: return np.zeros((300, 600, 3), dtype=np.uint8) max_h = max(x.shape[0] for x in rendered) padded: List[np.ndarray] = [] for im in rendered: if im.shape[0] < max_h: pad = np.zeros((max_h - im.shape[0], im.shape[1], 3), dtype=np.uint8) im = np.vstack([im, pad]) padded.append(im) gap = 10 gap_w = np.full((max_h, gap, 3), 24, dtype=np.uint8) rows: List[np.ndarray] = [] filler = np.zeros_like(padded[0]) for i in range(0, len(padded), cols): items = padded[i:i + cols] while len(items) < cols: items.append(filler.copy()) row = items[0] for j in range(1, cols): row = np.hstack([row, gap_w, items[j]]) rows.append(row) gap_h = np.full((gap, rows[0].shape[1], 3), 24, dtype=np.uint8) canvas = rows[0] for r in rows[1:]: canvas = np.vstack([canvas, gap_h, r]) return canvas def make_info_panel(lines: List[str], size: Tuple[int, int] = (900, 280)) -> np.ndarray: w, h = size img = np.zeros((h, w, 3), dtype=np.uint8) cv2.rectangle(img, (0, 0), (w - 1, h - 1), (70, 70, 70), 1) y = 28 for i, line in enumerate(lines): color = (0, 255, 255) if i == 0 else (235, 235, 235) cv2.putText(img, cv_text(line[:145]), (12, y), cv2.FONT_HERSHEY_SIMPLEX, 0.52, color, 1, cv2.LINE_AA) y += 23 if y > h - 12: break return img def falsecolor_overlay(a: np.ndarray, b: np.ndarray) -> np.ndarray: """ Verde=A, Magenta=B. Onde casa, tende a ficar claro/cinza/branco. Onde desalinha, aparecem franjas. """ au8 = normalize_to_u8(a) bu8 = normalize_to_u8(b) out = np.zeros((au8.shape[0], au8.shape[1], 3), dtype=np.uint8) out[:, :, 1] = au8 out[:, :, 0] = bu8 out[:, :, 2] = bu8 return out def alpha_blend(base_bgr: np.ndarray, overlay_gray: np.ndarray, alpha: float = 0.38, cmap: int = cv2.COLORMAP_TURBO) -> np.ndarray: cm = colorize_gray(overlay_gray, cmap) cm = resize_to(cm, base_bgr.shape[:2]) return cv2.addWeighted(base_bgr, 1.0 - alpha, cm, alpha, 0.0) def edge_overlay(rgb_gray: np.ndarray, re: np.ndarray, nir: np.ndarray) -> np.ndarray: 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) out = np.zeros((e_rgb.shape[0], e_rgb.shape[1], 3), dtype=np.uint8) out[:, :, 1] = e_rgb out[:, :, 2] = e_re out[:, :, 0] = e_nir return out def draw_edges_on_rgb(rgb_bgr: np.ndarray, img: np.ndarray, color: Tuple[int, int, int]) -> np.ndarray: out = rgb_bgr.copy() img = resize_to(img, rgb_bgr.shape[:2]) ed = edge_binary(img) out[ed > 0] = color return out def mask_overlay(rgb_bgr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarray: if mask is None: return rgb_bgr.copy() mask = resize_to(mask.astype(np.int32), rgb_bgr.shape[:2], interp=cv2.INTER_NEAREST) out = rgb_bgr.copy() color_mask = np.zeros_like(out) palette = { 0: (0, 0, 128), 1: (128, 0, 0), 2: (0, 128, 0), 255: (0, 0, 0), } for cls_id in np.unique(mask): color_mask[mask == int(cls_id)] = palette.get(int(cls_id), (100, 100, 100)) return cv2.addWeighted(out, 0.70, color_mask, 0.30, 0) def draw_roi(img: np.ndarray, roi_mask: Optional[np.ndarray], bbox: Optional[Tuple[int, int, int, int]], title: str = "") -> np.ndarray: out = img.copy() if roi_mask is not None: mask = resize_to(roi_mask.astype(np.uint8), out.shape[:2], interp=cv2.INTER_NEAREST) tint = np.zeros_like(out) tint[:, :, 1] = 255 out = np.where(mask[:, :, None] > 0, cv2.addWeighted(out, 0.55, tint, 0.45, 0), out) if bbox is not None: x0, y0, x1, y1 = bbox cv2.rectangle(out, (x0, y0), (x1, y1), (0, 255, 255), 2) if title: cv2.putText(out, cv_text(title), (10, out.shape[0] - 12), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 1, cv2.LINE_AA) return out def draw_crop_box(img: np.ndarray, crop_box: Optional[Tuple[int, int, int, int]], label: str = "crop") -> np.ndarray: out = img.copy() if crop_box is None: return out x0, y0, x1, y1 = [int(v) for v in crop_box] cv2.rectangle(out, (x0, y0), (x1, y1), (0, 255, 255), 2) cv2.putText(out, cv_text(label), (x0 + 6, max(22, y0 + 22)), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 1, cv2.LINE_AA) return out # ============================================================ # Registro por bordas / ROI # ============================================================ @dataclass class AlignResult: method: str priority: str accepted: bool warp: np.ndarray used_inverse_map: bool roi_mask: Optional[np.ndarray] roi_bbox: Optional[Tuple[int, int, int, int]] phase_dx: float = 0.0 phase_dy: float = 0.0 phase_response: float = 0.0 edge_corr_before: float = 0.0 edge_corr_after: float = 0.0 roi_corr_before: float = 0.0 roi_corr_after: float = 0.0 translation_px: float = 0.0 rotation_deg: float = 0.0 note: str = "" def edge_corr(a: np.ndarray, b: np.ndarray, roi_mask: Optional[np.ndarray] = None) -> float: ga = gradient_mag(a) gb = gradient_mag(b) if roi_mask is not None: m = resize_to(roi_mask.astype(np.uint8), ga.shape[:2], interp=cv2.INTER_NEAREST) > 0 if np.count_nonzero(m) < 32: return 0.0 va = ga[m].reshape(-1) vb = gb[m].reshape(-1) else: va = ga.reshape(-1) vb = gb.reshape(-1) if va.size == 0 or vb.size == 0 or np.std(va) < EPS or np.std(vb) < EPS: return 0.0 return float(np.corrcoef(va, vb)[0, 1]) def bbox_from_mask(mask: np.ndarray, pad: int = 8) -> Optional[Tuple[int, int, int, int]]: ys, xs = np.where(mask > 0) if xs.size == 0 or ys.size == 0: return None h, w = mask.shape[:2] x0 = max(0, int(xs.min()) - pad) y0 = max(0, int(ys.min()) - pad) x1 = min(w, int(xs.max()) + 1 + pad) y1 = min(h, int(ys.max()) + 1 + pad) if x1 <= x0 or y1 <= y0: return None return x0, y0, x1, y1 def largest_edge_blob_mask(ref: np.ndarray, tgt: np.ndarray, min_area_frac: float = 0.003, dilate_iter: int = 5) -> Tuple[Optional[np.ndarray], Optional[Tuple[int, int, int, int]], str]: ref_g = gradient_mag(ref) tgt_g = gradient_mag(tgt) combo = np.maximum(normalize_to_u8(ref_g, 70, 99.5), normalize_to_u8(tgt_g, 70, 99.5)) # threshold robusto por percentil: pega bordas fortes, nao toda a palhada fina. th = max(25, int(np.percentile(combo, 88))) strong = (combo >= th).astype(np.uint8) * 255 k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) strong = cv2.morphologyEx(strong, cv2.MORPH_CLOSE, k, iterations=2) strong = cv2.dilate(strong, k, iterations=max(1, int(dilate_iter))) n, labels, stats, _cent = cv2.connectedComponentsWithStats(strong, connectivity=8) if n <= 1: return None, None, "sem componentes" h, w = strong.shape[:2] min_area = int(float(min_area_frac) * h * w) best_id = None best_score = -1.0 for cid in range(1, n): x, y, bw, bh, area = stats[cid] if area < min_area: continue # Favorece area, mas tambem energia de borda dentro do blob. m = labels == cid energy = float(np.mean(combo[m])) if np.any(m) else 0.0 score = float(area) * (1.0 + energy / 255.0) if score > best_score: best_score = score best_id = cid if best_id is None: return None, None, f"sem blob >= {min_area}px" mask = (labels == best_id).astype(np.uint8) bbox = bbox_from_mask(mask, pad=10) area = int(stats[best_id, cv2.CC_STAT_AREA]) return mask, bbox, f"largest_blob id={best_id} area={area} score={best_score:.1f}" def gt_target_mask(mask: Optional[np.ndarray], target_classes: List[int], hw: Tuple[int, int]) -> Tuple[Optional[np.ndarray], Optional[Tuple[int, int, int, int]], str]: if mask is None: return None, None, "sem GT mask" m = resize_to(mask.astype(np.int32), hw, interp=cv2.INTER_NEAREST) out = np.zeros(hw, dtype=np.uint8) for cls_id in target_classes: out[m == int(cls_id)] = 1 if np.count_nonzero(out) < 32: return None, None, f"GT target vazio classes={target_classes}" k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) out = cv2.dilate(out, k, iterations=3) return out, bbox_from_mask(out, pad=10), f"GT target classes={target_classes} area={int(np.count_nonzero(out))}" def make_alignment_roi(ref: np.ndarray, tgt: np.ndarray, priority: str, mask: Optional[np.ndarray], target_classes: List[int]) -> Tuple[Optional[np.ndarray], Optional[Tuple[int, int, int, int]], str]: priority = str(priority).lower() hw = ref.shape[:2] if priority == "global": return None, None, "global/full frame" if priority == "largest_blob": return largest_edge_blob_mask(ref, tgt) if priority == "gt_target": return gt_target_mask(mask, target_classes, hw) return None, None, f"priority desconhecida: {priority}" def crop_by_bbox(a: np.ndarray, bbox: Optional[Tuple[int, int, int, int]]) -> np.ndarray: if bbox is None: return a x0, y0, x1, y1 = bbox return a[y0:y1, x0:x1] def estimate_phase_shift( ref: np.ndarray, tgt: np.ndarray, bbox: Optional[Tuple[int, int, int, int]] = None, roi_mask: Optional[np.ndarray] = None, ) -> Tuple[float, float, float]: rr = crop_by_bbox(gradient_mag(ref), bbox) tt = crop_by_bbox(gradient_mag(tgt), bbox) if roi_mask is not None: m = resize_to(roi_mask.astype(np.uint8), ref.shape[:2], interp=cv2.INTER_NEAREST) m = crop_by_bbox(m, bbox) if m.shape[:2] == rr.shape[:2] and np.count_nonzero(m) >= 32: # Usa a mascara real da ROI, nao apenas o bbox. Isso faz gt_target/largest_blob # puxarem a estimativa para o objeto dominante em vez da textura do retangulo inteiro. mf = (m > 0).astype(np.float32) rr = rr * mf tt = tt * mf rr = normalize_to_u8(rr).astype(np.float32) tt = normalize_to_u8(tt).astype(np.float32) try: (dx, dy), resp = cv2.phaseCorrelate(rr, tt) return float(dx), float(dy), float(resp) except Exception: return 0.0, 0.0, 0.0 def warp_from_phase(dx: float, dy: float) -> np.ndarray: # Para alinhar tgt ao ref, desloca pelo negativo do shift estimado. return np.array([[1.0, 0.0, -dx], [0.0, 1.0, -dy]], dtype=np.float32) def warp_2x3_to_3x3(W: np.ndarray) -> np.ndarray: H = np.eye(3, dtype=np.float32) H[:2, :] = W.astype(np.float32) return H def warp_3x3_to_2x3(H: np.ndarray) -> np.ndarray: return H[:2, :].astype(np.float32) def local_warp_to_full(W_local: np.ndarray, bbox: Optional[Tuple[int, int, int, int]]) -> np.ndarray: if bbox is None: return W_local.astype(np.float32) x0, y0, _x1, _y1 = bbox T_full_to_local = np.array([[1, 0, -x0], [0, 1, -y0], [0, 0, 1]], dtype=np.float32) T_local_to_full = np.array([[1, 0, x0], [0, 1, y0], [0, 0, 1]], dtype=np.float32) H_local = warp_2x3_to_3x3(W_local) H_full = T_local_to_full @ H_local @ T_full_to_local return warp_3x3_to_2x3(H_full) def try_ecc_alignment(ref: np.ndarray, tgt: np.ndarray, method: str, bbox: Optional[Tuple[int, int, int, int]] = None, max_iter: int = 60, eps: float = 1e-5) -> Tuple[np.ndarray, bool, str]: ref_crop = crop_by_bbox(ref, bbox) tgt_crop = crop_by_bbox(tgt, bbox) ref_img = normalize_to_u8(gradient_mag(ref_crop)).astype(np.float32) / 255.0 tgt_img = normalize_to_u8(gradient_mag(tgt_crop)).astype(np.float32) / 255.0 if ref_img.shape[0] < 30 or ref_img.shape[1] < 30: return np.eye(2, 3, dtype=np.float32), False, "ECC ROI pequena" if method == "ecc_translation": motion = cv2.MOTION_TRANSLATION elif method == "ecc_euclidean": motion = cv2.MOTION_EUCLIDEAN elif method == "ecc_affine": motion = cv2.MOTION_AFFINE else: raise ValueError(f"Metodo ECC invalido: {method}") warp = np.eye(2, 3, dtype=np.float32) criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, max_iter, eps) try: cc, W_local = cv2.findTransformECC(ref_img, tgt_img, warp, motion, criteria) W_full = local_warp_to_full(W_local.astype(np.float32), bbox) return W_full, True, f"ECC cc={cc:.4f}" except cv2.error as e: return np.eye(2, 3, dtype=np.float32), False, f"ECC falhou: {str(e)[:90]}" def extract_warp_metrics(warp: np.ndarray) -> Tuple[float, float]: tx = float(warp[0, 2]) ty = float(warp[1, 2]) translation = math.hypot(tx, ty) a = float(warp[0, 0]) b = float(warp[0, 1]) rot = -math.degrees(math.atan2(b, a)) return translation, rot def apply_warp(img: np.ndarray, warp: np.ndarray, inverse_map: bool = False, border_mode: int = cv2.BORDER_REFLECT101) -> np.ndarray: flags = cv2.INTER_LINEAR if inverse_map: flags |= cv2.WARP_INVERSE_MAP return cv2.warpAffine( img.astype(np.float32), warp.astype(np.float32), (img.shape[1], img.shape[0]), flags=flags, borderMode=border_mode, borderValue=0.0, ) def estimate_alignment(ref: np.ndarray, tgt: np.ndarray, method: str, priority: str, mask: Optional[np.ndarray], target_classes: List[int], max_shift_px: float, max_rotation_deg: float, min_improve_corr: float = -0.005) -> AlignResult: roi_mask, roi_bbox, roi_note = make_alignment_roi(ref, tgt, priority, mask, target_classes) corr_before = edge_corr(ref, tgt) roi_corr_before = edge_corr(ref, tgt, roi_mask) pdx, pdy, presp = estimate_phase_shift(ref, tgt, roi_bbox, roi_mask) if method == "none": return AlignResult( method=method, priority=priority, accepted=False, warp=np.eye(2, 3, dtype=np.float32), used_inverse_map=False, roi_mask=roi_mask, roi_bbox=roi_bbox, phase_dx=pdx, phase_dy=pdy, phase_response=presp, edge_corr_before=corr_before, edge_corr_after=corr_before, roi_corr_before=roi_corr_before, roi_corr_after=roi_corr_before, note=f"sem correcao | {roi_note}", ) if method == "phase": warp = warp_from_phase(pdx, pdy) corrected = apply_warp(tgt, warp, inverse_map=False) corr_after = edge_corr(ref, corrected) roi_corr_after = edge_corr(ref, corrected, roi_mask) trans, rot = extract_warp_metrics(warp) accepted = trans <= max_shift_px and (corr_after >= corr_before + min_improve_corr or roi_corr_after >= roi_corr_before + min_improve_corr) note = f"phase resp={presp:.3f} | {roi_note}" if not accepted: # Mantem o warp proposto mesmo rejeitado. Assim o browser consegue mostrar # o modo PROPOSTO/forcado para diagnostico visual. note += " | rejeitado" return AlignResult( method=method, priority=priority, accepted=accepted, warp=warp, used_inverse_map=False, roi_mask=roi_mask, roi_bbox=roi_bbox, phase_dx=pdx, phase_dy=pdy, phase_response=presp, edge_corr_before=corr_before, edge_corr_after=corr_after, roi_corr_before=roi_corr_before, roi_corr_after=roi_corr_after, translation_px=trans, rotation_deg=rot, note=note, ) warp, ok, note = try_ecc_alignment(ref, tgt, method, bbox=roi_bbox) if not ok: return AlignResult( method=method, priority=priority, accepted=False, warp=np.eye(2, 3, dtype=np.float32), used_inverse_map=True, roi_mask=roi_mask, roi_bbox=roi_bbox, phase_dx=pdx, phase_dy=pdy, phase_response=presp, edge_corr_before=corr_before, edge_corr_after=corr_before, roi_corr_before=roi_corr_before, roi_corr_after=roi_corr_before, note=f"{note} | {roi_note}", ) corrected = apply_warp(tgt, warp, inverse_map=True) corr_after = edge_corr(ref, corrected) roi_corr_after = edge_corr(ref, corrected, roi_mask) trans, rot = extract_warp_metrics(warp) accepted = ( trans <= max_shift_px and abs(rot) <= max_rotation_deg and (corr_after >= corr_before + min_improve_corr or roi_corr_after >= roi_corr_before + min_improve_corr) ) if not accepted: # Mantem o warp proposto mesmo rejeitado. Assim o browser consegue mostrar # o modo PROPOSTO/forcado para diagnostico visual. note += " | rejeitado por limite/correlacao" return AlignResult( method=method, priority=priority, accepted=accepted, warp=warp, used_inverse_map=True, roi_mask=roi_mask, roi_bbox=roi_bbox, phase_dx=pdx, phase_dy=pdy, phase_response=presp, edge_corr_before=corr_before, edge_corr_after=corr_after, roi_corr_before=roi_corr_before, roi_corr_after=roi_corr_after, translation_px=trans, rotation_deg=rot, note=f"{note} | {roi_note}", ) # ============================================================ # Leitura dos dados: final e native # ============================================================ @dataclass class SampleEntry: dataset_root: Path meta_path: Path sample_name: str @dataclass class FusionDebug: available: bool warped_re: Optional[np.ndarray] = None warped_nir: Optional[np.ndarray] = None valid_re: Optional[np.ndarray] = None valid_nir: Optional[np.ndarray] = None common_mask: Optional[np.ndarray] = None crop_box: Optional[Tuple[int, int, int, int]] = None note: str = "" class NativeDecoder: def __init__(self): self.core_cache: Dict[Any, Any] = {} def load_native(self, entry: SampleEntry) -> Dict[str, Any]: meta = load_json(entry.meta_path) saved_dtypes = meta.get("saved_payload_dtypes", {}) or {} saved_shapes = meta.get("saved_payload_shapes", {}) or {} cam_paths = resolve_camera_payloads(entry.meta_path, entry.dataset_root, meta) 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 {entry.meta_path.name}") arr = np.fromfile(str(payload_path), dtype=np.dtype(saved_dtype)).reshape(tuple(saved_shape)) frame[cam_id] = arr sensor_width = int(meta.get("sensor_width", 1280)) sensor_height = int(meta.get("sensor_height", 800)) bayer = meta.get("bayer_pattern", "RGGB") calib_path = resolve_module_params_path(entry.meta_path, entry.dataset_root, meta) core = get_raw_processor_core( core_cache=self.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") decoded = core.decode_stream_cameras(frame, processing_meta) return { "meta": meta, "processing_meta": processing_meta, "decoded": decoded, "core": core, "calib_path": calib_path, "source": "native_decoded_before_fusion", } def role_to_images(decoded: Dict[str, Any]) -> Tuple[np.ndarray, np.ndarray, np.ndarray, Dict[str, str]]: rgb = None re = None nir = None role_cam = {} for cam_id, item in decoded.items(): role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower() if role: role_cam[role] = str(cam_id) img = item.get("image") if role == "rgb": rgb = img elif role == "re": re = img elif role == "nir": nir = img if rgb is None or re is None or nir is None: raise RuntimeError(f"Decoded sem rgb/re/nir completos. roles={role_cam}") if rgb.ndim != 3 or rgb.shape[2] != 3: raise RuntimeError(f"RGB nativo invalido: shape={rgb.shape}") if re.ndim != 2 or nir.ndim != 2: raise RuntimeError(f"RE/NIR nativos invalidos: re={re.shape} nir={nir.shape}") return rgb.astype(np.float32), re.astype(np.float32), nir.astype(np.float32), role_cam def compute_current_fusion_debug(core: Any, decoded: Dict[str, Any], processing_meta: Dict[str, Any]) -> FusionDebug: try: rgb, re, nir, _role_cam = role_to_images(decoded) ref_h, ref_w = rgb.shape[:2] ref_shape = (ref_h, ref_w) warped_re, valid_re = core._warp_with_valid_mask(re, "re", ref_shape, processing_meta) warped_nir, valid_nir = core._warp_with_valid_mask(nir, "nir", ref_shape, processing_meta) common = np.ones((ref_h, ref_w), dtype=np.uint8) common = np.logical_and(common > 0, valid_re > 0) common = np.logical_and(common > 0, valid_nir > 0).astype(np.uint8) crop_box = core._compute_common_crop_box([np.ones((ref_h, ref_w), dtype=np.uint8), valid_re, valid_nir]) if crop_box is not None: crop_box = tuple(int(v) for v in crop_box) return FusionDebug( available=True, warped_re=warped_re.astype(np.float32), warped_nir=warped_nir.astype(np.float32), valid_re=valid_re.astype(np.uint8), valid_nir=valid_nir.astype(np.uint8), common_mask=common.astype(np.uint8), crop_box=crop_box, note="homografia atual aplicada so para debug", ) except Exception as e: return FusionDebug(available=False, note=f"fusion_debug indisponivel: {str(e)[:160]}") # ============================================================ # Browser # ============================================================ @dataclass class BrowserState: space: str = "final" method: str = "phase" priority: str = "global" show_corrected: bool = False force_apply: bool = False show_edges: bool = True show_mask: bool = True show_crop_debug: bool = True panel_w: int = 410 class DatasetAlignmentBrowserV2: def __init__(self, args: argparse.Namespace): ensure_imports_ok() self.args = args self.state = BrowserState( space=args.space, method=args.method, priority=args.priority, panel_w=args.panel_w, show_crop_debug=not args.hide_crop_debug, ) self.final_core_cache: Dict[Any, Any] = {} self.native_decoder = NativeDecoder() self.entries = self._build_entries(args.input_path, args.groups_except) if not self.entries: raise RuntimeError("Nenhuma amostra encontrada para navegar.") self.index = max(0, min(args.start_index, len(self.entries) - 1)) self.window_name = "Dataset Alignment Browser V2" self.save_dir = Path(args.save_dir) if args.save_dir else Path("alignment_browser_v2_out") self.save_dir.mkdir(parents=True, exist_ok=True) self.target_classes = [int(x) for x in str(args.target_classes).split(",") if x.strip()] def _build_entries(self, input_path: str, groups_except: str) -> List[SampleEntry]: roots = find_dataset_roots(Path(input_path)) skip = parse_csv_set(groups_except) if groups_except and parse_csv_set else set() entries: List[SampleEntry] = [] for root in roots: if root.name.lower() in skip: continue for meta_path in list_meta_files(root): entries.append(SampleEntry(root, meta_path, f"{root.name}__{meta_path.stem}")) return entries def _load_mask_for_entry(self, entry: SampleEntry, hw: Tuple[int, int]) -> Optional[np.ndarray]: try: mask_path = resolve_mask_path(entry.dataset_root, entry.meta_path) if mask_path is None: return None return load_mask(mask_path, hw, IGNORE_INDEX) except Exception: return None def load_sample_final(self, entry: SampleEntry) -> Dict[str, Any]: tensor, meta, _source_payload = load_multispec_tensor(entry.meta_path, entry.dataset_root, self.final_core_cache) h, w = tensor.shape[1], tensor.shape[2] mask = self._load_mask_for_entry(entry, (h, w)) rgb_bgr = rgb_from_tensor(tensor, stretch=False) rgb_stretch_bgr = rgb_from_tensor(tensor, stretch=True) rgb_gray = gray_from_tensor_rgb(tensor) re = tensor[3].astype(np.float32, copy=False) nir = tensor[4].astype(np.float32, copy=False) return { "space": "final", "entry": entry, "meta": meta, "mask": mask, "rgb_bgr": rgb_bgr, "rgb_stretch_bgr": rgb_stretch_bgr, "rgb_gray": rgb_gray, "re": re, "nir": nir, "native_note": "tensor final: pos homografia/crop/resize/flat/radnorm conforme pipeline", "fusion_debug": None, } def load_sample_native(self, entry: SampleEntry) -> Dict[str, Any]: native = self.native_decoder.load_native(entry) rgb_hwc, re_native, nir_native, role_cam = role_to_images(native["decoded"]) # Para comparacao visual sem homografia, redimensiona RE/NIR para shape do RGB. # Isso e apenas resize escalar, nao corrige paralaxe nem homografia. ref_h, ref_w = rgb_hwc.shape[:2] re_cmp = resize_to(re_native, (ref_h, ref_w)) nir_cmp = resize_to(nir_native, (ref_h, ref_w)) rgb_bgr = rgb_hwc_to_bgr(rgb_hwc, stretch=False) rgb_stretch_bgr = rgb_hwc_to_bgr(rgb_hwc, stretch=True) rgb_gray = gray_from_rgb_hwc(rgb_hwc) mask = self._load_mask_for_entry(entry, (ref_h, ref_w)) fusion_debug = compute_current_fusion_debug(native["core"], native["decoded"], native["processing_meta"]) return { "space": "native", "entry": entry, "meta": native["meta"], "mask": mask, "rgb_bgr": rgb_bgr, "rgb_stretch_bgr": rgb_stretch_bgr, "rgb_gray": rgb_gray, "re": re_cmp.astype(np.float32), "nir": nir_cmp.astype(np.float32), "re_native_original": re_native.astype(np.float32), "nir_native_original": nir_native.astype(np.float32), "rgb_native_original": rgb_hwc.astype(np.float32), "role_cam": role_cam, "native_note": f"native decoded antes da fusao | RGB={rgb_hwc.shape[:2]} RE={re_native.shape} NIR={nir_native.shape} | overlay usa resize simples para RGB", "fusion_debug": fusion_debug, } def load_current_sample(self) -> Dict[str, Any]: entry = self.entries[self.index] if self.state.space == "native": sample = self.load_sample_native(entry) elif self.state.space == "final": sample = self.load_sample_final(entry) else: # fallback defensivo sample = self.load_sample_final(entry) re_align = estimate_alignment( sample["rgb_gray"], sample["re"], self.state.method, self.state.priority, sample["mask"] if self.state.show_mask else None, self.target_classes, max_shift_px=self.args.max_shift_px, max_rotation_deg=self.args.max_rotation_deg, min_improve_corr=self.args.min_improve_corr, ) nir_align = estimate_alignment( sample["rgb_gray"], sample["nir"], self.state.method, self.state.priority, sample["mask"] if self.state.show_mask else None, self.target_classes, max_shift_px=self.args.max_shift_px, max_rotation_deg=self.args.max_rotation_deg, min_improve_corr=self.args.min_improve_corr, ) apply_re = bool(re_align.accepted or self.state.force_apply) apply_nir = bool(nir_align.accepted or self.state.force_apply) re_corr = apply_warp(sample["re"], re_align.warp, inverse_map=re_align.used_inverse_map) if apply_re else sample["re"] nir_corr = apply_warp(sample["nir"], nir_align.warp, inverse_map=nir_align.used_inverse_map) if apply_nir else sample["nir"] sample["re_align"] = re_align sample["nir_align"] = nir_align sample["re_corr"] = re_corr sample["nir_corr"] = nir_corr return sample def build_panels(self, sample: Dict[str, Any]) -> np.ndarray: entry = sample["entry"] idx_txt = f"[{self.index + 1}/{len(self.entries)}] {entry.sample_name}" space_txt = self.state.space.upper() if self.state.show_corrected and self.state.force_apply: mode_txt = "PROPOSTO" elif self.state.show_corrected: mode_txt = "CORRIGIDO" else: mode_txt = "BRUTO" rgb_bgr = sample["rgb_bgr"] rgb_stretch = sample["rgb_stretch_bgr"] rgb_gray = sample["rgb_gray"] re_raw = sample["re"] nir_raw = sample["nir"] re = sample["re_corr"] if self.state.show_corrected else re_raw nir = sample["nir_corr"] if self.state.show_corrected else nir_raw mask = sample["mask"] if self.state.show_mask else None re_align: AlignResult = sample["re_align"] nir_align: AlignResult = sample["nir_align"] panels: List[Tuple[str, np.ndarray, str]] = [] info_lines = [ idx_txt, f"space={space_txt} | exibicao={mode_txt} | metodo={self.state.method} | prioridade={self.state.priority}", "teclas: A/D prev/next | Up/Down +/-10 | X space | C raw/corr | F force/proposto | M metodo | P prioridade | V crop | E edges | K mask | S save | Q sair", sample.get("native_note", ""), f"RE global before/after={re_align.edge_corr_before:.4f}/{re_align.edge_corr_after:.4f} | ROI before/after={re_align.roi_corr_before:.4f}/{re_align.roi_corr_after:.4f}", f"RE phase=({re_align.phase_dx:.2f},{re_align.phase_dy:.2f}) resp={re_align.phase_response:.3f} | accepted={re_align.accepted} | force={self.state.force_apply} | trans={re_align.translation_px:.2f}px rot={re_align.rotation_deg:.2f}deg", f"NIR global before/after={nir_align.edge_corr_before:.4f}/{nir_align.edge_corr_after:.4f} | ROI before/after={nir_align.roi_corr_before:.4f}/{nir_align.roi_corr_after:.4f}", f"NIR phase=({nir_align.phase_dx:.2f},{nir_align.phase_dy:.2f}) resp={nir_align.phase_response:.3f} | accepted={nir_align.accepted} | force={self.state.force_apply} | trans={nir_align.translation_px:.2f}px rot={nir_align.rotation_deg:.2f}deg", f"RE note: {re_align.note}", f"NIR note: {nir_align.note}", ] info_panel = make_info_panel(info_lines, size=(950, 305)) mask_on_rgb = mask_overlay(rgb_bgr, mask) roi_re_panel = draw_roi(rgb_bgr, re_align.roi_mask, re_align.roi_bbox, "ROI RE") roi_nir_panel = draw_roi(rgb_bgr, nir_align.roi_mask, nir_align.roi_bbox, "ROI NIR") panels.extend([ ("RGB", rgb_bgr, idx_txt), ("RGB stretch", rgb_stretch, "visual somente"), ("Mask overlay", mask_on_rgb, "GT over RGB" if mask is not None else "sem mascara"), ("RE bruto", colorize_gray(re_raw), f"space={space_txt}"), ("NIR bruto", colorize_gray(nir_raw), f"space={space_txt}"), ("Info", info_panel, "metricas de alinhamento"), ("ROI usado RE", roi_re_panel, f"priority={self.state.priority}"), ("ROI usado NIR", roi_nir_panel, f"priority={self.state.priority}"), (f"RE exibido {mode_txt}", colorize_gray(re), "bruto ou corrigido"), ]) panels.extend([ (f"RGBgray vs RE ({mode_txt})", falsecolor_overlay(rgb_gray, re), "verde=RGBgray magenta=RE"), (f"RGBgray vs NIR ({mode_txt})", falsecolor_overlay(rgb_gray, nir), "verde=RGBgray magenta=NIR"), (f"RE vs NIR ({mode_txt})", falsecolor_overlay(re, nir), "verde=RE magenta=NIR"), (f"RGB + RE tint ({mode_txt})", alpha_blend(rgb_bgr, re, alpha=0.38, cmap=cv2.COLORMAP_INFERNO), "RGB com RE"), (f"RGB + NIR tint ({mode_txt})", alpha_blend(rgb_bgr, nir, alpha=0.38, cmap=cv2.COLORMAP_VIRIDIS), "RGB com NIR"), (f"RGB + RE edges ({mode_txt})", draw_edges_on_rgb(rgb_bgr, re, (0, 0, 255)), "bordas RE sobre RGB"), ]) if self.state.show_edges: panels.extend([ ("Edge overlay bruto", edge_overlay(rgb_gray, re_raw, nir_raw), "G=RGB | R=RE | B=NIR"), (f"Edge overlay {mode_txt}", edge_overlay(rgb_gray, re, nir), "G=RGB | R=RE | B=NIR"), (f"RGB + NIR edges ({mode_txt})", draw_edges_on_rgb(rgb_bgr, nir, (255, 255, 0)), "bordas NIR sobre RGB"), ]) # Debug de homografia/crop atual no modo native. fusion_debug: Optional[FusionDebug] = sample.get("fusion_debug") if self.state.show_crop_debug and sample.get("space") == "native": if fusion_debug and fusion_debug.available: warped_re = resize_to(fusion_debug.warped_re, rgb_bgr.shape[:2]) warped_nir = resize_to(fusion_debug.warped_nir, rgb_bgr.shape[:2]) common = fusion_debug.common_mask.astype(np.uint8) * 255 common_bgr = cv2.cvtColor(common, cv2.COLOR_GRAY2BGR) crop_rgb = draw_crop_box(rgb_bgr, fusion_debug.crop_box, "crop comum atual") panels.extend([ ("Debug homografia atual RE", falsecolor_overlay(rgb_gray, warped_re), "verde=RGBgray magenta=RE warp atual"), ("Debug homografia atual NIR", falsecolor_overlay(rgb_gray, warped_nir), "verde=RGBgray magenta=NIR warp atual"), ("Mascara valida comum", common_bgr, f"crop={fusion_debug.crop_box}"), ("Crop comum atual", crop_rgb, "area que vira tensor final"), ("Warp atual RE vs NIR", falsecolor_overlay(warped_re, warped_nir), "verde=RE magenta=NIR apos H atual"), ("Edge H atual", edge_overlay(rgb_gray, warped_re, warped_nir), "G=RGB | R=RE | B=NIR"), ]) else: note = fusion_debug.note if fusion_debug else "sem fusion debug" panels.append(("Crop debug indisponivel", make_info_panel([note], size=(900, 260)), "")) canvas = make_grid(panels, panel_w=self.state.panel_w, cols=3) footer_h = 34 footer = np.full((footer_h, canvas.shape[1], 3), 18, dtype=np.uint8) footer_text = ( f"sample {self.index+1}/{len(self.entries)} | space={space_txt} | exibicao={mode_txt} | force={self.state.force_apply} | metodo={self.state.method} | prioridade={self.state.priority} | " f"RE g={re_align.edge_corr_before:.3f}->{re_align.edge_corr_after:.3f} roi={re_align.roi_corr_before:.3f}->{re_align.roi_corr_after:.3f} | " f"NIR g={nir_align.edge_corr_before:.3f}->{nir_align.edge_corr_after:.3f} roi={nir_align.roi_corr_before:.3f}->{nir_align.roi_corr_after:.3f}" ) cv2.putText(footer, cv_text(footer_text[:260]), (10, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.52, (220, 220, 220), 1, cv2.LINE_AA) return np.vstack([canvas, footer]) def save_current(self, canvas: np.ndarray, entry: SampleEntry): mode_name = 'proposto' if (self.state.show_corrected and self.state.force_apply) else ('corr' if self.state.show_corrected else 'raw') fname = f"{entry.sample_name}__space-{self.state.space}__{self.state.method}__{self.state.priority}__{mode_name}.png" out_path = self.save_dir / fname cv2.imwrite(str(out_path), canvas) print(f"[OK] painel salvo: {out_path}") def next_method(self): methods = ["phase", "ecc_translation", "ecc_euclidean", "ecc_affine", "none"] cur = methods.index(self.state.method) if self.state.method in methods else 0 self.state.method = methods[(cur + 1) % len(methods)] def next_priority(self): priorities = ["global", "largest_blob", "gt_target"] cur = priorities.index(self.state.priority) if self.state.priority in priorities else 0 self.state.priority = priorities[(cur + 1) % len(priorities)] def next_space(self): spaces = ["final", "native"] cur = spaces.index(self.state.space) if self.state.space in spaces else 0 self.state.space = spaces[(cur + 1) % len(spaces)] def run(self): cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL) cv2.resizeWindow(self.window_name, 1640, 980) while True: entry = self.entries[self.index] try: sample = self.load_current_sample() canvas = self.build_panels(sample) except Exception as e: canvas = make_info_panel([ f"Erro ao carregar sample {self.index+1}/{len(self.entries)}", str(entry.meta_path), str(e), "Use A/D para navegar, Q para sair.", ], size=(1100, 420)) print(f"[ERRO] {entry.sample_name}: {e}") cv2.imshow(self.window_name, canvas) key = cv2.waitKeyEx(0) if key in (27, ord('q'), ord('Q')): break elif key in (ord('d'), ord('D'), 2555904): self.index = min(self.index + 1, len(self.entries) - 1) elif key in (ord('a'), ord('A'), 2424832): self.index = max(self.index - 1, 0) elif key == 2490368: self.index = max(self.index - 10, 0) elif key == 2621440: self.index = min(self.index + 10, len(self.entries) - 1) elif key in (ord('c'), ord('C')): self.state.show_corrected = not self.state.show_corrected elif key in (ord('f'), ord('F')): self.state.force_apply = not self.state.force_apply if self.state.force_apply: self.state.show_corrected = True elif key in (ord('x'), ord('X')): self.next_space() elif key in (ord('m'), ord('M')): self.next_method() elif key in (ord('p'), ord('P')): self.next_priority() elif key in (ord('e'), ord('E')): self.state.show_edges = not self.state.show_edges elif key in (ord('k'), ord('K')): self.state.show_mask = not self.state.show_mask elif key in (ord('v'), ord('V')): self.state.show_crop_debug = not self.state.show_crop_debug elif key in (ord('s'), ord('S')): self.save_current(canvas, entry) elif key in (ord('h'), ord('H')): print("\n=== HELP V2 ===") print("A / Left : amostra anterior") print("D / Right : proxima amostra") print("Up / Down : pula -10 / +10") print("X : alterna space final/native") print("C : alterna bruto/corrigido") print("F : forca aplicar warp proposto mesmo se rejeitado") print("M : alterna metodo phase/ecc_translation/ecc_euclidean/ecc_affine/none") print("P : alterna prioridade global/largest_blob/gt_target") print("V : mostra/esconde debug de homografia/crop atual") print("E : alterna paineis de borda") print("K : mostra/esconde mascara") print("S : salva painel atual") print("Q / Esc : sair") print("==============\n") cv2.destroyAllWindows() # ============================================================ # CLI # ============================================================ def build_argparser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser( description="Navegador visual V2 para comparar alinhamento nativo/final RGB/RE/NIR e testar correcao por bordas." ) ap.add_argument("--input_path", type=str, required=True, help="Raiz do dataset ou super-root com grupos.") ap.add_argument("--groups-except", type=str, default="", help="Ex: chao ou chao,chao_cana") ap.add_argument("--start-index", type=int, default=0, help="Indice inicial para navegacao.") ap.add_argument("--space", type=str, default="native", choices=["native", "final"], help="Espaco inicial de visualizacao.") ap.add_argument("--method", type=str, default="phase", choices=["phase", "ecc_translation", "ecc_euclidean", "ecc_affine", "none"], help="Metodo inicial de correcao dinamica.") ap.add_argument("--priority", type=str, default="global", choices=["global", "largest_blob", "gt_target"], help="Prioridade inicial para ROI do alinhamento.") ap.add_argument("--target-classes", type=str, default="1,2", help="Classes usadas no modo gt_target. Padrao: 1,2 = cana,erva") ap.add_argument("--panel-w", type=int, default=410, help="Largura de cada painel no grid.") ap.add_argument("--max-shift-px", type=float, default=35.0, help="Limite de translacao aceito para a correcao dinamica.") ap.add_argument("--max-rotation-deg", type=float, default=3.0, help="Limite de rotacao aceito para ECC euclidean/affine.") ap.add_argument("--min-improve-corr", type=float, default=-0.003, help="Melhoria minima aceitavel na correlacao. Negativo leve permite correcao equivalente.") ap.add_argument("--hide-crop-debug", action="store_true", help="Esconde paineis de debug da homografia/crop atual no modo native.") ap.add_argument("--save-dir", type=str, default="alignment_browser_v2_out", help="Pasta para salvar paineis com tecla S.") return ap if __name__ == "__main__": args = build_argparser().parse_args() browser = DatasetAlignmentBrowserV2(args) browser.run()