ajustes no sistema de treinamento multiespectral e perfil de homografia

This commit is contained in:
Diego Freitas 2026-06-03 07:37:15 -03:00
parent 441d5f5d01
commit c591e53d67
15 changed files with 6195 additions and 2103 deletions

3
.gitignore vendored
View File

@ -69,9 +69,10 @@ Python/OAK/datasets/oak-fcc-3/backup/
Python/OAK/datasets/oak-fcc-3/dataset/
Python/OAK/datasets/oak-fcc-3/audit_multispec_out/
Python/OAK/datasets/oak-fcc-3/depth_probe_out/
Python/OAK/datasets/oak-fcc-3/calibration/multicam_charuco_calib_out/debug/
Python/OAK/datasets/oak-fcc-3/calibration/multicam_charuco_calib_out/
Python/OAK/datasets/oak-fcc-3/calibration/stereo_charuco_calib_out/debug/
Python/OAK/datasets/oak-fcc-3/calibration/stereo_dataset/
Python/OAK/datasets/oak-fcc-3/calibration/dataset_homography/
Python/OAK/datasets/oak-fcc-3/.cache/
Python/OAK/datasets/gal5000/dataset/
Python/OAK/datasets/gal5000/backup/

View File

@ -1,7 +1,9 @@
import json
import os
import time
import threading
from pathlib import Path
from datetime import datetime
import cv2
import numpy as np
@ -1045,3 +1047,339 @@ class CameraMultispectral:
return out
# ============================================================
# Salvamento científico / pós-processamento
# ============================================================
def _ts_name(self) -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
def requisitar_bundle_raw_multispec(self, force: bool = True, max_age_s: float = None):
"""
Retorna um bundle científico RAW_BRUTO completo.
Retorno:
bundle, resultado
bundle = {
"raw_frame": {
"CAM_A": np.ndarray RAW10 packed,
"CAM_B": np.ndarray RAW10 packed,
"CAM_C": np.ndarray RAW10 packed,
},
"raw_meta": dict,
"preview_bgr": np.ndarray BGR uint8 ou None,
"preview_method": str,
}
Este método não salva nada em disco.
Ele apenas coleta e organiza o pacote bruto.
"""
try:
agora = time.perf_counter()
if max_age_s is None:
max_age_s = self._cache_max_age_s
with self._lock:
cache_ok = (
self.ultimo_raw_multi is not None
and self.ultimo_meta is not None
and self.timestamp_ultimo_raw_multi is not None
and (agora - self.timestamp_ultimo_raw_multi) < max_age_s
)
if cache_ok and not force:
raw_frame = {
cam_id: arr.copy()
for cam_id, arr in self.ultimo_raw_multi.items()
}
raw_meta = dict(self.ultimo_meta)
preview_bgr, preview_method = self._build_preview_raw_multispec(
raw_frame=raw_frame,
raw_meta=raw_meta,
)
bundle = {
"raw_frame": raw_frame,
"raw_meta": raw_meta,
"preview_bgr": preview_bgr,
"preview_method": preview_method,
}
return bundle, dict(self._ultimo_resultado_raw)
if self.client is None:
raise RuntimeError("OakFcc3Client não inicializado")
t0 = time.perf_counter()
raw_frame, raw_meta = self.client.get_next_raw_frame(
timeout=self.timeout_s
)
dur = time.perf_counter() - t0
if not isinstance(raw_frame, dict) or not raw_frame:
raise RuntimeError(
f"RAW_BRUTO inválido. Esperado dict por câmera, veio {type(raw_frame)}"
)
raw_meta = dict(raw_meta or {})
frame_type = str(raw_meta.get("frame_type", "")).upper()
if frame_type and frame_type != "RAW_BRUTO":
raise RuntimeError(
f"Bundle científico esperado em RAW_BRUTO, mas veio frame_type={frame_type}"
)
required = {"CAM_A", "CAM_B", "CAM_C"}
presentes = set(raw_frame.keys())
faltando = sorted(required - presentes)
if faltando:
raise RuntimeError(
f"RAW_BRUTO incompleto. Faltando câmeras: {faltando}. Presentes: {sorted(presentes)}"
)
raw_frame_copy = {
cam_id: arr.copy()
for cam_id, arr in raw_frame.items()
}
preview_bgr, preview_method = self._build_preview_raw_multispec(
raw_frame=raw_frame_copy,
raw_meta=raw_meta,
)
resultado = {
"erro": None,
"duracao": dur,
"frame_valido": True,
"cameras": list(raw_frame_copy.keys()),
"sync_ok": bool(raw_meta.get("sync_ok", True)),
"sync_dt_ms": float(raw_meta.get("sync_dt_ms", 0.0) or 0.0),
"frame_id": raw_meta.get("frame_id"),
"preview_method": preview_method,
}
with self._lock:
self.ultimo_raw_multi = raw_frame_copy
self.ultimo_meta = raw_meta
self.timestamp_ultimo_raw_multi = agora
self._ultimo_resultado_raw = resultado
bundle = {
"raw_frame": raw_frame_copy,
"raw_meta": raw_meta,
"preview_bgr": preview_bgr,
"preview_method": preview_method,
}
return bundle, resultado
except Exception as e:
resultado = {
"erro": str(e),
"duracao": 0.0,
"frame_valido": False,
}
with self._lock:
self._ultimo_resultado_raw = resultado
self.mostrar_log(
f"[CameraMultispectral] Erro ao requisitar bundle RAW multispec: {e}"
)
if self._is_erro_fatal_depthai(e):
self._falha_fatal_depthai(e)
return None, resultado
def _build_preview_raw_multispec(self, raw_frame: dict, raw_meta: dict):
"""
Gera preview visual para acompanhar o bundle RAW_BRUTO.
Preferência:
1) build_save_preview_from_cam_a(), igual ao capture atual.
2) build_preview_from_raw_payload(), fallback.
3) None.
"""
if self.client is None:
return None, "client_indisponivel"
try:
preview = self.client.build_save_preview_from_cam_a(
packed_raw_by_camera=raw_frame,
meta_stream=raw_meta,
sensor_width=self.width,
sensor_height=self.height,
bayer_pattern="BGGR",
)
if preview is not None:
return preview, "cam_a_reconstructed_raw10"
except Exception as e:
self.mostrar_log(
f"[CameraMultispectral] Falha ao gerar preview CAM_A RAW10: {e}"
)
try:
preview, _, preview_source_id = self.client.build_preview_from_raw_payload(
frame=raw_frame,
meta=raw_meta,
)
if preview is not None:
return preview, f"raw_payload_preview_{preview_source_id}"
except Exception as e:
self.mostrar_log(
f"[CameraMultispectral] Falha no fallback de preview RAW: {e}"
)
return None, "preview_indisponivel"
def salvar_bundle_raw_multispec(
self,
pasta: str,
nome: str = "",
nota: str = "operacao",
extra_meta: dict = None,
):
"""
Salva pacote RAW_BRUTO multiespectral no mesmo espírito do capture de dataset.
Saída:
<nome>.png
<nome>.json
<nome>_CAM_A.bin
<nome>_CAM_B.bin
<nome>_CAM_C.bin
Retorna:
list[str] com os caminhos salvos.
"""
try:
os.makedirs(pasta, exist_ok=True)
nome_base = nome.strip() if nome else self._ts_name()
bundle, resultado = self.requisitar_bundle_raw_multispec(
force=True,
max_age_s=0.0,
)
if not resultado.get("frame_valido", False):
raise RuntimeError(
resultado.get("erro") or "Bundle RAW multispec inválido"
)
raw_frame = bundle["raw_frame"]
raw_meta = bundle["raw_meta"]
preview_bgr = bundle.get("preview_bgr")
preview_method = bundle.get("preview_method")
caminhos = []
payload_files = {}
payload_shapes = {}
payload_dtypes = {}
for cam_id, arr in raw_frame.items():
if arr is None:
continue
caminho_bin = os.path.join(
pasta,
f"{nome_base}_{cam_id}.bin"
)
arr.tofile(caminho_bin)
payload_files[cam_id] = os.path.basename(caminho_bin)
payload_shapes[cam_id] = list(arr.shape)
payload_dtypes[cam_id] = str(arr.dtype)
caminhos.append(caminho_bin)
if not payload_files:
raise RuntimeError("Nenhum payload RAW foi salvo.")
caminho_preview = None
if preview_bgr is not None and hasattr(preview_bgr, "size") and preview_bgr.size > 0:
caminho_preview = os.path.join(
pasta,
f"{nome_base}.png"
)
cv2.imwrite(caminho_preview, preview_bgr)
caminhos.append(caminho_preview)
meta_save = {
"ts": datetime.now().isoformat(timespec="milliseconds"),
"source": "operacao_robo",
"note": nota,
"camera_model": self.modelo,
"mx_id": self.mx_id,
"module_calibration_json": self.module_calibration_json,
"sensor_width": self.width,
"sensor_height": self.height,
"bayer_pattern": "BGGR",
"fps_target": self.fps,
"frame_type": "RAW_BRUTO",
"capture_mode_requested": "TRIPLE",
"capture_mode_effective": "TRIPLE",
"raw_policy": "require_triple",
"saved_payload_type": "raw_native_multi",
"saved_payload_paths": payload_files,
"saved_payload_shapes": payload_shapes,
"saved_payload_dtypes": payload_dtypes,
"saved_preview_path": os.path.basename(caminho_preview) if caminho_preview else None,
"saved_preview_method": preview_method,
"stream_meta": raw_meta,
"actual_camera_controls": self.client.get_current_camera_controls() if self.client else None,
"radiometric_last_result": self.client.get_radiometric_last_result() if self.client else None,
"resultado": resultado,
}
if extra_meta:
meta_save["extra"] = extra_meta
caminho_json = os.path.join(
pasta,
f"{nome_base}.json"
)
with open(caminho_json, "w", encoding="utf-8") as f:
json.dump(meta_save, f, ensure_ascii=False, indent=2)
caminhos.append(caminho_json)
return caminhos
except Exception as e:
self.mostrar_log(
f"[CameraMultispectral] Erro ao salvar bundle RAW multispec: {e}"
)
if self._is_erro_fatal_depthai(e):
self._falha_fatal_depthai(e)
return []

View File

@ -1090,17 +1090,7 @@ class RawProcessorCore:
warped_mask = self._affine_image(mask, dx, dy, theta_deg)
elif mode == "homography":
H = cfg.get("homographies", {}).get(f"{role}_to_rgb")
if H is None:
raise RuntimeError(
f"fusion_config.alignment_mode='homography', "
f"mas homografia '{role}_to_rgb' está ausente. "
f"Isso deixaria o canal {role.upper()} sem alinhamento."
)
calib_size = cfg.get("homography_calibration_size", None)
H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
H = self._scale_homography_to_runtime(
H,
calib_size=calib_size,
@ -3611,22 +3601,30 @@ class RawProcessorCore:
def _direct_fusion_get_role_homography_fast(self, role, meta, ref_size):
"""
Retorna H_role_to_rgb escalada para o espaço da referência RGB.
Suporta:
- contrato antigo: fusion_config.homographies.re_to_rgb/nir_to_rgb
- contrato novo: fusion_config.homography_profiles.<perfil>.homographies.*
"""
role = str(role).lower()
fusion = getattr(self, "fusion_config", {}) or {}
homographies = fusion.get("homographies", {}) or {}
key = f"{role}_to_rgb"
H = homographies.get(key)
H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
if H is None:
# Fallbacks para contratos diferentes.
H = homographies.get(role)
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
if H is None:
raise RuntimeError(f"Homografia ausente para role={role}. Esperado fusion_config.homographies.{key}")
H_scaled = self._scale_homography_to_runtime(
H,
calib_size=calib_size,
runtime_size=(ref_w, ref_h),
)
return self._direct_fusion_scale_homography_for_ref_fast(H, meta, ref_size)
if H_scaled is None or H_scaled.shape != (3, 3):
raise RuntimeError(
f"Homografia inválida para role={role}, profile={profile_name}: "
f"shape={None if H_scaled is None else H_scaled.shape}"
)
return H_scaled.astype(np.float32)
def _direct_fusion_resize_spec_to_ref_if_needed_fast(self, img, ref_size):
"""
@ -3770,6 +3768,7 @@ class RawProcessorCore:
"geometry_cache_hit": bool(geom.get("prepare_cache_hit", False)),
"geometry_cache_hits": int(geom.get("cache_hits", 0)),
"geometry_cache_misses": int(geom.get("cache_misses", 0)),
"homography_profiles_used": geom.get("homography_profiles_used", {}),
}
tensor = np.empty((int(channels_expected), target_h, target_w), dtype=np.float32)
@ -3962,34 +3961,38 @@ class RawProcessorCore:
"""
Chave simples e estável para cache da geometria.
A geometria depende de:
- tamanho do RGB de referência
Considera:
- tamanho do RGB/ref
- target final
- roles presentes
- crop_valid_common / resize_after_crop
- homografias e calibration_size
Para evitar custo de serializar o JSON todo por frame, usamos uma versão
simples. Se você editar module_params em runtime, chame
clear_direct_fusion_geometry_cache().
- crop/resize
- homografia efetivamente selecionada por perfil
- calibration_size efetivo por role
"""
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
target_w, target_h = int(target_size[0]), int(target_size[1])
fusion = getattr(self, "fusion_config", {}) or {}
homographies = fusion.get("homographies", {}) or {}
# Pequena assinatura numérica das homografias.
def h_sig(key):
H = homographies.get(key)
if H is None:
return None
arr = np.asarray(H, dtype=np.float32).reshape(-1)
# arredonda para evitar ruído float/json, mas detecta mudança real.
return tuple(np.round(arr, 8).tolist())
roles = tuple(sorted([str(r).lower() for r in role_to_cam.keys()]))
def h_sig_for_role(role):
role = str(role).lower()
if role not in role_to_cam:
return None
try:
H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
except Exception:
return None
arr = np.asarray(H, dtype=np.float32).reshape(-1)
return (
str(profile_name),
tuple(calib_size or []),
tuple(np.round(arr, 8).tolist()),
)
return (
ref_w,
ref_h,
@ -3998,9 +4001,8 @@ class RawProcessorCore:
roles,
bool(fusion.get("crop_valid_common", False)),
bool(fusion.get("resize_after_crop", False)),
tuple(fusion.get("homography_calibration_size") or fusion.get("calibration_size") or []),
h_sig("re_to_rgb"),
h_sig("nir_to_rgb"),
h_sig_for_role("re"),
h_sig_for_role("nir"),
)
def clear_direct_fusion_geometry_cache(self):
@ -4053,8 +4055,14 @@ class RawProcessorCore:
# Homografias escaladas para runtime.
# ------------------------------------------------------------
H_role_to_rgb = {}
homography_profiles_used = {}
for role in ("re", "nir"):
if role in role_to_cam:
H_raw, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
homography_profiles_used[role] = {
"profile": profile_name,
"calib_size": list(calib_size) if calib_size is not None else None,
}
H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast(role, meta, ref_size)
# ------------------------------------------------------------
@ -4103,6 +4111,7 @@ class RawProcessorCore:
"prepare_cache_hit": False,
"cache_hits": int(self._direct_fusion_geometry_cache_hits),
"cache_misses": int(self._direct_fusion_geometry_cache_misses),
"homography_profiles_used": homography_profiles_used,
}
# Cache pequeno: normalmente só uma geometria. Se mudar resolução/config,
@ -4376,6 +4385,120 @@ class RawProcessorCore:
tensor[int(channel_index)] = out
def _resolve_homography_profile_name_for_role(self, role: str) -> str:
"""
Resolve qual perfil de homografia usar para uma role.
Prioridade:
1) fusion_config.homography_profile_by_role[role]
2) fusion_config.homography_profile
3) "default"
"""
role = str(role).lower()
fusion = getattr(self, "fusion_config", {}) or {}
by_role = fusion.get("homography_profile_by_role", {}) or {}
if isinstance(by_role, dict):
selected = by_role.get(role)
if selected:
return str(selected).lower()
selected = fusion.get("homography_profile", None)
if selected:
return str(selected).lower()
return "default"
def _resolve_homography_entry_for_role(self, role: str):
"""
Resolve a homografia no contrato novo ou antigo.
Contrato novo:
fusion_config.homography_profiles.<perfil>.homographies.<role>_to_rgb
Contrato antigo:
fusion_config.homographies.<role>_to_rgb
Retorna:
H, calib_size, profile_name
"""
role = str(role).lower()
fusion = getattr(self, "fusion_config", {}) or {}
key = f"{role}_to_rgb"
selected_profile = self._resolve_homography_profile_name_for_role(role)
# ------------------------------------------------------------
# Futuro: auto por profundidade.
# Por enquanto, cai em media/default de forma explícita.
# ------------------------------------------------------------
if selected_profile == "auto":
profiles = fusion.get("homography_profiles", {}) or {}
if "media" in profiles:
selected_profile = "media"
elif "default" in profiles:
selected_profile = "default"
else:
selected_profile = ""
# ------------------------------------------------------------
# Contrato novo: homography_profiles
# ------------------------------------------------------------
profiles = fusion.get("homography_profiles", {}) or {}
if isinstance(profiles, dict) and selected_profile:
profile = profiles.get(selected_profile)
if profile is None:
# tolera nomes com caixa diferente
for name, item in profiles.items():
if str(name).lower() == selected_profile:
profile = item
selected_profile = str(name)
break
if isinstance(profile, dict):
profile_homographies = profile.get("homographies", {}) or {}
H = profile_homographies.get(key)
if H is None:
# fallback curto: "re" ou "nir"
H = profile_homographies.get(role)
if H is not None:
calib_size = (
profile.get("homography_calibration_size")
or profile.get("calibration_size")
or fusion.get("homography_calibration_size")
or fusion.get("calibration_size")
or None
)
return H, calib_size, selected_profile
# ------------------------------------------------------------
# Contrato antigo: homographies direto
# ------------------------------------------------------------
homographies = fusion.get("homographies", {}) or {}
H = homographies.get(key)
if H is None:
H = homographies.get(role)
if H is not None:
calib_size = (
fusion.get("homography_calibration_size")
or fusion.get("calibration_size")
or None
)
return H, calib_size, "legacy"
raise RuntimeError(
f"Homografia ausente para role={role}. "
f"Procurei profile='{selected_profile}' em "
f"fusion_config.homography_profiles.*.homographies.{key} "
f"e fallback fusion_config.homographies.{key}"
)
def _raw10_rgb_linear_demosaic_to_rgb_float01_fast(
@ -4806,3 +4929,6 @@ class RawProcessorCore:
self._flatfield_runtime_cache[key] = gain_tensor
return gain_tensor

File diff suppressed because it is too large Load Diff

View File

@ -372,6 +372,7 @@ def build_tensor_from_sample(
"module_params": module_params_path,
"bayer_pattern": bayer,
"fusion_result": copy_json_safe(getattr(core, "last_fusion_result", None)),
"radiometric_normalization_result": copy_json_safe(getattr(core, "last_radiometric_normalization_result", None)),
"patch_normalization_result": copy_json_safe(getattr(core, "last_patch_normalization_result", None)),
"frame_quality": copy_json_safe(getattr(core, "last_frame_quality_result", None)),
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -288,6 +288,7 @@ class OakFcc3TensorMultiHeadDataset(Dataset):
self.channel_indices = channel_indices
self.samples = self._collect_samples()
self._add_sample_class_stats()
if not self.samples:
raise RuntimeError(f"Nenhuma amostra encontrada em: {self.root}")
@ -450,6 +451,59 @@ class OakFcc3TensorMultiHeadDataset(Dataset):
"base": s["base"],
}
def _add_sample_class_stats(self):
for s in self.samples:
stats = {
"pixels_total": 0,
"pixels_chao": 0,
"pixels_cana": 0,
"pixels_erva": 0,
"pixels_vegetation": 0,
"pixels_target": 0,
"pct_cana": 0.0,
"pct_erva": 0.0,
"pct_target": 0.0,
"has_cana": False,
"has_erva": False,
"has_target": False,
}
sem_path = s["masks"].get("semantic")
veg_path = s["masks"].get("vegetation")
cana_path = s["masks"].get("cana")
if sem_path is not None and Path(sem_path).exists():
sem = np.load(str(sem_path)).astype(np.int64)
valid = sem != 255
total = int(valid.sum())
stats["pixels_total"] = total
if total > 0:
stats["pixels_chao"] = int(((sem == 0) & valid).sum())
stats["pixels_cana"] = int(((sem == 1) & valid).sum())
stats["pixels_erva"] = int(((sem == 2) & valid).sum())
stats["pct_cana"] = stats["pixels_cana"] / total
stats["pct_erva"] = stats["pixels_erva"] / total
if veg_path is not None and cana_path is not None and Path(veg_path).exists() and Path(cana_path).exists():
veg = np.load(str(veg_path)).astype(np.int64)
cana = np.load(str(cana_path)).astype(np.int64)
valid = (veg != 255) & (cana != 255)
total = int(valid.sum())
if total > 0:
target = (veg == 1) & (cana == 0) & valid
stats["pixels_vegetation"] = int(((veg == 1) & valid).sum())
stats["pixels_target"] = int(target.sum())
stats["pct_target"] = stats["pixels_target"] / total
stats["has_cana"] = stats["pixels_cana"] > 0
stats["has_erva"] = stats["pixels_erva"] > 0
stats["has_target"] = stats["pixels_target"] > 0
s["class_stats"] = stats
def collate_fn(batch):
imgs = torch.stack([b["image"] for b in batch], dim=0)
@ -534,6 +588,49 @@ def build_normalizer(config: dict, args, device: torch.device):
return None, None
def build_sample_weights(ds, mode="target_focus"):
weights = []
for s in ds.samples:
st = s.get("class_stats", {})
group = str(s.get("group", "")).lower()
pct_cana = float(st.get("pct_cana", 0.0))
pct_erva = float(st.get("pct_erva", 0.0))
pct_target = float(st.get("pct_target", 0.0))
has_cana = bool(st.get("has_cana", False))
has_erva = bool(st.get("has_erva", False))
has_target = bool(st.get("has_target", False))
w = 1.0
# Reduz chão puro
if not has_cana and not has_erva and not has_target:
w *= 0.35
# Aumenta cana
if has_cana:
w *= 1.25
# Aumenta erva/target com força
if has_erva:
w *= 3.0
if has_target:
w *= 4.0
# Bônus suave por área real de target/erva
w *= 1.0 + min(5.0, 80.0 * pct_target)
w *= 1.0 + min(3.0, 50.0 * pct_erva)
# Evita pesos absurdos
w = max(0.05, min(w, 20.0))
weights.append(w)
return torch.tensor(weights, dtype=torch.double)
DEFAULT_CHANNEL_ORDER = ["R", "G", "B", "RE", "NIR"]
def get_input_channel_names(config: dict) -> List[str]:
@ -1493,6 +1590,9 @@ def main():
parser.add_argument("--early-stop", type=int, default=25)
parser.add_argument("--balanced_sampler", action="store_true")
parser.add_argument("--samples_per_epoch", type=int, default=0)
args = parser.parse_args()
set_seed(args.seed)
@ -1579,10 +1679,32 @@ def main():
print(f"[DATA] train={len(ds_train)} | val={len(ds_val)}")
train_sampler = None
train_shuffle = True
if args.balanced_sampler:
from torch.utils.data import WeightedRandomSampler
sample_weights = build_sample_weights(ds_train)
num_samples = int(args.samples_per_epoch) if args.samples_per_epoch > 0 else len(ds_train)
train_sampler = WeightedRandomSampler(
weights=sample_weights,
num_samples=num_samples,
replacement=True,
)
train_shuffle = False
print("[SAMPLER] WeightedRandomSampler ativado")
print(f"[SAMPLER] peso min={float(sample_weights.min()):.3f} "
f"max={float(sample_weights.max()):.3f} "
f"mean={float(sample_weights.mean()):.3f}")
dl_train = DataLoader(
ds_train,
batch_size=args.batch,
shuffle=True,
shuffle=train_shuffle,
sampler=train_sampler,
num_workers=args.num_workers,
pin_memory=True,
collate_fn=collate_fn,

View File

@ -0,0 +1,257 @@
# export_depthai_stereo_dataset.py
import argparse
import re
from pathlib import Path
import cv2
import numpy as np
# ============================================================
# RAW10 unpack / preview
# ============================================================
def unpack_raw10_packed(raw: bytes, width: int, height: int) -> np.ndarray:
"""
RAW10 packed:
5 bytes = 4 pixels de 10 bits.
Retorna uint16 HxW em 0..1023.
"""
arr = np.frombuffer(raw, dtype=np.uint8)
pixel_count = width * height
expected_bytes = (pixel_count // 4) * 5
if pixel_count % 4 != 0:
raise RuntimeError(f"width*height precisa ser múltiplo de 4. Recebido: {pixel_count}")
if arr.size < expected_bytes:
raise RuntimeError(
f"RAW10 menor que esperado: bytes={arr.size}, esperado={expected_bytes}, "
f"width={width}, height={height}"
)
arr = arr[:expected_bytes]
groups = arr.reshape(-1, 5).astype(np.uint16)
p0 = (groups[:, 0] << 2) | ((groups[:, 4] >> 0) & 0x03)
p1 = (groups[:, 1] << 2) | ((groups[:, 4] >> 2) & 0x03)
p2 = (groups[:, 2] << 2) | ((groups[:, 4] >> 4) & 0x03)
p3 = (groups[:, 3] << 2) | ((groups[:, 4] >> 6) & 0x03)
out = np.empty(groups.shape[0] * 4, dtype=np.uint16)
out[0::4] = p0
out[1::4] = p1
out[2::4] = p2
out[3::4] = p3
return out.reshape(height, width)
def normalize_to_u8(img: np.ndarray, p_low=1.0, p_high=99.0) -> np.ndarray:
arr = img.astype(np.float32)
valid = np.isfinite(arr)
if np.count_nonzero(valid) < 20:
return np.zeros(arr.shape[:2], dtype=np.uint8)
vals = arr[valid]
lo = np.percentile(vals, p_low)
hi = np.percentile(vals, p_high)
out = (arr - lo) / (hi - lo + 1e-6)
out = np.clip(out, 0.0, 1.0)
return (out * 255).astype(np.uint8)
def read_raw10_mono_png_ready(path: Path, width: int, height: int, use_clahe: bool) -> np.ndarray:
raw = path.read_bytes()
raw10 = unpack_raw10_packed(raw, width, height)
gray = normalize_to_u8(raw10)
if use_clahe:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
gray = clahe.apply(gray)
return gray
# ============================================================
# Pairing
# ============================================================
def clean_stem_for_pair(path: Path, cam_key: str) -> str:
s = path.stem
variants = [
cam_key,
cam_key.lower(),
cam_key.replace("_", ""),
cam_key.replace("_", "").lower(),
]
for v in variants:
s = s.replace(v, "")
s = re.sub(r"[_\-\s]+", "_", s).strip("_").lower()
return s
def find_cam_bins(root_dir: Path, cam_key: str):
return sorted([p for p in root_dir.rglob("*.bin") if cam_key.lower() in p.name.lower()])
def find_pairs(root_dir: Path, left_cam: str, right_cam: str):
left_paths = find_cam_bins(root_dir, left_cam)
right_paths = find_cam_bins(root_dir, right_cam)
right_map = {}
for p in right_paths:
key = clean_stem_for_pair(p, right_cam)
right_map[(p.parent, key)] = p
right_map.setdefault((None, key), p)
pairs = []
for lp in left_paths:
key = clean_stem_for_pair(lp, left_cam)
folder = lp.parent
rp = right_map.get((folder, key)) or right_map.get((None, key))
if rp is None:
same_folder = [x for x in right_paths if x.parent == folder]
if len(same_folder) == 1:
rp = same_folder[0]
if rp is not None:
pairs.append((lp, rp))
return pairs, left_paths, right_paths
# ============================================================
# Main
# ============================================================
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root_dir", required=True, help="Pasta onde estão os .bin CAM_A/CAM_B/CAM_C")
parser.add_argument("--out_dir", required=True, help="Pasta de saída. Ex: C:/dev/depthai/dataset")
parser.add_argument("--width", type=int, default=1280)
parser.add_argument("--height", type=int, default=800)
# Para DepthAI stereo:
# left = CAM_C / NIR
# right = CAM_B / RE
parser.add_argument("--left_cam", default="CAM_C")
parser.add_argument("--right_cam", default="CAM_B")
parser.add_argument("--prefix", default="p", help="Prefixo dos arquivos. Default: p")
parser.add_argument("--suffix", default="_0", help="Sufixo depois do índice. Default: _0")
parser.add_argument("--start_index", type=int, default=0)
parser.add_argument("--no_clahe", action="store_true")
parser.add_argument("--overwrite", action="store_true")
parser.add_argument("--save_preview", action="store_true")
parser.add_argument(
"--images_per_pose",
type=int,
default=3,
help="Quantidade de imagens por pose para gerar nomes tipo p0_0, p0_1, p0_2, p1_3..."
)
args = parser.parse_args()
root_dir = Path(args.root_dir)
out_dir = Path(args.out_dir)
left_dir = out_dir / "left"
right_dir = out_dir / "right"
left_dir.mkdir(parents=True, exist_ok=True)
right_dir.mkdir(parents=True, exist_ok=True)
preview_dir = out_dir / "_preview_pairs"
if args.save_preview:
preview_dir.mkdir(parents=True, exist_ok=True)
pairs, left_paths, right_paths = find_pairs(root_dir, args.left_cam, args.right_cam)
print(f"[INFO] root_dir={root_dir}")
print(f"[INFO] out_dir={out_dir}")
print(f"[INFO] left_cam={args.left_cam} -> {left_dir}")
print(f"[INFO] right_cam={args.right_cam} -> {right_dir}")
print(f"[INFO] arquivos left encontrados: {len(left_paths)}")
print(f"[INFO] arquivos right encontrados: {len(right_paths)}")
print(f"[INFO] pares encontrados: {len(pairs)}")
print(f"[INFO] size={args.width}x{args.height}")
print(f"[INFO] clahe={not args.no_clahe}")
if not pairs:
raise RuntimeError("Nenhum par encontrado. Verifique nomes dos arquivos e CAMs.")
for i, (left_path, right_path) in enumerate(pairs):
idx = args.start_index + i
pose_idx = idx // args.images_per_pose
name = f"{args.prefix}{pose_idx}_{idx}.png"
left_out = left_dir / name
right_out = right_dir / name
if not args.overwrite and (left_out.exists() or right_out.exists()):
print(f"[SKIP] {name} já existe. Use --overwrite para sobrescrever.")
continue
try:
left_img = read_raw10_mono_png_ready(
left_path,
width=args.width,
height=args.height,
use_clahe=not args.no_clahe,
)
right_img = read_raw10_mono_png_ready(
right_path,
width=args.width,
height=args.height,
use_clahe=not args.no_clahe,
)
cv2.imwrite(str(left_out), left_img)
cv2.imwrite(str(right_out), right_img)
if args.save_preview:
left_bgr = cv2.cvtColor(left_img, cv2.COLOR_GRAY2BGR)
right_bgr = cv2.cvtColor(right_img, cv2.COLOR_GRAY2BGR)
cv2.putText(left_bgr, f"left {args.left_cam}", (20, 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
cv2.putText(right_bgr, f"right {args.right_cam}", (20, 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
preview = np.hstack([left_bgr, right_bgr])
preview = cv2.resize(preview, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
cv2.imwrite(str(preview_dir / name), preview)
print(f"[OK] {idx:04d}: {left_path.name} -> left/{name} | {right_path.name} -> right/{name}")
except Exception as e:
print(f"[ERRO] par {i}:")
print(f" left ={left_path}")
print(f" right={right_path}")
print(f" erro ={e}")
print("")
print("[DONE] Dataset exportado.")
print(f" left : {left_dir}")
print(f" right: {right_dir}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,307 @@
import argparse
import shutil
from pathlib import Path
import cv2
import numpy as np
def get_aruco_dict(name: str):
aruco = cv2.aruco
name = name.upper()
mapping = {
"4X4_50": aruco.DICT_4X4_50,
"4X4_100": aruco.DICT_4X4_100,
"4X4_250": aruco.DICT_4X4_250,
"4X4_1000": aruco.DICT_4X4_1000,
"5X5_50": aruco.DICT_5X5_50,
"5X5_100": aruco.DICT_5X5_100,
"5X5_250": aruco.DICT_5X5_250,
"5X5_1000": aruco.DICT_5X5_1000,
}
if name not in mapping:
raise RuntimeError(f"Dicionário ArUco não suportado: {name}")
if hasattr(aruco, "Dictionary_get"):
return aruco.Dictionary_get(mapping[name])
return aruco.getPredefinedDictionary(mapping[name])
def count_markers(path: Path, aruco_dict, use_clahe: bool):
img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
if img is None:
return 0, None
proc = img
if use_clahe:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
proc = clahe.apply(proc)
corners, ids, rejected = cv2.aruco.detectMarkers(proc, aruco_dict)
count = 0 if ids is None else len(ids)
return count, proc
def create_charuco_board(squares_x, squares_y, square_size_cm, marker_size_cm, aruco_dict):
return cv2.aruco.CharucoBoard_create(
int(squares_x),
int(squares_y),
float(square_size_cm),
float(marker_size_cm),
aruco_dict
)
def count_charuco(path: Path, aruco_dict, board, use_clahe: bool):
img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
if img is None:
return 0, 0, None
proc = img.copy()
if use_clahe:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
proc = clahe.apply(proc)
marker_corners, marker_ids, rejected = cv2.aruco.detectMarkers(proc, aruco_dict)
marker_count = 0 if marker_ids is None else len(marker_ids)
if marker_ids is None or marker_count == 0:
return marker_count, 0, proc
try:
cv2.aruco.refineDetectedMarkers(
proc,
board,
marker_corners,
marker_ids,
rejectedCorners=rejected
)
except Exception:
pass
ret, charuco_corners, charuco_ids = cv2.aruco.interpolateCornersCharuco(
marker_corners,
marker_ids,
proc,
board,
minMarkers=1
)
charuco_count = 0 if charuco_ids is None else len(charuco_ids)
return marker_count, charuco_count, proc
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dataset_dir", required=True, help="Pasta dataset com left/ e right/")
parser.add_argument("--out_dir", default=None, help="Se informado, cria dataset filtrado em outra pasta")
parser.add_argument("--min_markers", type=int, default=4)
parser.add_argument("--aruco_dict", default="4X4_1000")
parser.add_argument("--images_per_pose", type=int, default=3)
parser.add_argument("--squares_x", type=int, default=13)
parser.add_argument("--squares_y", type=int, default=7)
parser.add_argument("--square_size_cm", type=float, default=3.1)
parser.add_argument("--marker_size_cm", type=float, default=2.3)
parser.add_argument("--min_charuco", type=int, default=20)
parser.add_argument("--no_clahe", action="store_true")
parser.add_argument("--max_pairs", type=int, default=0, help="Limita a quantidade final de pares exportados. Use 39 para DepthAI padrão: 13 poses x 3 imagens.")
parser.add_argument("--overwrite", action="store_true")
parser.add_argument("--copy", action="store_true", help="Copia em vez de mover/reescrever")
parser.add_argument("--save_debug", action="store_true")
args = parser.parse_args()
dataset_dir = Path(args.dataset_dir)
left_dir = dataset_dir / "left"
right_dir = dataset_dir / "right"
if not left_dir.exists() or not right_dir.exists():
raise RuntimeError(f"Dataset precisa ter left/ e right/: {dataset_dir}")
out_dir = Path(args.out_dir) if args.out_dir else dataset_dir
out_left = out_dir / "left"
out_right = out_dir / "right"
rejected_dir = out_dir / "_rejected"
debug_dir = out_dir / "_debug_marker_check"
if args.out_dir:
if out_dir.exists() and args.overwrite:
shutil.rmtree(out_dir)
out_left.mkdir(parents=True, exist_ok=True)
out_right.mkdir(parents=True, exist_ok=True)
else:
# Se for filtrar in-place, primeiro joga tudo para staging.
staging_dir = dataset_dir / "_staging_original"
if staging_dir.exists() and args.overwrite:
shutil.rmtree(staging_dir)
if staging_dir.exists():
raise RuntimeError(
f"Staging já existe: {staging_dir}. "
f"Apague manualmente ou use --overwrite."
)
staging_left = staging_dir / "left"
staging_right = staging_dir / "right"
staging_left.mkdir(parents=True, exist_ok=True)
staging_right.mkdir(parents=True, exist_ok=True)
for p in left_dir.glob("*.png"):
shutil.move(str(p), str(staging_left / p.name))
for p in right_dir.glob("*.png"):
shutil.move(str(p), str(staging_right / p.name))
left_dir = staging_left
right_dir = staging_right
out_left.mkdir(parents=True, exist_ok=True)
out_right.mkdir(parents=True, exist_ok=True)
rejected_dir.mkdir(parents=True, exist_ok=True)
if args.save_debug:
debug_dir.mkdir(parents=True, exist_ok=True)
aruco_dict = get_aruco_dict(args.aruco_dict)
board = create_charuco_board(
args.squares_x,
args.squares_y,
args.square_size_cm,
args.marker_size_cm,
aruco_dict
)
left_files = sorted(left_dir.glob("*.png"))
right_map = {p.name: p for p in right_dir.glob("*.png")}
valid_pairs = []
rejected = []
print(f"[INFO] dataset_dir={dataset_dir}")
print(f"[INFO] out_dir={out_dir}")
print(f"[INFO] left files={len(left_files)}")
print(f"[INFO] aruco_dict={args.aruco_dict}")
print(f"[INFO] min_markers={args.min_markers}")
print(f"[INFO] clahe={not args.no_clahe}")
for left_path in left_files:
right_path = right_map.get(left_path.name)
if right_path is None:
rejected.append((left_path, None, "missing_right", 0, 0))
continue
left_markers, left_charuco, left_img = count_charuco(
left_path,
aruco_dict,
board,
use_clahe=not args.no_clahe
)
right_markers, right_charuco, right_img = count_charuco(
right_path,
aruco_dict,
board,
use_clahe=not args.no_clahe
)
ok = (
left_markers >= args.min_markers and
right_markers >= args.min_markers and
left_charuco >= args.min_charuco and
right_charuco >= args.min_charuco
)
if ok:
valid_pairs.append((left_path, right_path, left_charuco, right_charuco))
print(
f"[OK] {left_path.name}: "
f"left markers={left_markers} charuco={left_charuco} | "
f"right markers={right_markers} charuco={right_charuco}"
)
else:
rejected.append((left_path, right_path, "low_charuco", left_charuco, right_charuco))
print(
f"[REJECT] {left_path.name}: "
f"left markers={left_markers} charuco={left_charuco} | "
f"right markers={right_markers} charuco={right_charuco}"
)
rej_left_dir = rejected_dir / "left"
rej_right_dir = rejected_dir / "right"
rej_left_dir.mkdir(parents=True, exist_ok=True)
rej_right_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(left_path, rej_left_dir / left_path.name)
shutil.copy2(right_path, rej_right_dir / right_path.name)
if args.save_debug and left_img is not None and right_img is not None:
left_bgr = cv2.cvtColor(left_img, cv2.COLOR_GRAY2BGR)
right_bgr = cv2.cvtColor(right_img, cv2.COLOR_GRAY2BGR)
cv2.putText(left_bgr, f"left markers={left_markers}", (20, 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
cv2.putText(right_bgr, f"right markers={right_markers}", (20, 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
dbg = np.hstack([left_bgr, right_bgr])
cv2.imwrite(str(debug_dir / left_path.name), dbg)
valid_pairs = sorted(
valid_pairs,
key=lambda x: min(x[2], x[3]),
reverse=True
)
if args.max_pairs > 0:
valid_pairs = valid_pairs[:args.max_pairs]
print("")
print(f"[INFO] pares válidos: {len(valid_pairs)}")
print(f"[INFO] pares rejeitados: {len(rejected)}")
if len(valid_pairs) < 10:
print("[WARN] Poucos pares válidos. Talvez o dicionário ArUco esteja errado ou as imagens estejam ruins.")
# Reindexa os válidos no padrão DepthAI: p0_0, p0_1, p0_2, p1_3...
for new_idx, (left_path, right_path, left_markers, right_markers) in enumerate(valid_pairs):
pose_idx = new_idx // args.images_per_pose
new_name = f"p{pose_idx}_{new_idx}.png"
dst_left = out_left / new_name
dst_right = out_right / new_name
if dst_left.exists() or dst_right.exists():
if not args.overwrite:
raise RuntimeError(f"Arquivo já existe: {new_name}. Use --overwrite.")
dst_left.unlink(missing_ok=True)
dst_right.unlink(missing_ok=True)
if args.copy or args.out_dir:
shutil.copy2(left_path, dst_left)
shutil.copy2(right_path, dst_right)
else:
shutil.copy2(left_path, dst_left)
shutil.copy2(right_path, dst_right)
print("")
print("[DONE] Dataset filtrado/reindexado.")
print(f" left : {out_left}")
print(f" right: {out_right}")
print(f" rejected: {rejected_dir}")
if __name__ == "__main__":
main()

View File

@ -58,41 +58,184 @@
}
},
"homography_calibration_size": [1280, 800],
"homographies": {
"re_to_rgb": [
[
1.0103099557765387,
0.00879456142897448,
-9.666692994320178
],
[
-0.00014434784827104316,
1.0214419841673288,
50.41247184630176
],
[
6.270900951674823e-06,
1.788434277122896e-05,
1.0
]
],
"nir_to_rgb": [
[
0.994769714806237,
0.005296878287321148,
-6.433772056422987
],
[
-0.00092900679814922,
1.0017494877636166,
29.883379280823718
],
[
-5.35529902496836e-08,
7.331793189295934e-06,
1.0
]
]
"homography_profile": "baixa",
"homography_profile_by_role": {
"re": "baixa",
"nir": "baixa"
},
"homography_profiles": {
"baixa": {
"description": "Plano mais baixo/distante da câmera, normalmente mais próximo do chão.",
"depth": 120.0,
"homography_calibration_size": [1280, 800],
"homography_source": "all_valid_triplets",
"homography_stats": {
"re_total_points": 267,
"re_inliers": 267,
"re_inlier_pct": 100.0,
"nir_total_points": 282,
"nir_inliers": 276,
"nir_inlier_pct": 97.87234042553192,
"re_frames_used": 13,
"nir_frames_used": 13,
"common_frames_used": 13,
"overlap_common_pct": 92.09267578125
},
"homographies": {
"re_to_rgb": [
[
1.014814963583485,
0.015257560646391,
-27.5415127674596
],
[
0.000064618247568,
1.020756196943496,
51.80289590472656
],
[
0.000003411885963,
0.0000210631067,
1.0
]
],
"nir_to_rgb": [
[
0.993261705358079,
0.008537634512529,
-19.84130896890106
],
[
-0.004568701240477,
1.002385627262319,
37.83216994660247
],
[
-0.00000725852956,
0.000011588890195,
1.0
]
]
}
},
"media": {
"description": "Plano médio, calibrado com ChArUco a aproximadamente 66 cm da lente.",
"depth": 66.0,
"homography_calibration_size": [1280, 800],
"homography_source": "all_valid_triplets",
"homography_stats": {
"min_common_frame": 4,
"min_total_points": 30,
"re_total_points": 681,
"re_inliers": 666,
"re_inlier_pct": 97.79735682819384,
"nir_total_points": 682,
"nir_inliers": 662,
"nir_inlier_pct": 97.0674486803519,
"re_frames_used": 10,
"nir_frames_used": 10,
"common_frames_used": 10,
"overlap_re_pct": 92.06279296874999,
"overlap_nir_pct": 92.97744140625001,
"overlap_common_pct": 90.9361328125
},
"homographies": {
"re_to_rgb": [
[
1.0178493693104127,
0.01417807024780309,
-21.68597109171115
],
[
0.0010063750504059967,
1.0231248578982763,
57.68510361307461
],
[
0.000003886299643192958,
0.0000197941570901694,
1.0
]
],
"nir_to_rgb": [
[
0.9967828768745967,
0.007204312783804796,
-27.956111536702632
],
[
-0.0033537915240536544,
1.005150033976991,
43.58722588915352
],
[
-0.000005956433574512551,
0.000010346309041666395,
1.0
]
]
}
},
"alta": {
"description": "Plano mais alto/próximo da câmera, calibrado com ChArUco acima do plano médio.",
"depth": 36.0,
"homography_calibration_size": [1280, 800],
"homography_source": "all_valid_triplets",
"homography_stats": {
"min_common_frame": 4,
"min_total_points": 30,
"re_total_points": 336,
"re_inliers": 305,
"re_inlier_pct": 90.77380952380952,
"nir_total_points": 333,
"nir_inliers": 330,
"nir_inlier_pct": 99.09909909909909,
"re_frames_used": 5,
"nir_frames_used": 5,
"common_frames_used": 5,
"overlap_re_pct": 91.26904296875,
"overlap_nir_pct": 90.83525390625,
"overlap_common_pct": 88.8357421875
},
"homographies": {
"re_to_rgb": [
[
1.022058360214937,
0.013411355134898,
-11.893143823943664
],
[
0.001034227529954,
1.026408605195186,
69.3538160647828
],
[
0.000003600847244,
0.000019493014216,
1.0
]
],
"nir_to_rgb": [
[
1.001478266241777,
0.007623070623269,
-43.01507447243508
],
[
-0.002877662918552,
1.008411842924782,
55.15260407195015
],
[
-0.000005444722264,
0.000009647719914,
1.0
]
]
}
}
},
"crop_valid_common": true,
"resize_after_crop": true,

View File

@ -1,7 +1,7 @@
{
"camera": "oak-fcc-3",
"modelo": "segformer_b1",
"model_name": "target_fixed",
"model_name": "copypaste",
"main_class_name": "cana",
"es_classes": "",
"model_to_use": "geral",
@ -27,7 +27,7 @@
"mask_dir": "masks",
"classes": {"chao": 0, "cana": 1, "erva": 2},
"ignore_index": 255,
"loss_weight": 0.10
"loss_weight": 0.20
},
"vegetation": {
"enabled": true,
@ -54,7 +54,7 @@
"mask_dir": "__derived_target__",
"classes": {"background": 0, "target": 1},
"ignore_index": 255,
"loss_weight": 0.45,
"loss_weight": 0.35,
"derived": true
}
},

View File

@ -1090,17 +1090,7 @@ class RawProcessorCore:
warped_mask = self._affine_image(mask, dx, dy, theta_deg)
elif mode == "homography":
H = cfg.get("homographies", {}).get(f"{role}_to_rgb")
if H is None:
raise RuntimeError(
f"fusion_config.alignment_mode='homography', "
f"mas homografia '{role}_to_rgb' está ausente. "
f"Isso deixaria o canal {role.upper()} sem alinhamento."
)
calib_size = cfg.get("homography_calibration_size", None)
H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
H = self._scale_homography_to_runtime(
H,
calib_size=calib_size,
@ -3611,22 +3601,30 @@ class RawProcessorCore:
def _direct_fusion_get_role_homography_fast(self, role, meta, ref_size):
"""
Retorna H_role_to_rgb escalada para o espaço da referência RGB.
Suporta:
- contrato antigo: fusion_config.homographies.re_to_rgb/nir_to_rgb
- contrato novo: fusion_config.homography_profiles.<perfil>.homographies.*
"""
role = str(role).lower()
fusion = getattr(self, "fusion_config", {}) or {}
homographies = fusion.get("homographies", {}) or {}
key = f"{role}_to_rgb"
H = homographies.get(key)
H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
if H is None:
# Fallbacks para contratos diferentes.
H = homographies.get(role)
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
if H is None:
raise RuntimeError(f"Homografia ausente para role={role}. Esperado fusion_config.homographies.{key}")
H_scaled = self._scale_homography_to_runtime(
H,
calib_size=calib_size,
runtime_size=(ref_w, ref_h),
)
return self._direct_fusion_scale_homography_for_ref_fast(H, meta, ref_size)
if H_scaled is None or H_scaled.shape != (3, 3):
raise RuntimeError(
f"Homografia inválida para role={role}, profile={profile_name}: "
f"shape={None if H_scaled is None else H_scaled.shape}"
)
return H_scaled.astype(np.float32)
def _direct_fusion_resize_spec_to_ref_if_needed_fast(self, img, ref_size):
"""
@ -3770,6 +3768,7 @@ class RawProcessorCore:
"geometry_cache_hit": bool(geom.get("prepare_cache_hit", False)),
"geometry_cache_hits": int(geom.get("cache_hits", 0)),
"geometry_cache_misses": int(geom.get("cache_misses", 0)),
"homography_profiles_used": geom.get("homography_profiles_used", {}),
}
tensor = np.empty((int(channels_expected), target_h, target_w), dtype=np.float32)
@ -3962,34 +3961,38 @@ class RawProcessorCore:
"""
Chave simples e estável para cache da geometria.
A geometria depende de:
- tamanho do RGB de referência
Considera:
- tamanho do RGB/ref
- target final
- roles presentes
- crop_valid_common / resize_after_crop
- homografias e calibration_size
Para evitar custo de serializar o JSON todo por frame, usamos uma versão
simples. Se você editar module_params em runtime, chame
clear_direct_fusion_geometry_cache().
- crop/resize
- homografia efetivamente selecionada por perfil
- calibration_size efetivo por role
"""
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
target_w, target_h = int(target_size[0]), int(target_size[1])
fusion = getattr(self, "fusion_config", {}) or {}
homographies = fusion.get("homographies", {}) or {}
# Pequena assinatura numérica das homografias.
def h_sig(key):
H = homographies.get(key)
if H is None:
return None
arr = np.asarray(H, dtype=np.float32).reshape(-1)
# arredonda para evitar ruído float/json, mas detecta mudança real.
return tuple(np.round(arr, 8).tolist())
roles = tuple(sorted([str(r).lower() for r in role_to_cam.keys()]))
def h_sig_for_role(role):
role = str(role).lower()
if role not in role_to_cam:
return None
try:
H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
except Exception:
return None
arr = np.asarray(H, dtype=np.float32).reshape(-1)
return (
str(profile_name),
tuple(calib_size or []),
tuple(np.round(arr, 8).tolist()),
)
return (
ref_w,
ref_h,
@ -3998,9 +4001,8 @@ class RawProcessorCore:
roles,
bool(fusion.get("crop_valid_common", False)),
bool(fusion.get("resize_after_crop", False)),
tuple(fusion.get("homography_calibration_size") or fusion.get("calibration_size") or []),
h_sig("re_to_rgb"),
h_sig("nir_to_rgb"),
h_sig_for_role("re"),
h_sig_for_role("nir"),
)
def clear_direct_fusion_geometry_cache(self):
@ -4053,8 +4055,14 @@ class RawProcessorCore:
# Homografias escaladas para runtime.
# ------------------------------------------------------------
H_role_to_rgb = {}
homography_profiles_used = {}
for role in ("re", "nir"):
if role in role_to_cam:
H_raw, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
homography_profiles_used[role] = {
"profile": profile_name,
"calib_size": list(calib_size) if calib_size is not None else None,
}
H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast(role, meta, ref_size)
# ------------------------------------------------------------
@ -4103,6 +4111,7 @@ class RawProcessorCore:
"prepare_cache_hit": False,
"cache_hits": int(self._direct_fusion_geometry_cache_hits),
"cache_misses": int(self._direct_fusion_geometry_cache_misses),
"homography_profiles_used": homography_profiles_used,
}
# Cache pequeno: normalmente só uma geometria. Se mudar resolução/config,
@ -4376,6 +4385,120 @@ class RawProcessorCore:
tensor[int(channel_index)] = out
def _resolve_homography_profile_name_for_role(self, role: str) -> str:
"""
Resolve qual perfil de homografia usar para uma role.
Prioridade:
1) fusion_config.homography_profile_by_role[role]
2) fusion_config.homography_profile
3) "default"
"""
role = str(role).lower()
fusion = getattr(self, "fusion_config", {}) or {}
by_role = fusion.get("homography_profile_by_role", {}) or {}
if isinstance(by_role, dict):
selected = by_role.get(role)
if selected:
return str(selected).lower()
selected = fusion.get("homography_profile", None)
if selected:
return str(selected).lower()
return "default"
def _resolve_homography_entry_for_role(self, role: str):
"""
Resolve a homografia no contrato novo ou antigo.
Contrato novo:
fusion_config.homography_profiles.<perfil>.homographies.<role>_to_rgb
Contrato antigo:
fusion_config.homographies.<role>_to_rgb
Retorna:
H, calib_size, profile_name
"""
role = str(role).lower()
fusion = getattr(self, "fusion_config", {}) or {}
key = f"{role}_to_rgb"
selected_profile = self._resolve_homography_profile_name_for_role(role)
# ------------------------------------------------------------
# Futuro: auto por profundidade.
# Por enquanto, cai em media/default de forma explícita.
# ------------------------------------------------------------
if selected_profile == "auto":
profiles = fusion.get("homography_profiles", {}) or {}
if "media" in profiles:
selected_profile = "media"
elif "default" in profiles:
selected_profile = "default"
else:
selected_profile = ""
# ------------------------------------------------------------
# Contrato novo: homography_profiles
# ------------------------------------------------------------
profiles = fusion.get("homography_profiles", {}) or {}
if isinstance(profiles, dict) and selected_profile:
profile = profiles.get(selected_profile)
if profile is None:
# tolera nomes com caixa diferente
for name, item in profiles.items():
if str(name).lower() == selected_profile:
profile = item
selected_profile = str(name)
break
if isinstance(profile, dict):
profile_homographies = profile.get("homographies", {}) or {}
H = profile_homographies.get(key)
if H is None:
# fallback curto: "re" ou "nir"
H = profile_homographies.get(role)
if H is not None:
calib_size = (
profile.get("homography_calibration_size")
or profile.get("calibration_size")
or fusion.get("homography_calibration_size")
or fusion.get("calibration_size")
or None
)
return H, calib_size, selected_profile
# ------------------------------------------------------------
# Contrato antigo: homographies direto
# ------------------------------------------------------------
homographies = fusion.get("homographies", {}) or {}
H = homographies.get(key)
if H is None:
H = homographies.get(role)
if H is not None:
calib_size = (
fusion.get("homography_calibration_size")
or fusion.get("calibration_size")
or None
)
return H, calib_size, "legacy"
raise RuntimeError(
f"Homografia ausente para role={role}. "
f"Procurei profile='{selected_profile}' em "
f"fusion_config.homography_profiles.*.homographies.{key} "
f"e fallback fusion_config.homographies.{key}"
)
def _raw10_rgb_linear_demosaic_to_rgb_float01_fast(
@ -4806,3 +4929,6 @@ class RawProcessorCore:
self._flatfield_runtime_cache[key] = gain_tensor
return gain_tensor

View File

@ -85,11 +85,10 @@ def raw10_bin_to_gray(path: Path, width: int, height: int, *, is_rgb: bool, baye
raw = path.read_bytes()
raw10 = unpack_raw10_packed(raw, width, height)
if is_rgb:
bgr = debayer_raw10_to_bgr_u8(raw10, bayer=bayer)
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
else:
gray = normalize_to_u8(raw10)
# Para detecção ChArUco, usar o RAW Bayer como intensidade costuma ser mais fiel
# que debayerizar, porque o debayer pode suavizar os IDs ArUco.
# O parâmetro is_rgb fica mantido por compatibilidade com chamadas antigas.
gray = normalize_to_u8(raw10)
if use_clahe:
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
@ -97,33 +96,52 @@ def raw10_bin_to_gray(path: Path, width: int, height: int, *, is_rgb: bool, baye
return gray
# ============================================================
# Metadata
# ============================================================
def find_meta(root_dir: Path) -> Path | None:
candidates = list(root_dir.rglob("meta.json")) + list(root_dir.rglob("metadata.json"))
candidates = (
list(root_dir.rglob("meta.json")) +
list(root_dir.rglob("metadata.json")) +
sorted(root_dir.rglob("*.json"))
)
return candidates[0] if candidates else None
def extract_camera_info_from_meta(meta: dict, cam_key: str):
# Caminho usado pelo meta atual do oak_fcc3.
stream_meta = meta.get("stream_meta")
if isinstance(stream_meta, dict):
camera_info = stream_meta.get("camera_info")
if isinstance(camera_info, dict):
info = camera_info.get(cam_key)
if isinstance(info, dict) and "width" in info and "height" in info:
return info
# Formatos alternativos.
for root_key in ["camera_info", "cameras", "camera_meta", "payload_sources_info"]:
root = meta.get(root_key)
if isinstance(root, dict):
info = root.get(cam_key)
if isinstance(info, dict):
if isinstance(info, dict) and "width" in info and "height" in info:
return info
# Busca recursiva, mas só aceita se parecer info geométrica da câmera.
stack = [meta]
while stack:
obj = stack.pop()
if isinstance(obj, dict):
if cam_key in obj and isinstance(obj[cam_key], dict):
return obj[cam_key]
info = obj[cam_key]
if "width" in info and "height" in info:
return info
for v in obj.values():
if isinstance(v, (dict, list)):
stack.append(v)
elif isinstance(obj, list):
for v in obj:
if isinstance(v, (dict, list)):
@ -155,11 +173,11 @@ def try_get_width_height_from_meta(root_dir: Path, cam_key: str):
return int(width), int(height)
def resolve_width_height(args, cam_key: str):
def resolve_width_height(args, cam_key: str, search_root: Path):
if args.width > 0 and args.height > 0:
return args.width, args.height
w, h = try_get_width_height_from_meta(Path(args.root_dir), cam_key)
w, h = try_get_width_height_from_meta(search_root, cam_key)
if w and h:
return w, h
@ -268,6 +286,26 @@ def resolve_homography_triplet(triplets: list[dict], homo_ref_frame: str | None,
raise RuntimeError(f"Não encontrei triplet correspondente a --homo_ref_frame={homo_ref_frame}")
def find_triplets_multi(root_dirs: list[Path], cams: list[str]):
all_triplets = []
all_by_cam = {cam: [] for cam in cams}
for root in root_dirs:
triplets, by_cam = find_triplets(root, cams)
for cam in cams:
all_by_cam[cam].extend(by_cam[cam])
for item in triplets:
item = dict(item)
item["__root_dir"] = root
all_triplets.append(item)
print(f"[INFO] root_dir={root} triplets={len(triplets)}")
return all_triplets, all_by_cam
# ============================================================
# ChArUco helpers
# ============================================================
@ -319,11 +357,34 @@ def get_board_corners(board):
def create_detector_params():
aruco = cv2.aruco
if hasattr(aruco, "DetectorParameters"):
return aruco.DetectorParameters()
if hasattr(aruco, "DetectorParameters_create"):
return aruco.DetectorParameters_create()
return None
params = aruco.DetectorParameters()
elif hasattr(aruco, "DetectorParameters_create"):
params = aruco.DetectorParameters_create()
else:
return None
params.adaptiveThreshWinSizeMin = 3
params.adaptiveThreshWinSizeMax = 53
params.adaptiveThreshWinSizeStep = 4
params.minMarkerPerimeterRate = 0.01
params.maxMarkerPerimeterRate = 4.0
params.polygonalApproxAccuracyRate = 0.05
params.minCornerDistanceRate = 0.02
params.minDistanceToBorder = 1
try:
params.cornerRefinementMethod = aruco.CORNER_REFINE_SUBPIX
params.cornerRefinementWinSize = 5
params.cornerRefinementMaxIterations = 50
params.cornerRefinementMinAccuracy = 0.01
except Exception:
pass
return params
def detect_charuco(gray: np.ndarray, board, aruco_dict, min_corners: int):
@ -636,13 +697,261 @@ def compute_planar_homography_from_triplet(
}
def detect_charuco_best(gray: np.ndarray, board, aruco_dict, min_corners: int, cam: str = ""):
"""
Tenta múltiplos pré-processamentos e escalas.
Retorna a melhor detecção mesmo quando ela fica abaixo de min_corners.
"""
variants = []
base = gray.copy()
variants.append(("raw", base))
clahe2 = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
clahe4 = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8, 8))
variants.append(("clahe_2", clahe2.apply(base)))
variants.append(("clahe_4", clahe4.apply(base)))
blur = cv2.GaussianBlur(base, (3, 3), 0)
variants.append(("blur_clahe_2", clahe2.apply(blur)))
th = cv2.adaptiveThreshold(
base,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
31,
5,
)
variants.append(("adaptive", th))
variants.append(("invert_raw", 255 - base))
variants.append(("invert_clahe_2", 255 - clahe2.apply(base)))
best = {
"name": None,
"corners": None,
"ids": None,
"count": 0,
"accepted": False,
}
for name, img in variants:
for scale in [1.0, 2.0, 3.0]:
if scale == 1.0:
test_img = img
else:
test_img = cv2.resize(
img,
None,
fx=scale,
fy=scale,
interpolation=cv2.INTER_CUBIC,
)
corners, ids = detect_charuco(test_img, board, aruco_dict, min_corners=1)
count = 0 if ids is None else len(ids)
if count > best["count"]:
if corners is not None and scale != 1.0:
corners = corners / scale
best.update({
"name": f"{name}_x{scale:g}" if scale != 1.0 else name,
"corners": corners,
"ids": ids,
"count": count,
"accepted": count >= min_corners,
})
return best["corners"], best["ids"], best["name"], best["count"], best["accepted"]
def _make_overlap_masks_from_homographies(H_re, H_nir, image_size: tuple[int, int]):
image_w, image_h = image_size
ones = np.ones((image_h, image_w), dtype=np.uint8) * 255
overlap_re_to_rgb = cv2.warpPerspective(
ones,
H_re,
(image_w, image_h),
flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT,
borderValue=0,
)
overlap_nir_to_rgb = cv2.warpPerspective(
ones,
H_nir,
(image_w, image_h),
flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT,
borderValue=0,
)
overlap_common_rgb = cv2.bitwise_and(overlap_re_to_rgb, overlap_nir_to_rgb)
return overlap_re_to_rgb, overlap_nir_to_rgb, overlap_common_rgb
def compute_planar_homography_from_collected_pairs(
homography_pairs: dict,
image_size: tuple[int, int],
args,
):
"""
Calcula H_RE_to_RGB e H_NIR_to_RGB usando TODOS os pares válidos acumulados
durante a varredura do dataset.
Premissa: todos os frames usados representam o mesmo plano físico.
"""
re_items = homography_pairs.get("RE_to_RGB", [])
nir_items = homography_pairs.get("NIR_to_RGB", [])
if len(re_items) == 0:
raise RuntimeError("Nenhum par válido acumulado para homografia RE -> RGB.")
if len(nir_items) == 0:
raise RuntimeError("Nenhum par válido acumulado para homografia NIR -> RGB.")
def stack_points(items, label):
src = np.vstack([x["pts_src"] for x in items]).astype(np.float32)
dst = np.vstack([x["pts_dst"] for x in items]).astype(np.float32)
min_total = max(4, int(args.homo_min_total_points))
if len(src) < min_total:
raise RuntimeError(
f"Poucos pontos totais para homografia {label}: {len(src)}. "
f"Mínimo configurado={min_total}."
)
return src, dst
pts_re_src, pts_re_dst = stack_points(re_items, "RE_to_RGB")
pts_nir_src, pts_nir_dst = stack_points(nir_items, "NIR_to_RGB")
H_re, mask_re = cv2.findHomography(
pts_re_src,
pts_re_dst,
cv2.RANSAC,
args.homo_ransac_thresh,
)
H_nir, mask_nir = cv2.findHomography(
pts_nir_src,
pts_nir_dst,
cv2.RANSAC,
args.homo_ransac_thresh,
)
if H_re is None:
raise RuntimeError("cv2.findHomography falhou para RE -> RGB usando todos os frames válidos.")
if H_nir is None:
raise RuntimeError("cv2.findHomography falhou para NIR -> RGB usando todos os frames válidos.")
re_inliers = int(np.count_nonzero(mask_re)) if mask_re is not None else 0
nir_inliers = int(np.count_nonzero(mask_nir)) if mask_nir is not None else 0
overlap_re_to_rgb, overlap_nir_to_rgb, overlap_common_rgb = _make_overlap_masks_from_homographies(
H_re,
H_nir,
image_size,
)
re_frame_indices = sorted({int(x["triplet_idx"]) for x in re_items})
nir_frame_indices = sorted({int(x["triplet_idx"]) for x in nir_items})
common_frame_indices = sorted(set(re_frame_indices) & set(nir_frame_indices))
stats = {
"mode": "all_valid_triplets",
"min_common_frame": int(args.homo_min_common_frame),
"min_total_points": int(args.homo_min_total_points),
"re_total_points": int(len(pts_re_src)),
"nir_total_points": int(len(pts_nir_src)),
"re_inliers": re_inliers,
"nir_inliers": nir_inliers,
"re_inlier_pct": float(re_inliers / max(1, len(pts_re_src)) * 100.0),
"nir_inlier_pct": float(nir_inliers / max(1, len(pts_nir_src)) * 100.0),
"re_frames_used": int(len(re_frame_indices)),
"nir_frames_used": int(len(nir_frame_indices)),
"common_frames_used": int(len(common_frame_indices)),
"re_pair_records": int(len(re_items)),
"nir_pair_records": int(len(nir_items)),
"overlap_re_pct": float(np.mean(overlap_re_to_rgb > 0) * 100.0),
"overlap_nir_pct": float(np.mean(overlap_nir_to_rgb > 0) * 100.0),
"overlap_common_pct": float(np.mean(overlap_common_rgb > 0) * 100.0),
}
common_ids_re = np.concatenate([x["common_ids"] for x in re_items]).astype(np.int32)
common_ids_nir = np.concatenate([x["common_ids"] for x in nir_items]).astype(np.int32)
return {
"H_RE_to_RGB": H_re,
"H_NIR_to_RGB": H_nir,
"mask_RE_to_RGB": mask_re,
"mask_NIR_to_RGB": mask_nir,
"common_ids_RE_to_RGB": common_ids_re,
"common_ids_NIR_to_RGB": common_ids_nir,
"overlap_RE_to_RGB": overlap_re_to_rgb,
"overlap_NIR_to_RGB": overlap_nir_to_rgb,
"overlap_common_RGB": overlap_common_rgb,
"stats": stats,
"frame_indices_RE_to_RGB": np.array(re_frame_indices, dtype=np.int32),
"frame_indices_NIR_to_RGB": np.array(nir_frame_indices, dtype=np.int32),
"frame_indices_common": np.array(common_frame_indices, dtype=np.int32),
}
def add_homography_to_save_dict(save_dict: dict, homo_result: dict, args):
hs = homo_result["stats"]
save_dict["has_planar_homography"] = True
save_dict["planar_homography_source"] = "all_valid_triplets"
save_dict["planar_homography_resolved"] = "all_valid_triplets"
save_dict["planar_homography_note"] = (
"Homography maps RE/NIR to RGB using all valid ChArUco detections "
"from the same physical plane."
)
save_dict["H_RE_to_RGB"] = homo_result["H_RE_to_RGB"]
save_dict["H_NIR_to_RGB"] = homo_result["H_NIR_to_RGB"]
save_dict[f"H_{args.re_cam}_to_{args.rgb_cam}"] = homo_result["H_RE_to_RGB"]
save_dict[f"H_{args.nir_cam}_to_{args.rgb_cam}"] = homo_result["H_NIR_to_RGB"]
save_dict["homography_mask_RE_to_RGB"] = homo_result["mask_RE_to_RGB"]
save_dict["homography_mask_NIR_to_RGB"] = homo_result["mask_NIR_to_RGB"]
save_dict["homography_common_ids_RE_to_RGB"] = homo_result["common_ids_RE_to_RGB"]
save_dict["homography_common_ids_NIR_to_RGB"] = homo_result["common_ids_NIR_to_RGB"]
save_dict["homography_frame_indices_RE_to_RGB"] = homo_result["frame_indices_RE_to_RGB"]
save_dict["homography_frame_indices_NIR_to_RGB"] = homo_result["frame_indices_NIR_to_RGB"]
save_dict["homography_frame_indices_common"] = homo_result["frame_indices_common"]
save_dict["overlap_RE_to_RGB"] = homo_result["overlap_RE_to_RGB"]
save_dict["overlap_NIR_to_RGB"] = homo_result["overlap_NIR_to_RGB"]
save_dict["overlap_common_RGB"] = homo_result["overlap_common_RGB"]
for k, v in hs.items():
save_dict[f"planar_homography_{k}"] = v
return save_dict
# ============================================================
# Main
# ============================================================
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root_dir", default="calibration/stereo_dataset", required=True)
parser.add_argument("--root_dir", default=None)
parser.add_argument(
"--root_dirs",
nargs="+",
default=None,
help="Lista de diretórios de calibração para juntar no mesmo cálculo stereo. Ex: baixa media alta",
)
parser.add_argument("--out_dir", default="calibration/multicam_charuco_calib_out")
parser.add_argument("--rgb_cam", default="CAM_A")
@ -650,8 +959,8 @@ def main():
parser.add_argument("--nir_cam", default="CAM_C")
parser.add_argument("--ref_cam", default="CAM_A", help="Referência global. Recomendo CAM_A/RGB.")
parser.add_argument("--width", type=int, default=1280)
parser.add_argument("--height", type=int, default=800)
parser.add_argument("--width", type=int, default=0)
parser.add_argument("--height", type=int, default=0)
parser.add_argument("--rgb_bayer", default="BGGR")
parser.add_argument("--squares_x", type=int, default=13)
@ -664,26 +973,76 @@ def main():
parser.add_argument("--min_corners", type=int, default=40)
parser.add_argument("--min_common", type=int, default=40)
# Homografia planar de referência.
# Homografia planar usando todos os frames válidos do mesmo plano físico.
parser.add_argument(
"--homo_ref_frame",
default=None,
"--homography_mode",
default="all_valid",
choices=["all_valid", "off"],
help=(
"Triplet usado como plano de referência para H_RE_to_RGB e H_NIR_to_RGB. "
"Aceita índice, nome parcial/stem ou caminho de um .bin do triplet. "
"Se omitido, não salva homografias planares."
"Modo de homografia planar. 'all_valid' acumula todos os pares válidos "
"RE->RGB e NIR->RGB encontrados no dataset. 'off' desativa."
),
)
# Homografia all_valid:
# - Não corta detecções fracas por câmera antes de acumular pontos.
# - Cada frame/par contribui se tiver pelo menos homo_min_common_frame IDs comuns.
# - O corte forte acontece no acumulado total, em homo_min_total_points.
parser.add_argument(
"--homo_min_common_frame",
type=int,
default=4,
help=(
"Mínimo de IDs comuns por frame/par para adicionar pontos à homografia. "
"Use 4 como mínimo matemático; 5-8 para ficar menos permissivo."
),
)
parser.add_argument(
"--homo_min_total_points",
type=int,
default=30,
help=(
"Mínimo de pontos acumulados no dataset inteiro para calcular cada homografia. "
"Ex: 30 para teste, 50-100 para calibração mais robusta."
),
)
parser.add_argument("--homo_min_corners", type=int, default=30)
parser.add_argument("--homo_min_common", type=int, default=20)
parser.add_argument("--homo_ransac_thresh", type=float, default=3.0)
# Compatibilidade com comandos antigos. Não são mais usados como corte da homografia all_valid.
parser.add_argument("--homo_min_corners", type=int, default=None, help=argparse.SUPPRESS)
parser.add_argument("--homo_min_common", type=int, default=None, help=argparse.SUPPRESS)
parser.add_argument(
"--min_calib_triplets",
type=int,
default=5,
help="Mínimo de triplets aceitos para executar calibração intrínseca/stereo.",
)
parser.add_argument("--no_clahe", action="store_true")
parser.add_argument("--show", action="store_true")
args = parser.parse_args()
root_dir = Path(args.root_dir)
if args.root_dirs:
root_dirs = [Path(p) for p in args.root_dirs]
elif args.root_dir:
root_dirs = [Path(args.root_dir)]
else:
raise RuntimeError("Informe --root_dir ou --root_dirs.")
# Compatibilidade com comandos antigos:
# --homo_min_common antigo vira o novo corte mínimo por frame/par.
# --homo_min_corners antigo não é mais usado como corte para homografia all_valid,
# porque agora aceitamos detecções pequenas e filtramos pelo total acumulado.
if args.homo_min_common is not None:
args.homo_min_common_frame = int(args.homo_min_common)
if getattr(args, "root_dirs", None):
root_dirs = [Path(p) for p in args.root_dirs]
elif getattr(args, "root_dir", None):
root_dirs = [Path(args.root_dir)]
else:
raise RuntimeError("Informe --root_dir ou --root_dirs.")
out_dir = Path(args.out_dir)
debug_dir = out_dir / "debug"
out_dir.mkdir(parents=True, exist_ok=True)
@ -701,14 +1060,14 @@ def main():
sizes = {}
for cam in cams:
w, h = resolve_width_height(args, cam)
w, h = resolve_width_height(args, cam, root_dirs[0])
sizes[cam] = (w, h)
image_w = min(w for w, h in sizes.values())
image_h = min(h for w, h in sizes.values())
image_size = (image_w, image_h)
print(f"[INFO] root_dir={root_dir}")
print(f"[INFO] root_dir={root_dirs}")
print(f"[INFO] cams={cams} ref_cam={args.ref_cam}")
for cam in cams:
print(f"[INFO] {cam} role={cam_roles[cam]} size={sizes[cam]}")
@ -717,13 +1076,18 @@ def main():
print(f"[INFO] square_length={args.square_length}")
print(f"[INFO] marker_length={args.marker_length}")
print(f"[INFO] rectify_alpha={args.rectify_alpha}")
print(f"[INFO] homography_mode={args.homography_mode}")
if args.homography_mode == "all_valid":
print(f"[INFO] homo_min_common_frame={args.homo_min_common_frame}")
print(f"[INFO] homo_min_total_points={args.homo_min_total_points}")
print(f"[INFO] homo_ransac_thresh={args.homo_ransac_thresh}")
triplets, by_cam = find_triplets(root_dir, cams)
triplets, by_cam = find_triplets_multi(root_dirs, cams)
for cam in cams:
print(f"[INFO] arquivos {cam}: {len(by_cam[cam])}")
print(f"[INFO] triplets encontrados: {len(triplets)}")
if len(triplets) < 8:
if len(triplets) < 5:
raise RuntimeError("Poucos triplets encontrados. Verifique nomes dos arquivos CAM_A/B/C.")
aruco_dict = get_aruco_dict(args.aruco_dict)
@ -741,6 +1105,11 @@ def main():
accepted = 0
rejected = 0
homography_pairs = {
"RE_to_RGB": [],
"NIR_to_RGB": [],
}
for idx, item in enumerate(triplets):
print(f"[{idx + 1}/{len(triplets)}] " + " | ".join([f"{cam}={item[cam].name}" for cam in cams]))
@ -764,22 +1133,101 @@ def main():
gray = cv2.resize(gray, image_size, interpolation=cv2.INTER_AREA)
gray_by_cam[cam] = gray
corners, ids = detect_charuco(gray, board, aruco_dict, min_corners=args.min_corners)
corners, ids, det_mode, det_count, det_ok = detect_charuco_best(
gray,
board,
aruco_dict,
min_corners=args.min_corners,
cam=cam,
)
# Mantém a melhor detecção bruta para homografia, mesmo quando
# ela fica abaixo do mínimo mais rígido da calibração stereo.
detections[cam] = (corners, ids)
detections[f"{cam}__count"] = det_count
detections[f"{cam}__mode"] = det_mode
counts = {cam: (0 if detections[cam][1] is None else len(detections[cam][1])) for cam in cams}
print(
f" [DETECT] {cam}: best={det_mode} corners={det_count} "
f"{'OK' if det_ok else f'LOW<{args.min_corners}'}"
)
if any(detections[cam][0] is None for cam in cams):
raw_counts = {cam: (0 if detections[cam][1] is None else len(detections[cam][1])) for cam in cams}
# Homografia all_valid:
# Aqui não usamos corte por câmera tipo "RGB precisa ter 20/40 pontos".
# Se um frame achou poucos pontos, mas tem pelo menos 4 IDs comuns no par,
# esses pontos entram no acumulado. O corte forte é feito depois, no total.
if args.homography_mode == "all_valid":
rgb_corners, rgb_ids = detections[args.rgb_cam]
re_corners, re_ids = detections[args.re_cam]
nir_corners, nir_ids = detections[args.nir_cam]
pts_re, pts_rgb_re, common_re = common_points_pair(
re_corners,
re_ids,
rgb_corners,
rgb_ids,
min_common=args.homo_min_common_frame,
)
if pts_re is not None:
homography_pairs["RE_to_RGB"].append({
"triplet_idx": idx,
"pts_src": pts_re,
"pts_dst": pts_rgb_re,
"common_ids": np.array(common_re, dtype=np.int32),
"src_file": str(item[args.re_cam]),
"dst_file": str(item[args.rgb_cam]),
"src_count": raw_counts[args.re_cam],
"dst_count": raw_counts[args.rgb_cam],
})
print(f" [HOMO ADD] RE->RGB common={len(common_re)} total_records={len(homography_pairs['RE_to_RGB'])}")
else:
print(f" [HOMO SKIP] RE->RGB common={len(common_re)} < {args.homo_min_common_frame}")
pts_nir, pts_rgb_nir, common_nir = common_points_pair(
nir_corners,
nir_ids,
rgb_corners,
rgb_ids,
min_common=args.homo_min_common_frame,
)
if pts_nir is not None:
homography_pairs["NIR_to_RGB"].append({
"triplet_idx": idx,
"pts_src": pts_nir,
"pts_dst": pts_rgb_nir,
"common_ids": np.array(common_nir, dtype=np.int32),
"src_file": str(item[args.nir_cam]),
"dst_file": str(item[args.rgb_cam]),
"src_count": raw_counts[args.nir_cam],
"dst_count": raw_counts[args.rgb_cam],
})
print(f" [HOMO ADD] NIR->RGB common={len(common_nir)} total_records={len(homography_pairs['NIR_to_RGB'])}")
else:
print(f" [HOMO SKIP] NIR->RGB common={len(common_nir)} < {args.homo_min_common_frame}")
detections_calib = {}
for cam in cams:
corners, ids = detections[cam]
if raw_counts[cam] >= args.min_corners:
detections_calib[cam] = (corners, ids)
else:
detections_calib[cam] = (None, None)
counts = {cam: (0 if detections_calib[cam][1] is None else len(detections_calib[cam][1])) for cam in cams}
if any(detections_calib[cam][0] is None for cam in cams):
print(" [REJECT] detect insuficiente: " + ", ".join([f"{cam}={counts[cam]}" for cam in cams]))
dbg = draw_debug_panel(gray_by_cam, detections, 0, False, f"triplet_{idx:04d}")
dbg = draw_debug_panel(gray_by_cam, detections_calib, 0, False, f"triplet_{idx:04d}")
cv2.imwrite(str(debug_dir / f"triplet_{idx:04d}_rejected.png"), dbg)
rejected += 1
continue
obj, imgpoints_by_cam, common_ids = common_points_multi(detections, board_corners, min_common=args.min_common)
obj, imgpoints_by_cam, common_ids = common_points_multi(detections_calib, board_corners, min_common=args.min_common)
if obj is None:
print(f" [REJECT] comuns insuficientes nas 3 cams: common={len(common_ids)}")
dbg = draw_debug_panel(gray_by_cam, detections, len(common_ids), False, f"triplet_{idx:04d}")
dbg = draw_debug_panel(gray_by_cam, detections_calib, len(common_ids), False, f"triplet_{idx:04d}")
cv2.imwrite(str(debug_dir / f"triplet_{idx:04d}_rejected.png"), dbg)
rejected += 1
continue
@ -791,7 +1239,7 @@ def main():
single_imgpoints[cam].append(imgpoints_by_cam[cam].copy())
accepted += 1
dbg = draw_debug_panel(gray_by_cam, detections, len(common_ids), True, f"triplet_{idx:04d}")
dbg = draw_debug_panel(gray_by_cam, detections_calib, len(common_ids), True, f"triplet_{idx:04d}")
cv2.imwrite(str(debug_dir / f"triplet_{idx:04d}_accepted.png"), dbg)
if args.show:
@ -810,11 +1258,79 @@ def main():
cv2.destroyAllWindows()
print("")
print(f"[INFO] triplets aceitos: {accepted}")
print(f"[INFO] triplets rejeitados: {rejected}")
print(f"[INFO] triplets aceitos para calibração stereo: {accepted}")
print(f"[INFO] triplets rejeitados para calibração stereo: {rejected}")
re_acc_points = sum(len(x["pts_src"]) for x in homography_pairs["RE_to_RGB"])
nir_acc_points = sum(len(x["pts_src"]) for x in homography_pairs["NIR_to_RGB"])
print(f"[INFO] pares acumulados homografia RE->RGB: {len(homography_pairs['RE_to_RGB'])} | pontos={re_acc_points}")
print(f"[INFO] pares acumulados homografia NIR->RGB: {len(homography_pairs['NIR_to_RGB'])} | pontos={nir_acc_points}")
if accepted < 8:
raise RuntimeError(f"Poucos triplets aceitos: {accepted}. Ideal: 20-40+ bons.")
homo_result = None
if args.homography_mode == "all_valid":
print("")
print("[HOMO] Calculando homografia planar com TODOS os pares válidos do dataset...")
try:
homo_result = compute_planar_homography_from_collected_pairs(
homography_pairs=homography_pairs,
image_size=image_size,
args=args,
)
hs = homo_result["stats"]
print("[HOMO] Resultado planar all_valid:")
print(f" RE points/inliers={hs['re_total_points']}/{hs['re_inliers']} ({hs['re_inlier_pct']:.1f}%)")
print(f" NIR points/inliers={hs['nir_total_points']}/{hs['nir_inliers']} ({hs['nir_inlier_pct']:.1f}%)")
print(f" frames RE/NIR/common={hs['re_frames_used']}/{hs['nir_frames_used']}/{hs['common_frames_used']}")
print(f" overlap RE={hs['overlap_re_pct']:.1f}% NIR={hs['overlap_nir_pct']:.1f}% common={hs['overlap_common_pct']:.1f}%")
except Exception as e:
print(f"[HOMO][WARN] Não foi possível calcular homografia all_valid: {e}")
homo_result = None
out_path = out_dir / f"multicam_calib_{'_'.join(cams)}_ref_{args.ref_cam}.npz"
if accepted < args.min_calib_triplets:
if homo_result is None:
raise RuntimeError(
f"Poucos triplets aceitos para calibração stereo: {accepted}. "
f"Mínimo configurado={args.min_calib_triplets}. "
f"Também não foi possível salvar homografia."
)
save_dict = {
"schema": "multicam_charuco_raw10_v4_planar_homography_accumulated_only",
"cams": np.array(cams),
"rgb_cam": args.rgb_cam,
"nir_cam": args.nir_cam,
"re_cam": args.re_cam,
"ref_cam": args.ref_cam,
"image_size": np.array(image_size, dtype=np.int32),
"squares_x": args.squares_x,
"squares_y": args.squares_y,
"square_length": args.square_length,
"marker_length": args.marker_length,
"aruco_dict": args.aruco_dict,
"rectify_alpha": args.rectify_alpha,
"accepted": accepted,
"rejected": rejected,
"stereo_calibration_available": False,
"stereo_calibration_note": (
f"Calibração stereo não executada porque accepted={accepted} "
f"< min_calib_triplets={args.min_calib_triplets}."
),
}
for cam in cams:
save_dict[f"role_{cam}"] = cam_roles[cam]
add_homography_to_save_dict(save_dict, homo_result, args)
np.savez_compressed(out_path, **save_dict)
print("")
print(f"[OK] homografia planar salva em: {out_path}")
print("[OK] calibração stereo não foi executada por falta de triplets aceitos.")
print(f"[OK] debug salvo em: {debug_dir}")
return
K = {}
D = {}
@ -901,41 +1417,12 @@ def main():
extr_R_to_ref[cam] = R_cam_to_ref
extr_T_to_ref[cam] = T_cam_to_ref
# Homografia planar opcional.
homo_result = None
homo_triplet = None
homo_ref_resolved = ""
if args.homo_ref_frame:
print("")
print(f"[HOMO] Resolvendo frame de referência planar: {args.homo_ref_frame}")
homo_triplet, homo_ref_resolved = resolve_homography_triplet(triplets, args.homo_ref_frame, args.rgb_cam)
print(f"[HOMO] Usando triplet: {homo_ref_resolved}")
for cam in cams:
print(f" {cam}: {homo_triplet[cam]}")
homo_result = compute_planar_homography_from_triplet(
triplet=homo_triplet,
cams=cams,
cam_roles=cam_roles,
sizes=sizes,
image_size=image_size,
args=args,
board=board,
aruco_dict=aruco_dict,
)
hs = homo_result["stats"]
print("[HOMO] Resultado planar:")
print(f" RGB corners={hs['rgb_corners']} RE corners={hs['re_corners']} NIR corners={hs['nir_corners']}")
print(f" RE common/inliers={hs['re_common']}/{hs['re_inliers']}")
print(f" NIR common/inliers={hs['nir_common']}/{hs['nir_inliers']}")
print(f" overlap RE={hs['overlap_re_pct']:.1f}% NIR={hs['overlap_nir_pct']:.1f}% common={hs['overlap_common_pct']:.1f}%")
# Homografia planar all_valid já foi calculada antes da calibração stereo.
out_path = out_dir / f"multicam_calib_{'_'.join(cams)}_ref_{args.ref_cam}.npz"
save_dict = {
"schema": "multicam_charuco_raw10_v2_planar_homography",
"schema": "multicam_charuco_raw10_v4_planar_homography_accumulated",
"cams": np.array(cams),
"rgb_cam": args.rgb_cam,
"nir_cam": args.nir_cam,
@ -950,6 +1437,10 @@ def main():
"rectify_alpha": args.rectify_alpha,
"accepted": accepted,
"rejected": rejected,
"stereo_calibration_available": True,
"calibration_mode": "stereo_global",
"source_root_dirs": np.array([str(p) for p in root_dirs]),
"source_root_count": len(root_dirs),
}
for cam in cams:
@ -974,31 +1465,7 @@ def main():
save_dict[f"pair_{key}_{rk}"] = rv
if homo_result is not None:
hs = homo_result["stats"]
save_dict["has_planar_homography"] = True
save_dict["planar_homography_source"] = str(args.homo_ref_frame)
save_dict["planar_homography_resolved"] = str(homo_ref_resolved)
save_dict["planar_homography_note"] = "Homography maps RE/NIR to RGB for the physical plane visible in homo_ref_frame."
save_dict["H_RE_to_RGB"] = homo_result["H_RE_to_RGB"]
save_dict["H_NIR_to_RGB"] = homo_result["H_NIR_to_RGB"]
save_dict[f"H_{args.re_cam}_to_{args.rgb_cam}"] = homo_result["H_RE_to_RGB"]
save_dict[f"H_{args.nir_cam}_to_{args.rgb_cam}"] = homo_result["H_NIR_to_RGB"]
save_dict["homography_mask_RE_to_RGB"] = homo_result["mask_RE_to_RGB"]
save_dict["homography_mask_NIR_to_RGB"] = homo_result["mask_NIR_to_RGB"]
save_dict["homography_common_ids_RE_to_RGB"] = homo_result["common_ids_RE_to_RGB"]
save_dict["homography_common_ids_NIR_to_RGB"] = homo_result["common_ids_NIR_to_RGB"]
save_dict["overlap_RE_to_RGB"] = homo_result["overlap_RE_to_RGB"]
save_dict["overlap_NIR_to_RGB"] = homo_result["overlap_NIR_to_RGB"]
save_dict["overlap_common_RGB"] = homo_result["overlap_common_RGB"]
for cam in cams:
save_dict[f"planar_homography_file_{cam}"] = str(homo_triplet[cam])
for k, v in hs.items():
save_dict[f"planar_homography_{k}"] = v
add_homography_to_save_dict(save_dict, homo_result, args)
else:
save_dict["has_planar_homography"] = False

File diff suppressed because it is too large Load Diff