From 1a140ca60f69aacf8c43240a292c00887921ee42 Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Fri, 8 May 2026 13:25:26 -0300 Subject: [PATCH] ajustado global saturation guard e trocado ordem das cameras --- .../oak-fcc-3/calibration/module_params.json | 45 +- .../oak-fcc-3/core/oak_fcc3_manager.py | 42 +- .../oak-fcc-3/core/radiometric_controller.py | 570 +++++++++++++++-- .../utils/radiometric_config_tool.py | 588 +++++++++++------- 4 files changed, 952 insertions(+), 293 deletions(-) diff --git a/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json b/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json index 387dc6f01..eee4f8bb3 100644 --- a/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json +++ b/Python/OAK/datasets/oak-fcc-3/calibration/module_params.json @@ -95,7 +95,7 @@ "interval_s": 0.25, "verbose": true, "metering_mode": "reference_patches", - "spectral_control_mode": "shared", + "spectral_control_mode": "independent", "control_metric": "p50", "target_value": 0.5, "deadband": 0.035, @@ -208,7 +208,7 @@ "target_value_by_role": { "rgb": 0.34, "re": 0.24, - "nir": 0.30 + "nir": 0.3 }, "weight": 1.0, "roi_pct_by_role": { @@ -360,7 +360,7 @@ ], "exp_apply_threshold_us": 80, "gain_apply_threshold": 0.05, - "apply_same_spectral_to_both": true, + "apply_same_spectral_to_both": false, "spectral_roles": [ "re", "nir" @@ -370,7 +370,7 @@ "ratio_alpha": 0.35, "ratio_min": 0.65, "ratio_max": 1.35, - "reduce_fast_factor": 0.80, + "reduce_fast_factor": 0.8, "factor_min": 0.55, "factor_max": 1.28, "gain_return_enabled": true, @@ -415,7 +415,42 @@ "patch_roi_contract": "multi_roi_by_role_v1", "patch_roi_reduce_method": "median_valid_rois", "patch_roi_outlier_reject": true, - "patch_roi_max_p50_delta": 0.12 + "patch_roi_max_p50_delta": 0.12, + "global_saturation_guard_enabled": true, + "global_guard_roi_pct": { + "x0": 0.05, + "y0": 0.05, + "x1": 0.95, + "y1": 0.95 + }, + "global_guard_sat_threshold": 0.985, + "global_guard_near_sat_threshold": 0.94, + "global_guard_sat_pct_soft": 0.05, + "global_guard_sat_pct_hard": 0.2, + "global_guard_sat_pct_extreme": 0.8, + "global_guard_blob_pct_soft": 0.015, + "global_guard_blob_pct_hard": 0.08, + "global_guard_blob_pct_extreme": 0.25, + "global_guard_min_blob_px": 48, + "global_guard_downsample_max_side": 320, + "global_guard_reduce_factor_soft": 0.82, + "global_guard_reduce_factor_hard": 0.6, + "global_guard_reduce_factor_extreme": 0.35, + "sun_guard_enabled": true, + "sun_guard_p99_threshold": 0.9, + "sun_guard_near_sat_pct_threshold": 0.8, + "sun_guard_freeze_increase_cycles": 2, + "sun_guard_allow_decrease": true, + "guard_force_apply_enabled": true, + "guard_force_apply_soft": true, + "guard_force_apply_hard": true, + "guard_force_apply_extreme": true, + "guard_force_apply_on_patch_saturation": true, + "guard_freeze_cycles_soft": 3, + "guard_freeze_cycles_hard": 5, + "guard_freeze_cycles_extreme": 8, + "guard_reapply_min_exp_on_emergency": true, + "guard_min_exp_margin_us": 80 }, "radiometric_normalization": { "enabled": false, diff --git a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py index 008a6ffa6..e04778e8b 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py +++ b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py @@ -32,8 +32,8 @@ class OakFcc3Manager: self.only_camera = only_camera self.roles = roles or { - "CAM_A": "rgb", - "CAM_B": "nir", + "CAM_A": "nir", + "CAM_B": "rgb", "CAM_C": "re", } @@ -51,9 +51,8 @@ class OakFcc3Manager: self.frame_id = 0 self.control_queues = {} self.camera_controls = { - "CAM_A": {"ae_enable": True, "awb_enable": True, "exposure_time_us": 15000, "analogue_gain": 1.0, "colour_gains": [1.0, 1.0]}, - "CAM_B": {"ae_enable": False, "awb_enable": False, "exposure_time_us": 15000, "analogue_gain": 1.0, "colour_gains": None}, - "CAM_C": {"ae_enable": False, "awb_enable": False, "exposure_time_us": 15000, "analogue_gain": 1.0, "colour_gains": None}, + cam_id: self._default_controls_for_role(role) + for cam_id, role in self.roles.items() } self._last_raw_dims = {} @@ -67,6 +66,26 @@ class OakFcc3Manager: def _is_preview_mode(self): return str(self.frame_type).upper() == "PREVIEW" + def _default_controls_for_role(self, role: str): + role = str(role).lower() + + if role == "rgb": + return { + "ae_enable": False, + "awb_enable": False, + "exposure_time_us": 2000, + "analogue_gain": 1.0, + "colour_gains": [1.0, 1.0], + } + + return { + "ae_enable": False, + "awb_enable": False, + "exposure_time_us": 5000, + "analogue_gain": 1.0, + "colour_gains": None, + } + def list_cameras(self): with dai.Device() as dev: result = [] @@ -79,8 +98,17 @@ class OakFcc3Manager: return result def _get_available_cam_ids_ordered(self): - preferred_order = ["CAM_A", "CAM_B", "CAM_C"] # RGB, NIR, RE - return [cam_id for cam_id in preferred_order if cam_id in self.queues] + role_order = ["rgb", "nir", "re"] + available = list(self.queues.keys()) + + def sort_key(cam_id): + role = str(self.roles.get(cam_id, "unknown")).lower() + try: + return role_order.index(role) + except ValueError: + return 99 + + return sorted(available, key=sort_key) def start(self): if self.running: diff --git a/Python/OAK/datasets/oak-fcc-3/core/radiometric_controller.py b/Python/OAK/datasets/oak-fcc-3/core/radiometric_controller.py index cd9d8e509..cb1b760cf 100644 --- a/Python/OAK/datasets/oak-fcc-3/core/radiometric_controller.py +++ b/Python/OAK/datasets/oak-fcc-3/core/radiometric_controller.py @@ -122,6 +122,83 @@ class RadiometricController: self.saturation_hard_pct = float(cfg.get("saturation_hard_pct", 20.0)) self.saturation_extreme_pct = float(cfg.get("saturation_extreme_pct", 60.0)) + # ============================================================ + # Global Saturation Guard + Sun Guard + # ============================================================ + # O controle por patches continua sendo a referência radiométrica. + # Estas guardas olham a cena útil inteira para detectar regiões + # saturadas/sol direto fora dos cartões. + self.global_saturation_guard_enabled = bool(cfg.get("global_saturation_guard_enabled", True)) + self.global_guard_roi_pct = self._safe_roi_pct( + cfg.get("global_guard_roi_pct", cfg.get("global_roi_pct", {})) or {}, + fallback={"x0": 0.05, "y0": 0.05, "x1": 0.95, "y1": 0.95}, + ) + + raw_guard_by_role = cfg.get("global_guard_roi_pct_by_role", {}) or {} + self.global_guard_roi_pct_by_role = {} + for role in self.ROLES: + roi = raw_guard_by_role.get(role) + if isinstance(roi, dict) and roi: + self.global_guard_roi_pct_by_role[role] = self._safe_roi_pct( + roi, + fallback=self.global_guard_roi_pct, + ) + else: + self.global_guard_roi_pct_by_role[role] = dict(self.global_guard_roi_pct) + + self.global_guard_sat_threshold = float(cfg.get("global_guard_sat_threshold", 0.985)) + self.global_guard_near_sat_threshold = float(cfg.get("global_guard_near_sat_threshold", 0.940)) + + self.global_guard_sat_pct_soft = float(cfg.get("global_guard_sat_pct_soft", 0.08)) + self.global_guard_sat_pct_hard = float(cfg.get("global_guard_sat_pct_hard", 0.35)) + self.global_guard_sat_pct_extreme = float(cfg.get("global_guard_sat_pct_extreme", 1.50)) + + self.global_guard_blob_pct_soft = float(cfg.get("global_guard_blob_pct_soft", 0.025)) + self.global_guard_blob_pct_hard = float(cfg.get("global_guard_blob_pct_hard", 0.120)) + self.global_guard_blob_pct_extreme = float(cfg.get("global_guard_blob_pct_extreme", 0.400)) + + self.global_guard_min_blob_px = int(cfg.get("global_guard_min_blob_px", 48)) + self.global_guard_downsample_max_side = int(cfg.get("global_guard_downsample_max_side", 320)) + + self.global_guard_reduce_factor_soft = float(cfg.get("global_guard_reduce_factor_soft", 0.88)) + self.global_guard_reduce_factor_hard = float(cfg.get("global_guard_reduce_factor_hard", 0.68)) + self.global_guard_reduce_factor_extreme = float(cfg.get("global_guard_reduce_factor_extreme", 0.45)) + + self.sun_guard_enabled = bool(cfg.get("sun_guard_enabled", True)) + self.sun_guard_p99_threshold = float(cfg.get("sun_guard_p99_threshold", 0.900)) + self.sun_guard_near_sat_pct_threshold = float(cfg.get("sun_guard_near_sat_pct_threshold", 1.00)) + self.sun_guard_freeze_increase_cycles = int(cfg.get("sun_guard_freeze_increase_cycles", 2)) + self.sun_guard_allow_decrease = bool(cfg.get("sun_guard_allow_decrease", True)) + + # Modo parrudo: quando há clarão real, a guarda deixa de ser só + # consultiva e vira proteção prioritária. Isso evita perder frames + # por saturação quando a área clara está fora dos cartões. + self.guard_force_apply_enabled = bool(cfg.get("guard_force_apply_enabled", True)) + self.guard_force_apply_soft = bool(cfg.get("guard_force_apply_soft", True)) + self.guard_force_apply_hard = bool(cfg.get("guard_force_apply_hard", True)) + self.guard_force_apply_extreme = bool(cfg.get("guard_force_apply_extreme", True)) + self.guard_force_apply_on_patch_saturation = bool(cfg.get("guard_force_apply_on_patch_saturation", True)) + + # Depois de um clarão, seguramos qualquer aumento por alguns ciclos. + # Isso dá tempo para o pipeline aplicar a exposição e impede sanfona + # quando a chapa branca entra/sai rapidamente do campo central. + self.guard_freeze_cycles_soft = int(cfg.get("guard_freeze_cycles_soft", max(2, self.sun_guard_freeze_increase_cycles))) + self.guard_freeze_cycles_hard = int(cfg.get("guard_freeze_cycles_hard", max(4, self.sun_guard_freeze_increase_cycles))) + self.guard_freeze_cycles_extreme = int(cfg.get("guard_freeze_cycles_extreme", max(6, self.sun_guard_freeze_increase_cycles))) + + # Se a decisão chegar ao mínimo de exposição, reenviamos o comando + # em emergência mesmo que o estado interno já ache que está no mínimo. + # Isso cobre atraso de pipeline e diferença entre estado lógico e câmera real. + self.guard_reapply_min_exp_on_emergency = bool(cfg.get("guard_reapply_min_exp_on_emergency", True)) + self.guard_min_exp_margin_us = int(cfg.get("guard_min_exp_margin_us", 60)) + + self._sun_guard_hold_cycles = { + "rgb": 0, + "re": 0, + "nir": 0, + "spectral_shared": 0, + } + self.gain_return_enabled = bool(cfg.get("gain_return_enabled", True)) self.gain_return_factor = float(cfg.get("gain_return_factor", 0.60)) self.gain_reduce_on_saturation = bool(cfg.get("gain_reduce_on_saturation", True)) @@ -240,6 +317,17 @@ class RadiometricController: return self._safe_roi_pct(self.global_roi_pct) + def _get_global_guard_roi_pct_for_role(self, role: str) -> dict: + role = self._normalize_role(role) + + by_role = getattr(self, "global_guard_roi_pct_by_role", {}) or {} + if isinstance(by_role, dict): + roi = by_role.get(role) + if isinstance(roi, dict) and roi: + return self._safe_roi_pct(roi, fallback=self.global_guard_roi_pct) + + return self._safe_roi_pct(self.global_guard_roi_pct) + def _get_patch_roi_items_for_role(self, patch: dict, role: str) -> list[dict]: """ Retorna uma lista normalizada de ROIs para um patch/cor em uma câmera. @@ -658,10 +746,12 @@ class RadiometricController: stats = self.measure_global(img_gray, role=role) stats["source"] = "reference_patches_fallback_global" stats["patches"] = [] + stats["global_guard"] = self.measure_global_guard(img_gray, role=role) return stats metrics = self.aggregate_patch_metrics(patch_results) metrics["role"] = role + metrics["global_guard"] = self.measure_global_guard(img_gray, role=role) return metrics @@ -1012,6 +1102,11 @@ class RadiometricController: target_value = float(np.mean(target_values)) weighted_error = float(np.mean(errors)) + global_guards_by_role = { + role: item["metrics"].get("global_guard", {}) + for role, item in valid_items.items() + } + return { "valid": True, "source": "spectral_shared", @@ -1025,6 +1120,7 @@ class RadiometricController: "control_value": control_value, "target_value": target_value, "weighted_error": weighted_error, + "global_guard": self._merge_global_guards(global_guards_by_role), "control_values_by_role": { role: float(item["metrics"].get("control_value", item["metrics"].get("p50", 0.0))) for role, item in valid_items.items() @@ -1039,6 +1135,205 @@ class RadiometricController: }, } + def measure_global_guard(self, img_gray: np.ndarray, role: str = "rgb") -> dict: + """ + Mede risco global de saturação/sol direto na área útil da cena. + + Esta guarda não substitui os patches. Ela apenas detecta regiões + saturadas fora dos cartões, por exemplo uma chapa branca em sol direto + enquanto os patches permanecem na sombra. + """ + role = self._normalize_role(role) + + if not self.global_saturation_guard_enabled and not self.sun_guard_enabled: + return {"enabled": False, "role": role, "active": False, "severity": "none"} + + h, w = img_gray.shape[:2] + roi_pct = self._get_global_guard_roi_pct_for_role(role) + roi = self._roi_pct_to_pixels( + h, w, + roi_pct["x0"], + roi_pct["y0"], + roi_pct["x1"], + roi_pct["y1"], + ) + x0, y0, x1, y1 = roi + arr2d = np.asarray(img_gray[y0:y1, x0:x1], dtype=np.float32) + + if arr2d.size == 0: + return { + "enabled": True, + "role": role, + "active": False, + "severity": "none", + "valid": False, + "reason": "empty_roi", + "roi": list(roi), + "roi_pct": dict(roi_pct), + } + + flat = arr2d.reshape(-1) + sat_mask = arr2d >= self.global_guard_sat_threshold + near_mask = arr2d >= self.global_guard_near_sat_threshold + + sat_pct = float(sat_mask.mean() * 100.0) + near_sat_pct = float(near_mask.mean() * 100.0) + p95 = float(np.percentile(flat, 95)) + p99 = float(np.percentile(flat, 99)) + p999 = float(np.percentile(flat, 99.9)) + max_value = float(np.max(flat)) + + blob = self._largest_blob_pct(sat_mask) + largest_blob_pct = float(blob.get("largest_blob_pct", 0.0)) + + severity = "none" + exp_factor = 1.0 + guard_reasons = [] + + if self.global_saturation_guard_enabled: + if sat_pct >= self.global_guard_sat_pct_extreme or largest_blob_pct >= self.global_guard_blob_pct_extreme: + severity = "extreme" + exp_factor = self.global_guard_reduce_factor_extreme + guard_reasons.append("global_saturation_extreme") + elif sat_pct >= self.global_guard_sat_pct_hard or largest_blob_pct >= self.global_guard_blob_pct_hard: + severity = "hard" + exp_factor = self.global_guard_reduce_factor_hard + guard_reasons.append("global_saturation_hard") + elif sat_pct >= self.global_guard_sat_pct_soft or largest_blob_pct >= self.global_guard_blob_pct_soft: + severity = "soft" + exp_factor = self.global_guard_reduce_factor_soft + guard_reasons.append("global_saturation_soft") + + sun_active = False + if self.sun_guard_enabled: + sun_active = ( + p99 >= self.sun_guard_p99_threshold + or near_sat_pct >= self.sun_guard_near_sat_pct_threshold + or severity in ("soft", "hard", "extreme") + ) + if sun_active: + guard_reasons.append("sun_guard_active") + + return { + "enabled": True, + "valid": True, + "role": role, + "active": bool(severity != "none" or sun_active), + "severity": severity, + "sun_active": bool(sun_active), + "exp_factor": float(exp_factor), + "reason": "+".join(guard_reasons) if guard_reasons else "ok", + "roi": list(roi), + "roi_pct": dict(roi_pct), + "thresholds": { + "sat": self.global_guard_sat_threshold, + "near_sat": self.global_guard_near_sat_threshold, + "sun_p99": self.sun_guard_p99_threshold, + }, + "stats": { + "pixels": int(flat.size), + "p95": p95, + "p99": p99, + "p999": p999, + "max": max_value, + "sat_pct": sat_pct, + "near_sat_pct": near_sat_pct, + "largest_blob_px": int(blob.get("largest_blob_px", 0)), + "largest_blob_pct": largest_blob_pct, + "blob_count": int(blob.get("blob_count", 0)), + }, + } + + def _largest_blob_pct(self, mask: np.ndarray) -> dict: + """Calcula o maior blob saturado em porcentagem da ROI.""" + mask = np.asarray(mask, dtype=bool) + total_px = int(mask.size) + if total_px <= 0 or not bool(mask.any()): + return {"largest_blob_px": 0, "largest_blob_pct": 0.0, "blob_count": 0} + + h, w = mask.shape[:2] + max_side = max(h, w) + step = 1 + if self.global_guard_downsample_max_side > 0 and max_side > self.global_guard_downsample_max_side: + step = int(np.ceil(max_side / float(self.global_guard_downsample_max_side))) + mask_small = mask[::step, ::step] + else: + mask_small = mask + + hs, ws = mask_small.shape[:2] + visited = np.zeros(mask_small.shape, dtype=bool) + largest = 0 + blob_count = 0 + + ys, xs = np.nonzero(mask_small) + for sy, sx in zip(ys.tolist(), xs.tolist()): + if visited[sy, sx] or not mask_small[sy, sx]: + continue + + stack = [(sy, sx)] + visited[sy, sx] = True + area = 0 + + while stack: + cy, cx = stack.pop() + area += 1 + for ny in (cy - 1, cy, cy + 1): + if ny < 0 or ny >= hs: + continue + for nx in (cx - 1, cx, cx + 1): + if nx < 0 or nx >= ws or visited[ny, nx] or not mask_small[ny, nx]: + continue + visited[ny, nx] = True + stack.append((ny, nx)) + + estimated_area = int(area * step * step) + if estimated_area >= self.global_guard_min_blob_px: + blob_count += 1 + largest = max(largest, estimated_area) + + return { + "largest_blob_px": int(largest), + "largest_blob_pct": float((largest / max(1, total_px)) * 100.0), + "blob_count": int(blob_count), + } + + def _merge_global_guards(self, guards_by_role: dict) -> dict: + valid_guards = { + role: guard for role, guard in (guards_by_role or {}).items() + if isinstance(guard, dict) and guard.get("valid", False) + } + if not valid_guards: + return {"enabled": self.global_saturation_guard_enabled or self.sun_guard_enabled, "valid": False, "severity": "none", "sun_active": False} + + severity_rank = {"none": 0, "soft": 1, "hard": 2, "extreme": 3} + inv_rank = {v: k for k, v in severity_rank.items()} + max_rank = max(severity_rank.get(str(g.get("severity", "none")), 0) for g in valid_guards.values()) + + stats = { + "sat_pct": max(float(g.get("stats", {}).get("sat_pct", 0.0)) for g in valid_guards.values()), + "near_sat_pct": max(float(g.get("stats", {}).get("near_sat_pct", 0.0)) for g in valid_guards.values()), + "p95": max(float(g.get("stats", {}).get("p95", 0.0)) for g in valid_guards.values()), + "p99": max(float(g.get("stats", {}).get("p99", 0.0)) for g in valid_guards.values()), + "p999": max(float(g.get("stats", {}).get("p999", 0.0)) for g in valid_guards.values()), + "largest_blob_pct": max(float(g.get("stats", {}).get("largest_blob_pct", 0.0)) for g in valid_guards.values()), + "largest_blob_px": max(int(g.get("stats", {}).get("largest_blob_px", 0)) for g in valid_guards.values()), + "blob_count": sum(int(g.get("stats", {}).get("blob_count", 0)) for g in valid_guards.values()), + } + + factors = [float(g.get("exp_factor", 1.0)) for g in valid_guards.values() if str(g.get("severity", "none")) != "none"] + return { + "enabled": True, + "valid": True, + "role": "spectral_shared", + "active": bool(max_rank > 0 or any(bool(g.get("sun_active", False)) for g in valid_guards.values())), + "severity": inv_rank.get(max_rank, "none"), + "sun_active": any(bool(g.get("sun_active", False)) for g in valid_guards.values()), + "exp_factor": min(factors) if factors else 1.0, + "reason": "+".join(sorted(set(str(g.get("reason", "ok")) for g in valid_guards.values()))), + "stats": stats, + "by_role": valid_guards, + } + def compute_stats(self, arr: np.ndarray) -> dict: arr = np.asarray(arr, dtype=np.float32).reshape(-1) if arr.size == 0: @@ -1090,6 +1385,28 @@ class RadiometricController: return p return None + def _guard_freeze_cycles_for_severity(self, severity: str) -> int: + severity = str(severity or "none").lower() + if severity == "extreme": + return int(self.guard_freeze_cycles_extreme) + if severity == "hard": + return int(self.guard_freeze_cycles_hard) + if severity == "soft": + return int(self.guard_freeze_cycles_soft) + return int(self.sun_guard_freeze_increase_cycles) + + def _guard_force_apply_for_severity(self, severity: str) -> bool: + if not self.guard_force_apply_enabled: + return False + severity = str(severity or "none").lower() + if severity == "extreme": + return bool(self.guard_force_apply_extreme) + if severity == "hard": + return bool(self.guard_force_apply_hard) + if severity == "soft": + return bool(self.guard_force_apply_soft) + return False + def compute_control(self, role: str, metrics: dict, virtual_role: str | None = None) -> dict: state_role = str(role).lower() log_role = virtual_role or state_role @@ -1129,12 +1446,41 @@ class RadiometricController: error = float(metrics.get("weighted_error", target - control_value)) p95 = float(metrics.get("p95", 0.0)) sat_pct = float(metrics.get("sat_pct", 0.0)) + global_guard = metrics.get("global_guard", {}) or {} + + guard_active = bool(global_guard.get("active", False)) + guard_severity = str(global_guard.get("severity", "none")).lower() + sun_active = bool(global_guard.get("sun_active", False)) + guard_stats = global_guard.get("stats", {}) or {} + guard_sat_pct = float(guard_stats.get("sat_pct", 0.0)) + guard_p99 = float(guard_stats.get("p99", 0.0)) + guard_near_sat_pct = float(guard_stats.get("near_sat_pct", 0.0)) + guard_blob_pct = float(guard_stats.get("largest_blob_pct", 0.0)) + + # A Sun Guard não manda na exposição sozinha: ela congela aumentos por + # alguns ciclos quando a cena útil parece estar sob sol/brilho forte. + # Reduções continuam permitidas para proteger contra estouro. + if sun_active: + freeze_cycles = self._guard_freeze_cycles_for_severity(guard_severity) + self._sun_guard_hold_cycles[log_role] = max( + self._sun_guard_hold_cycles.get(log_role, 0), + freeze_cycles, + ) + else: + self._sun_guard_hold_cycles[log_role] = max(0, self._sun_guard_hold_cycles.get(log_role, 0) - 1) + + sun_hold_cycles = self._sun_guard_hold_cycles.get(log_role, 0) + + # Para os contadores de pressão, a guarda global conta como sobreexposição + # quando há saturação real fora dos patches. + pressure_p95 = max(p95, float(guard_stats.get("p95", 0.0))) if guard_active else p95 + pressure_sat = max(sat_pct, guard_sat_pct) if guard_active else sat_pct under_cycles, over_cycles = self._update_exposure_pressure_state( log_role=log_role, error=error, - p95=p95, - sat_pct=sat_pct, + p95=pressure_p95, + sat_pct=pressure_sat, ) exp_min = int(limits["exp_min_us"]) @@ -1150,11 +1496,48 @@ class RadiometricController: ratio = None factor = 1.0 gain_policy = "hold" + force_apply_exposure = False + force_apply_gain = False + force_apply_reason = "" # ============================================================ - # 1) Proteção forte contra saturação / p95 alto + # 0) Global Saturation Guard: proteção de cena inteira. # ============================================================ - if sat_pct > self.saturation_limit_pct or p95 > self.p95_limit: + if self.global_saturation_guard_enabled and guard_severity in ("soft", "hard", "extreme"): + exp_factor = float(global_guard.get("exp_factor", self.global_guard_reduce_factor_soft)) + exp_factor = self._clamp(exp_factor, 0.10, 1.0) + new_exp = int(self._clamp(old_exp * exp_factor, exp_min, exp_max)) + if self._guard_force_apply_for_severity(guard_severity): + force_apply_exposure = True + force_apply_reason = f"global_guard_{guard_severity}" + + if self.gain_reduce_on_saturation and old_gain > gain_min: + if self.gain_hard_reset_on_saturation and guard_severity == "extreme": + new_gain = gain_min + gain_policy = "hard_reset_gain_on_global_guard" + else: + desired_gain = old_gain - self.gain_step_down + new_gain = float(self._clamp(desired_gain, gain_min, gain_max)) + gain_policy = "decrease_gain_step_on_global_guard" + else: + new_gain = old_gain + gain_policy = "hold_gain" + + if new_gain != old_gain: + force_apply_gain = True + + action = "decrease_exposure" + reason = ( + f"global_saturation_guard:{guard_severity} " + f"sat={guard_sat_pct:.3f}% near={guard_near_sat_pct:.3f}% " + f"blob={guard_blob_pct:.3f}% p99={guard_p99:.3f} " + f"exp_factor={exp_factor:.3f} gain_policy={gain_policy}" + ) + + # ============================================================ + # 1) Proteção forte contra saturação / p95 alto dos patches. + # ============================================================ + elif sat_pct > self.saturation_limit_pct or p95 > self.p95_limit: if sat_pct >= self.saturation_extreme_pct: exp_factor = 0.45 elif sat_pct >= self.saturation_hard_pct: @@ -1165,6 +1548,9 @@ class RadiometricController: exp_factor = self.reduce_fast_factor new_exp = int(self._clamp(old_exp * exp_factor, exp_min, exp_max)) + if self.guard_force_apply_enabled and self.guard_force_apply_on_patch_saturation: + force_apply_exposure = True + force_apply_reason = "patch_saturation_or_p95" if self.gain_reduce_on_saturation and old_gain > gain_min: if self.gain_hard_reset_on_saturation and sat_pct >= self.saturation_extreme_pct: @@ -1178,6 +1564,9 @@ class RadiometricController: new_gain = old_gain gain_policy = "hold_gain" + if new_gain != old_gain: + force_apply_gain = True + action = "decrease_exposure" reason = ( f"saturação/p95 alto: sat={sat_pct:.2f}% p95={p95:.3f} " @@ -1185,7 +1574,7 @@ class RadiometricController: ) # ============================================================ - # 2) Fora da faixa morta: controle por ratio/linear + # 2) Fora da faixa morta: controle por ratio/linear. # ============================================================ elif abs(error) > self.deadband: if self.control_strategy == "ratio": @@ -1199,39 +1588,51 @@ class RadiometricController: if self.prefer_exposure: # ---------------------------------------------------- - # 2A) Cena escura: subir exposição primeiro. - # Só subir ganho se exposição já estiver perto do máximo. + # 2A) Cena escura nos patches: subir exposição primeiro. + # A Sun Guard pode congelar aumentos se a cena útil está + # sob sol/brilho forte, mesmo com patches na sombra. # ---------------------------------------------------- if error > 0: - desired_exp = int(self._clamp(old_exp * factor, exp_min, exp_max)) - new_exp = desired_exp - new_gain = old_gain - gain_policy = "hold_gain_prefer_exposure" - - exp_high_threshold = int(exp_max * self.exp_high_ratio_for_gain) - - if ( - desired_exp >= exp_high_threshold - and under_cycles >= self.gain_increase_required_cycles - ): - # Sobe ganho devagar, em degrau fixo. - desired_gain = old_gain + self.gain_step_up - new_gain = float(self._clamp(desired_gain, gain_min, gain_max)) - gain_policy = f"increase_gain_slow_under_cycles_{under_cycles}" - else: + if self.sun_guard_enabled and sun_hold_cycles > 0: + new_exp = old_exp new_gain = old_gain - gain_policy = f"hold_gain_under_cycles_{under_cycles}" + factor = 1.0 + action = "hold" + gain_policy = "hold_gain_sun_guard" + reason = ( + f"sun_guard congelou aumento: cycles={sun_hold_cycles} " + f"value={control_value:.3f} target={target:.3f} " + f"p99={guard_p99:.3f} near={guard_near_sat_pct:.3f}% " + f"sat={guard_sat_pct:.3f}% blob={guard_blob_pct:.3f}%" + ) + else: + desired_exp = int(self._clamp(old_exp * factor, exp_min, exp_max)) + new_exp = desired_exp + new_gain = old_gain + gain_policy = "hold_gain_prefer_exposure" - action = "increase_exposure" - reason = ( - f"subindo exposição por {self.control_metric}: " - f"value={control_value:.3f} target={target:.3f} " - f"error={error:.3f} factor={factor:.3f} gain_policy={gain_policy}" - ) + exp_high_threshold = int(exp_max * self.exp_high_ratio_for_gain) + + if ( + desired_exp >= exp_high_threshold + and under_cycles >= self.gain_increase_required_cycles + ): + desired_gain = old_gain + self.gain_step_up + new_gain = float(self._clamp(desired_gain, gain_min, gain_max)) + gain_policy = f"increase_gain_slow_under_cycles_{under_cycles}" + else: + new_gain = old_gain + gain_policy = f"hold_gain_under_cycles_{under_cycles}" + + action = "increase_exposure" + reason = ( + f"subindo exposição por {self.control_metric}: " + f"value={control_value:.3f} target={target:.3f} " + f"error={error:.3f} factor={factor:.3f} gain_policy={gain_policy}" + ) # ---------------------------------------------------- - # 2B) Cena clara: se ganho está acima do mínimo, - # reduzir ganho primeiro ou junto. + # 2B) Cena clara: reduzir normalmente. # ---------------------------------------------------- else: desired_exp = int(self._clamp(old_exp * factor, exp_min, exp_max)) @@ -1257,19 +1658,27 @@ class RadiometricController: ) else: - desired_gain = old_gain * factor - new_gain = float(self._clamp(desired_gain, gain_min, gain_max)) - action = "increase_gain" if error > 0 else "decrease_gain" - gain_policy = "direct_gain_control" - reason = ( - f"corrigindo ganho por {self.control_metric}: " - f"value={control_value:.3f} target={target:.3f} " - f"error={error:.3f} factor={factor:.3f}" - ) + if self.sun_guard_enabled and error > 0 and sun_hold_cycles > 0: + new_gain = old_gain + action = "hold" + gain_policy = "hold_gain_sun_guard" + reason = ( + f"sun_guard congelou aumento de ganho: cycles={sun_hold_cycles} " + f"value={control_value:.3f} target={target:.3f}" + ) + else: + desired_gain = old_gain * factor + new_gain = float(self._clamp(desired_gain, gain_min, gain_max)) + action = "increase_gain" if error > 0 else "decrease_gain" + gain_policy = "direct_gain_control" + reason = ( + f"corrigindo ganho por {self.control_metric}: " + f"value={control_value:.3f} target={target:.3f} " + f"error={error:.3f} factor={factor:.3f}" + ) # ============================================================ - # 3) Dentro da faixa morta: opcionalmente devolver ganho - # se ganho alto não é mais necessário. + # 3) Dentro da faixa morta: opcionalmente devolver ganho. # ============================================================ else: if self.gain_return_enabled and old_gain > gain_min: @@ -1292,12 +1701,24 @@ class RadiometricController: new_exp = int(self._clamp(new_exp, exp_min, exp_max)) new_gain = float(self._clamp(new_gain, gain_min, gain_max)) + if ( + self.guard_reapply_min_exp_on_emergency + and force_apply_exposure + and action == "decrease_exposure" + and new_exp <= (exp_min + self.guard_min_exp_margin_us) + ): + # Mesmo que old_exp == new_exp, reenviar o mínimo protege contra + # latência/dessincronia entre estado interno e exposição real da câmera. + force_apply_exposure = True + if not force_apply_reason: + force_apply_reason = "emergency_reapply_min_exp" + ready, ready_cycles = self._update_ready_state( log_role=log_role, action=action, error=error, - p95=p95, - sat_pct=sat_pct, + p95=max(p95, float(guard_stats.get("p95", 0.0))) if guard_active else p95, + sat_pct=max(sat_pct, guard_sat_pct) if guard_active else sat_pct, ) return { @@ -1313,13 +1734,11 @@ class RadiometricController: "error": error, "p95": p95, "sat_pct": sat_pct, - #"metrics_source": metrics.get("source"), - #"control_source": metrics.get("control_source"), - #"patch_quality": metrics.get("patch_quality"), - #"patches": metrics.get("patches"), - #"control_values_by_role": metrics.get("control_values_by_role"), - #"targets_by_role": metrics.get("targets_by_role"), - #"patch_quality_by_role": metrics.get("patch_quality_by_role"), + "global_guard": global_guard, + "sun_guard_hold_cycles": int(sun_hold_cycles), + "force_apply_exposure": bool(force_apply_exposure), + "force_apply_gain": bool(force_apply_gain), + "force_apply_reason": force_apply_reason, "old_exp": old_exp, "new_exp": new_exp, "old_gain": old_gain, @@ -1338,6 +1757,10 @@ class RadiometricController: role = str(role).lower() new_exp = int(decision["new_exp"]) new_gain = float(decision["new_gain"]) + force_apply_exposure = bool(decision.get("force_apply_exposure", False) or decision.get("force_apply", False)) + force_apply_gain = bool(decision.get("force_apply_gain", False)) + force_apply_reason = str(decision.get("force_apply_reason", "")) + self.state[role]["exp"] = new_exp self.state[role]["gain"] = new_gain responses = {} @@ -1348,12 +1771,34 @@ class RadiometricController: if role == "rgb": responses["awb"] = self.client.svc.set_awb_enable(role=role, enable=False) self._ae_disabled.add(role) - if last["exp"] is None or abs(new_exp - last["exp"]) >= self.exp_apply_threshold_us: + exp_delta_ok = last["exp"] is None or abs(new_exp - last["exp"]) >= self.exp_apply_threshold_us + if force_apply_exposure or exp_delta_ok: responses["exposure"] = self.client.svc.set_exposure_time(role=role, exposure_time_us=new_exp) last["exp"] = new_exp - if last["gain"] is None or abs(new_gain - last["gain"]) >= self.gain_apply_threshold: + if force_apply_exposure: + responses["force_exposure"] = { + "enabled": True, + "reason": force_apply_reason, + "threshold_us": self.exp_apply_threshold_us, + } + else: + responses["exposure_skipped"] = { + "reason": "below_threshold", + "threshold_us": self.exp_apply_threshold_us, + "last_exp": last["exp"], + "new_exp": new_exp, + } + + gain_delta_ok = last["gain"] is None or abs(new_gain - last["gain"]) >= self.gain_apply_threshold + if force_apply_gain or gain_delta_ok: responses["gain"] = self.client.svc.set_analogue_gain(role=role, analogue_gain=new_gain) last["gain"] = new_gain + if force_apply_gain: + responses["force_gain"] = { + "enabled": True, + "reason": force_apply_reason, + "threshold": self.gain_apply_threshold, + } except Exception as e: responses["error"] = str(e) if self.verbose: @@ -1362,6 +1807,8 @@ class RadiometricController: f"action={decision.get('action')} " f"exp={decision.get('old_exp')}->{decision.get('new_exp')} " f"gain={decision.get('old_gain'):.2f}->{decision.get('new_gain'):.2f} " + f"force_exp={bool(decision.get('force_apply_exposure', False))} " + f"force_reason={decision.get('force_apply_reason', '')} " f"ok={'error' not in responses}" ) return responses @@ -1405,6 +1852,21 @@ class RadiometricController: f"sat={decision.get('sat_pct'):.2f}%" ) + guard = decision.get("global_guard") or metrics.get("global_guard") or {} + if guard.get("active") or guard.get("sun_active"): + gst = guard.get("stats", {}) or {} + print( + f" [GLOBAL_GUARD] role={guard.get('role', role)} " + f"severity={guard.get('severity', 'none')} " + f"sun={bool(guard.get('sun_active', False))} " + f"reason={guard.get('reason', 'ok')} " + f"p99={gst.get('p99', 0):.3f} " + f"sat={gst.get('sat_pct', 0):.3f}% " + f"near={gst.get('near_sat_pct', 0):.3f}% " + f"blob={gst.get('largest_blob_pct', 0):.3f}% " + f"hold_cycles={decision.get('sun_guard_hold_cycles', 0)}" + ) + # Caso normal: rgb individual patches = metrics.get("patches", []) if patches: diff --git a/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py index 1ef9eee00..25e88dc64 100644 --- a/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py +++ b/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py @@ -215,153 +215,315 @@ def validate_module_ready(status: dict, raw_policy: str): # Config radiométrico # ============================================================ +# Defaults espelhados do module_params.json atual. +# Este bloco é a "semente boa" do AE Rad: patches + Global Saturation Guard + Sun Guard. +DEFAULT_RADIOMETRIC_CONFIG = {'enabled': True, + 'interval_s': 0.25, + 'verbose': True, + 'metering_mode': 'reference_patches', + 'spectral_control_mode': 'shared', + 'control_metric': 'p50', + 'target_value': 0.5, + 'deadband': 0.035, + 'p95_limit': 0.94, + 'saturation_limit_pct': 0.5, + 'alpha': 0.18, + 'exp_step_gain': 0.55, + 'prefer_exposure': True, + 'exp_min_us': 100, + 'exp_max_us': 80000, + 'gain_min': 1.0, + 'gain_max': 4.0, + 'reference_patches': [{'name': 'black_reference', + 'type': 'black', + 'roles': ['rgb', 're', 'nir'], + 'roi_pct': {'x0': 0.365625, 'y0': 0.8225, 'x1': 0.432812, 'y1': 0.995}, + 'target_value': 0.08, + 'weight': 0.7, + 'roi_pct_by_role': {'rgb': {'x0': 0.365625, 'y0': 0.8225, 'x1': 0.432812, 'y1': 0.995}, + 're': {'x0': 0.395313, 'y0': 0.745, 'x1': 0.4625, 'y1': 0.9225}, + 'nir': {'x0': 0.353125, 'y0': 0.78, 'x1': 0.420312, 'y1': 0.9525}}, + 'roi_list_by_role': {'rgb': [{'name': 'rgb_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.365625, + 'y0': 0.8225, + 'x1': 0.432812, + 'y1': 0.995}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:19:46'}], + 're': [{'name': 're_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.395313, + 'y0': 0.745, + 'x1': 0.4625, + 'y1': 0.9225}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:20:14'}], + 'nir': [{'name': 'nir_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.353125, + 'y0': 0.78, + 'x1': 0.420312, + 'y1': 0.9525}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:20:47'}]}}, + {'name': 'gray_reference', + 'type': 'gray', + 'roles': ['rgb', 're', 'nir'], + 'roi_pct': {'x0': 0.29375, 'y0': 0.825, 'x1': 0.3625, 'y1': 0.995}, + 'target_value': 0.35, + 'target_value_by_role': {'rgb': 0.34, 're': 0.24, 'nir': 0.3}, + 'weight': 1.0, + 'roi_pct_by_role': {'rgb': {'x0': 0.29375, 'y0': 0.825, 'x1': 0.3625, 'y1': 0.995}, + 're': {'x0': 0.325, 'y0': 0.75, 'x1': 0.389062, 'y1': 0.915}, + 'nir': {'x0': 0.284375, 'y0': 0.79, 'x1': 0.35, 'y1': 0.9525}}, + 'roi_list_by_role': {'rgb': [{'name': 'rgb_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.29375, + 'y0': 0.825, + 'x1': 0.3625, + 'y1': 0.995}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:19:29'}], + 're': [{'name': 're_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.325, 'y0': 0.75, 'x1': 0.389062, 'y1': 0.915}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:20:22'}], + 'nir': [{'name': 'nir_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.284375, 'y0': 0.79, 'x1': 0.35, 'y1': 0.9525}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:20:53'}]}}, + {'name': 'white_reference', + 'type': 'white', + 'roles': ['rgb', 're', 'nir'], + 'roi_pct': {'x0': 0.220312, 'y0': 0.8225, 'x1': 0.2875, 'y1': 0.995}, + 'target_value': 0.82, + 'weight': 0.8, + 'roi_pct_by_role': {'rgb': {'x0': 0.220312, 'y0': 0.8225, 'x1': 0.2875, 'y1': 0.995}, + 're': {'x0': 0.25, 'y0': 0.7525, 'x1': 0.315625, 'y1': 0.9225}, + 'nir': {'x0': 0.214062, 'y0': 0.785, 'x1': 0.282813, 'y1': 0.9525}}, + 'roi_list_by_role': {'rgb': [{'name': 'rgb_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.220312, + 'y0': 0.8225, + 'x1': 0.2875, + 'y1': 0.995}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:19:04'}], + 're': [{'name': 're_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.25, + 'y0': 0.7525, + 'x1': 0.315625, + 'y1': 0.9225}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:20:27'}], + 'nir': [{'name': 'nir_legacy_01', + 'enabled': True, + 'roi_pct': {'x0': 0.214062, + 'y0': 0.785, + 'x1': 0.282813, + 'y1': 0.9525}, + 'created_at': '2026-05-07 14:36:25', + 'updated_at': '2026-05-08 09:21:02'}]}}], + 'exp_apply_threshold_us': 80, + 'gain_apply_threshold': 0.05, + 'apply_same_spectral_to_both': True, + 'spectral_roles': ['re', 'nir'], + 'dark_limit_pct': 35.0, + 'control_strategy': 'ratio', + 'ratio_alpha': 0.35, + 'ratio_min': 0.65, + 'ratio_max': 1.35, + 'reduce_fast_factor': 0.8, + 'factor_min': 0.55, + 'factor_max': 1.28, + 'gain_return_enabled': True, + 'gain_reduce_on_saturation': True, + 'gain_increase_required_cycles': 5, + 'gain_decrease_required_cycles': 2, + 'gain_step_up': 0.2, + 'gain_step_down': 0.5, + 'gain_hard_reset_on_saturation': False, + 'exp_high_ratio_for_gain': 0.95, + 'exp_low_ratio_for_gain_return': 0.75, + 'role_limits': {'rgb': {'exp_min_us': 100, 'exp_max_us': 80000, 'gain_min': 1.0, 'gain_max': 2.0}, + 're': {'exp_min_us': 100, 'exp_max_us': 2500, 'gain_min': 1.0, 'gain_max': 2.0}, + 'nir': {'exp_min_us': 100, 'exp_max_us': 3000, 'gain_min': 1.0, 'gain_max': 2.0}}, + 'ready_required_cycles': 3, + 'patch_control_mode': 'gray_primary', + 'patch_require_order': True, + 'patch_min_separation': 0.08, + 'patch_white_sat_limit_pct': 0.5, + 'patch_white_p95_limit': 0.94, + 'patch_black_dark_limit_pct': 80.0, + 'patch_black_max_p50': 0.2, + 'patch_gray_min_p50': 0.08, + 'patch_gray_max_p50': 0.85, + 'patch_roi_contract': 'multi_roi_by_role_v1', + 'patch_roi_reduce_method': 'median_valid_rois', + 'patch_roi_outlier_reject': True, + 'patch_roi_max_p50_delta': 0.12, + 'global_saturation_guard_enabled': True, + 'global_guard_roi_pct': {'x0': 0.05, 'y0': 0.05, 'x1': 0.95, 'y1': 0.95}, + 'global_guard_sat_threshold': 0.985, + 'global_guard_near_sat_threshold': 0.94, + 'global_guard_sat_pct_soft': 0.05, + 'global_guard_sat_pct_hard': 0.2, + 'global_guard_sat_pct_extreme': 0.8, + 'global_guard_blob_pct_soft': 0.015, + 'global_guard_blob_pct_hard': 0.08, + 'global_guard_blob_pct_extreme': 0.25, + 'global_guard_min_blob_px': 48, + 'global_guard_downsample_max_side': 320, + 'global_guard_reduce_factor_soft': 0.82, + 'global_guard_reduce_factor_hard': 0.6, + 'global_guard_reduce_factor_extreme': 0.35, + 'sun_guard_enabled': True, + 'sun_guard_p99_threshold': 0.9, + 'sun_guard_near_sat_pct_threshold': 0.8, + 'sun_guard_freeze_increase_cycles': 2, + 'sun_guard_allow_decrease': True, + 'guard_force_apply_enabled': True, + 'guard_force_apply_soft': True, + 'guard_force_apply_hard': True, + 'guard_force_apply_extreme': True, + 'guard_force_apply_on_patch_saturation': True, + 'guard_freeze_cycles_soft': 3, + 'guard_freeze_cycles_hard': 5, + 'guard_freeze_cycles_extreme': 8, + 'guard_reapply_min_exp_on_emergency': True, + 'guard_min_exp_margin_us': 80} + +DEFAULT_PATCH_NORMALIZATION = {'enabled': True, + 'apply_when_metering_mode': 'reference_patches', + 'apply_stage': 'after_fusion', + 'method': 'gray_scale_with_white_guard', + 'space': 'multispec_tensor', + 'targets': {'black': 0.06, 'gray': 0.4, 'white': 0.78}, + 'white_guard_max': 0.92, + 'scale_min': 0.35, + 'scale_max': 2.5, + 'clip_output': True, + 'require_valid_gray': True, + 'use_black_for_offset': False, + 'save_patch_stats': True} + +PROFILE_SCHEMA = "multispec_radiometric_config_profiles_v4" +MODULE_PARAMS_SCHEMA = "multispec_module_params_v3" + + +def deep_clone(obj): + return json.loads(json.dumps(obj)) + + +def is_module_params_contract(data: dict) -> bool: + """Detecta o contrato completo do module_params.json, para não salvar wrappers do tool nele.""" + if not isinstance(data, dict): + return False + schema = str(data.get("schema", "")) + if schema == MODULE_PARAMS_SCHEMA: + return True + module_keys = ("camera_settings", "fusion_config", "rgb_calibration", "flatfield_config") + return "radiometric_config" in data and any(k in data for k in module_keys) + + +def sanitize_radiometric_config(cfg: dict) -> dict: + """Garante que o radiometric_config salvo siga o contrato runtime atual.""" + out = deep_clone(DEFAULT_RADIOMETRIC_CONFIG) + if isinstance(cfg, dict): + # Preserva valores/ROIs escolhidos no tool, mas injeta qualquer chave nova faltante. + for k, v in cfg.items(): + out[k] = v + + out.setdefault("reference_patches", deep_clone(DEFAULT_RADIOMETRIC_CONFIG.get("reference_patches", []))) + + # Garante contrato multi_roi_by_role_v1 em todos os patches. + out["patch_roi_contract"] = "multi_roi_by_role_v1" + for patch in out.get("reference_patches", []) or []: + if not isinstance(patch, dict): + continue + patch.setdefault("roles", ROLES[:] if "ROLES" in globals() else ["rgb", "re", "nir"]) + patch.setdefault("roi_pct", {}) + patch.setdefault("roi_pct_by_role", {"rgb": {}, "re": {}, "nir": {}}) + patch.setdefault("roi_list_by_role", {"rgb": [], "re": [], "nir": []}) + ensure_patch_roi_lists_by_role(patch) + + # Garante guardas parrudas mesmo em arquivos antigos. + for k, v in DEFAULT_RADIOMETRIC_CONFIG.items(): + if k.startswith("global_guard_") or k.startswith("sun_guard_") or k.startswith("guard_"): + out.setdefault(k, v) + + return out + + def base_ae_contract(): - return { - "enabled": True, - "interval_s": 0.20, - "verbose": True, - - "control_metric": "p50", - "target_value": 0.34, - "deadband": 0.035, - - "p95_limit": 0.94, - "saturation_limit_pct": 0.50, - "dark_limit_pct": 35.0, - - # Novo controle proporcional por razão - "control_strategy": "ratio", - "ratio_alpha": 0.35, - "ratio_min": 0.65, - "ratio_max": 1.35, - - # Redução rápida quando satura - "reduce_fast_factor": 0.80, - - # Mantém compatibilidade com o modo antigo - "alpha": 0.18, - "exp_step_gain": 0.55, - "factor_min": 0.55, - "factor_max": 1.28, - - "prefer_exposure": True, - - "exp_min_us": 100, - "exp_max_us": 80000, - "gain_min": 1.0, - "gain_max": 4.0, - - "gain_return_enabled": True, - "gain_reduce_on_saturation": True, - "gain_increase_required_cycles": 5, - "gain_decrease_required_cycles": 2, - "gain_step_up": 0.20, - "gain_step_down": 0.50, - "gain_hard_reset_on_saturation": False, - "exp_high_ratio_for_gain": 0.95, - "exp_low_ratio_for_gain_return": 0.75, - - "role_limits": { - "rgb": {"exp_min_us": 100, "exp_max_us": 80000, "gain_min": 1.0, "gain_max": 2.0}, - "re": {"exp_min_us": 100, "exp_max_us": 2500, "gain_min": 1.0, "gain_max": 2.0}, - "nir": {"exp_min_us": 100, "exp_max_us": 3000, "gain_min": 1.0, "gain_max": 2.0}, - }, - - "exp_apply_threshold_us": 80, - "gain_apply_threshold": 0.05, - - "ready_required_cycles": 3, - - "apply_same_spectral_to_both": True, - "spectral_roles": ["re", "nir"], - } + cfg = deep_clone(DEFAULT_RADIOMETRIC_CONFIG) + # Removemos somente campos específicos de patches quando usado como base global. + cfg.pop("reference_patches", None) + cfg.pop("patch_control_mode", None) + cfg.pop("patch_require_order", None) + cfg.pop("patch_min_separation", None) + cfg.pop("patch_white_sat_limit_pct", None) + cfg.pop("patch_white_p95_limit", None) + cfg.pop("patch_black_dark_limit_pct", None) + cfg.pop("patch_black_max_p50", None) + cfg.pop("patch_gray_min_p50", None) + cfg.pop("patch_gray_max_p50", None) + cfg.pop("patch_roi_contract", None) + cfg.pop("patch_roi_reduce_method", None) + cfg.pop("patch_roi_outlier_reject", None) + cfg.pop("patch_roi_max_p50_delta", None) + return cfg def default_profile_global(): cfg = base_ae_contract() - base = { - "x0": 0.08, - "y0": 0.08, - "x1": 0.92, - "y1": 0.92, - } + base = {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92} + guard_base = deep_clone(DEFAULT_RADIOMETRIC_CONFIG.get("global_guard_roi_pct", {"x0": 0.05, "y0": 0.05, "x1": 0.95, "y1": 0.95})) cfg.update({ "metering_mode": "global", - "spectral_control_mode": "shared", + "spectral_control_mode": DEFAULT_RADIOMETRIC_CONFIG.get("spectral_control_mode", "shared"), "global_roi_pct": base, - "global_roi_pct_by_role": { - "rgb": dict(base), - "re": dict(base), - "nir": dict(base), - }, + "global_roi_pct_by_role": {"rgb": dict(base), "re": dict(base), "nir": dict(base)}, + "global_guard_roi_pct": guard_base, }) - - return { - "radiometric_config": cfg - } + return {"radiometric_config": cfg} def make_default_patch(patch_type: str, target: float, weight: float): + # Preferimos copiar o patch correspondente do module_params.json atual. + for p in DEFAULT_RADIOMETRIC_CONFIG.get("reference_patches", []) or []: + if str(p.get("type", "")).lower() == str(patch_type).lower(): + patch = deep_clone(p) + patch.setdefault("target_value", target) + patch.setdefault("weight", weight) + patch.setdefault("roles", ROLES[:] if "ROLES" in globals() else ["rgb", "re", "nir"]) + patch.setdefault("roi_pct", {}) + patch.setdefault("roi_pct_by_role", {"rgb": {}, "re": {}, "nir": {}}) + patch.setdefault("roi_list_by_role", {"rgb": [], "re": [], "nir": []}) + return patch + return { "name": f"{patch_type}_reference", "type": patch_type, "roles": ROLES[:] if "ROLES" in globals() else ["rgb", "re", "nir"], "target_value": target, "weight": weight, - - # Compatibilidade com o controller antigo: primeira ROI ativa de RGB. "roi_pct": {}, "roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}, - - # Formato novo: lista dinâmica por câmera/role. - # Cada item: {name, enabled, roi_pct, created_at, updated_at} "roi_list_by_role": {"rgb": [], "re": [], "nir": []}, } def default_profile_patches(): - cfg = base_ae_contract() - gray_patch = make_default_patch("gray", 0.34, 1.0) - gray_patch["target_value_by_role"] = { - "rgb": 0.34, - "re": 0.24, - "nir": 0.30, - } - cfg.update({ - "metering_mode": "reference_patches", - "spectral_control_mode": "shared", - "deadband": 0.035, - - "patch_control_mode": "gray_primary", - "patch_require_order": True, - "patch_min_separation": 0.08, - - "patch_white_sat_limit_pct": 0.50, - "patch_white_p95_limit": 0.90, - - "patch_black_dark_limit_pct": 80.0, - "patch_black_max_p50": 0.20, - - "patch_gray_min_p50": 0.08, - "patch_gray_max_p50": 0.85, - - # Novo contrato: o runtime pode combinar N ROIs por cor/camera. - "patch_roi_contract": "multi_roi_by_role_v1", - "patch_roi_reduce_method": "median_valid_rois", - "patch_roi_outlier_reject": True, - "patch_roi_max_p50_delta": 0.12, - - "reference_patches": [ - make_default_patch("black", 0.06, 0.25), - gray_patch, - make_default_patch("white", 0.78, 0.7), - ], - }) - - return { - "radiometric_config": cfg - } + cfg = sanitize_radiometric_config(DEFAULT_RADIOMETRIC_CONFIG) + cfg["metering_mode"] = "reference_patches" + cfg["spectral_control_mode"] = DEFAULT_RADIOMETRIC_CONFIG.get("spectral_control_mode", "shared") + return {"radiometric_config": cfg} def get_active_profile_name(data: dict) -> str: name = str(data.get("active_profile", "global_scene_mode")) @@ -388,63 +550,46 @@ def update_root_radiometric_config(data: dict): def load_or_default_config(path: str): + """ + Carrega tanto: + 1) calibration/module_params.json completo, contrato multispec_module_params_v3; + 2) arquivo isolado do tool com perfis. + + Em ambos os casos, o root radiometric_config é mantido no mesmo contrato do runtime. + """ if path and os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) else: data = {} - data.setdefault("schema", "multispec_radiometric_config_profiles_v3") - data.setdefault("saved_at", now_str()) - data.setdefault("active_profile", "global_scene_mode") - data.setdefault("global_scene_mode", default_profile_global()) - data.setdefault("three_reference_patches_mode", default_profile_patches()) - data.setdefault("patch_normalization", { - "enabled": True, - "apply_when_metering_mode": "reference_patches", - "apply_stage": "after_fusion", - "method": "gray_scale_with_white_guard", - "space": "multispec_tensor", - "targets_by_patch_channel": { - "black": { - "R": 0.06, - "G": 0.06, - "B": 0.06, - "RE": 0.06, - "NIR": 0.06 - }, - "gray": { - "R": 0.34, - "G": 0.34, - "B": 0.34, - "RE": 0.24, - "NIR": 0.30 - }, - "white": { - "R": 0.78, - "G": 0.78, - "B": 0.78, - "RE": 0.78, - "NIR": 0.78 - } - }, - "white_guard_max": 0.92, - "white_guard_max_by_channel": { - "R": 0.92, - "G": 0.92, - "B": 0.92, - "RE": 0.88, - "NIR": 0.88 - }, - "scale_min": 0.35, - "scale_max": 2.50, - "clip_output": True, - "require_valid_gray": True, - "use_black_for_offset": False, - "save_patch_stats": True - }) + module_contract = is_module_params_contract(data) + root_cfg = data.get("radiometric_config") if isinstance(data.get("radiometric_config"), dict) else None - # Migração: se vier arquivo antigo sem contrato novo, injeta defaults novos + if module_contract: + # Não troca o schema do module_params. Apenas cria perfis internos para a UI. + data.setdefault("schema", MODULE_PARAMS_SCHEMA) + else: + data.setdefault("schema", PROFILE_SCHEMA) + + data.setdefault("saved_at", now_str()) + + # Se já existe um radiometric_config na raiz, ele é a fonte da verdade. + if root_cfg: + root_cfg = sanitize_radiometric_config(root_cfg) + active = "three_reference_patches_mode" if str(root_cfg.get("metering_mode", "")).lower() == "reference_patches" else "global_scene_mode" + data["active_profile"] = active + data.setdefault("global_scene_mode", default_profile_global()) + data.setdefault("three_reference_patches_mode", default_profile_patches()) + data[active]["radiometric_config"] = root_cfg + else: + data.setdefault("active_profile", "global_scene_mode") + data.setdefault("global_scene_mode", default_profile_global()) + data.setdefault("three_reference_patches_mode", default_profile_patches()) + + data.setdefault("patch_normalization", deep_clone(DEFAULT_PATCH_NORMALIZATION)) + + # Migração: injeta chaves novas nos dois perfis sem sobrescrever ROIs existentes. for profile_name, default_fn in ( ("global_scene_mode", default_profile_global), ("three_reference_patches_mode", default_profile_patches), @@ -457,22 +602,41 @@ def load_or_default_config(path: str): cfg = data[profile_name]["radiometric_config"] for k, v in default_cfg.items(): - cfg.setdefault(k, v) + cfg.setdefault(k, deep_clone(v)) + + if profile_name == "three_reference_patches_mode": + data[profile_name]["radiometric_config"] = sanitize_radiometric_config(cfg) update_root_radiometric_config(data) return data - def save_config(path: str, data: dict): ensure_dir(os.path.dirname(path) or ".") - data = dict(data) - data["schema"] = "multispec_radiometric_config_profiles_v3" + module_contract = is_module_params_contract(data) + + # Atualiza radiometric_config root a partir do perfil ativo, usando o contrato runtime atual. + update_root_radiometric_config(data) + data["radiometric_config"] = sanitize_radiometric_config(data.get("radiometric_config", {})) + data["patch_normalization"] = data.get("patch_normalization") or deep_clone(DEFAULT_PATCH_NORMALIZATION) data["saved_at"] = now_str() - update_root_radiometric_config(data) + if module_contract: + # Salva limpo no contrato multispec_module_params_v3, sem wrappers internos da UI. + out = dict(data) + out["schema"] = MODULE_PARAMS_SCHEMA + out.pop("active_profile", None) + out.pop("global_scene_mode", None) + out.pop("three_reference_patches_mode", None) + else: + out = dict(data) + out["schema"] = PROFILE_SCHEMA + out["active_profile"] = get_active_profile_name(data) + update_root_radiometric_config(out) + out["radiometric_config"] = sanitize_radiometric_config(out.get("radiometric_config", {})) with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + json.dump(out, f, ensure_ascii=False, indent=2) + ROLES = ["rgb", "re", "nir"] @@ -1367,57 +1531,27 @@ def main(): last_msg_t = time.time() elif k in (ord("r"), ord("R")): - data = { - "schema": "multispec_radiometric_config_profiles_v3", - "saved_at": now_str(), - "active_profile": "global_scene_mode", - "global_scene_mode": default_profile_global(), - "three_reference_patches_mode": default_profile_patches(), - "patch_normalization": { - "enabled": True, - "apply_when_metering_mode": "reference_patches", - "apply_stage": "after_fusion", - "method": "gray_scale_with_white_guard", - "space": "multispec_tensor", - "targets_by_patch_channel": { - "black": { - "R": 0.06, - "G": 0.06, - "B": 0.06, - "RE": 0.06, - "NIR": 0.06 - }, - "gray": { - "R": 0.34, - "G": 0.34, - "B": 0.34, - "RE": 0.24, - "NIR": 0.30 - }, - "white": { - "R": 0.78, - "G": 0.78, - "B": 0.78, - "RE": 0.78, - "NIR": 0.78 - } - }, - "white_guard_max": 0.92, - "white_guard_max_by_channel": { - "R": 0.92, - "G": 0.92, - "B": 0.92, - "RE": 0.88, - "NIR": 0.88 - }, - "scale_min": 0.35, - "scale_max": 2.50, - "clip_output": True, - "require_valid_gray": True, - "use_black_for_offset": False, - "save_patch_stats": True + # Restaura somente a parte radiométrica, preservando o restante do module_params quando existir. + module_contract = is_module_params_contract(data) + preserved = dict(data) if module_contract else {} + + if module_contract: + preserved["radiometric_config"] = sanitize_radiometric_config(DEFAULT_RADIOMETRIC_CONFIG) + preserved["patch_normalization"] = deep_clone(DEFAULT_PATCH_NORMALIZATION) + preserved["active_profile"] = "three_reference_patches_mode" + preserved["global_scene_mode"] = default_profile_global() + preserved["three_reference_patches_mode"] = default_profile_patches() + data = preserved + else: + data = { + "schema": PROFILE_SCHEMA, + "saved_at": now_str(), + "active_profile": "three_reference_patches_mode", + "global_scene_mode": default_profile_global(), + "three_reference_patches_mode": default_profile_patches(), + "patch_normalization": deep_clone(DEFAULT_PATCH_NORMALIZATION), } - } + update_root_radiometric_config(data) last_msg = "Defaults restaurados" last_msg_t = time.time()