agrobot_base/Python/OAK/datasets/oak-fcc-3/utils/dataset_alignment_browser.py

1200 lines
49 KiB
Python
Raw Normal View History

2026-05-26 11:01:47 +00:00
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()