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

1200 lines
49 KiB
Python

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()