ajustado global saturation guard e trocado ordem das cameras
This commit is contained in:
parent
7c5c8b54ea
commit
1a140ca60f
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue