ajustes na calibragem radiometrica, multi_roi

This commit is contained in:
Diego Freitas 2026-05-07 14:34:41 -03:00
parent a028c12a3a
commit b3f7b42f3c
3 changed files with 1684 additions and 268 deletions

View File

@ -82,6 +82,15 @@ class RadiometricController:
self.reference_patches = cfg.get("reference_patches", []) or []
self.patch_aggregation = str(cfg.get("patch_aggregation", "weighted_mean")).lower()
# Novo contrato de ROIs dinâmicas por patch/cor/câmera.
# Compatível com o formato antigo: roi_pct_by_role e roi_pct continuam
# servindo como fallback quando a lista dinâmica não existir.
self.patch_roi_contract = str(cfg.get("patch_roi_contract", "legacy_single_roi")).lower()
self.patch_roi_reduce_method = str(cfg.get("patch_roi_reduce_method", "median_valid_rois")).lower()
self.patch_roi_outlier_reject = bool(cfg.get("patch_roi_outlier_reject", True))
self.patch_roi_max_p50_delta = float(cfg.get("patch_roi_max_p50_delta", 0.12))
self.patch_roi_min_valid_rois = int(cfg.get("patch_roi_min_valid_rois", 1))
self.patch_control_mode = str(cfg.get("patch_control_mode", "gray_primary")).lower()
self.patch_require_order = bool(cfg.get("patch_require_order", True))
@ -231,20 +240,82 @@ class RadiometricController:
return self._safe_roi_pct(self.global_roi_pct)
def _get_patch_roi_pct_for_role(self, patch: dict, role: str):
role = self._normalize_role(role)
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.
Contrato novo:
patch["roi_list_by_role"][role] = [
{"name": "gray_rgb_01", "enabled": True, "roi_pct": {...}},
...
]
Fallbacks legados:
patch["roi_pct_by_role"][role]
patch["roi_pct"]
"""
role = self._normalize_role(role)
items: list[dict] = []
list_by_role = patch.get("roi_list_by_role", {})
raw_items = []
if isinstance(list_by_role, dict):
role_items = list_by_role.get(role)
if isinstance(role_items, list):
raw_items = role_items
for idx, item in enumerate(raw_items, start=1):
if not isinstance(item, dict):
continue
if item.get("enabled", True) is False:
continue
roi = item.get("roi_pct")
if not isinstance(roi, dict) or not roi:
continue
items.append({
"name": str(item.get("name") or f"{patch.get('type', 'patch')}_{role}_{idx:02d}"),
"enabled": True,
"roi_pct": self._safe_roi_pct(roi),
"roi_source": "roi_list_by_role",
"index": int(idx - 1),
})
if items:
return items
# Fallback 1: contrato antigo por câmera.
by_role = patch.get("roi_pct_by_role", {})
if isinstance(by_role, dict):
roi = by_role.get(role)
if isinstance(roi, dict) and roi:
return self._safe_roi_pct(roi), "roi_pct_by_role"
return [{
"name": f"{patch.get('type', 'patch')}_{role}_legacy_by_role",
"enabled": True,
"roi_pct": self._safe_roi_pct(roi),
"roi_source": "roi_pct_by_role",
"index": 0,
}]
# Fallback 2: contrato antigo global do patch.
legacy = patch.get("roi_pct")
if isinstance(legacy, dict) and legacy:
return self._safe_roi_pct(legacy), "roi_pct"
return [{
"name": f"{patch.get('type', 'patch')}_{role}_legacy",
"enabled": True,
"roi_pct": self._safe_roi_pct(legacy),
"roi_source": "roi_pct",
"index": 0,
}]
return []
def _get_patch_roi_pct_for_role(self, patch: dict, role: str):
"""Compatibilidade para código legado: retorna a primeira ROI ativa."""
items = self._get_patch_roi_items_for_role(patch, role)
if not items:
return None, "missing"
item = items[0]
return item["roi_pct"], item.get("roi_source", "roi_list_by_role")
def get_patch_target_for_role(self, patch, role, fallback):
by_role = patch.get("target_value_by_role", {})
@ -516,8 +587,21 @@ class RadiometricController:
if role not in roles and "all" not in roles:
continue
roi_pct, roi_source = self._get_patch_roi_pct_for_role(patch, role)
roi_items = self._get_patch_roi_items_for_role(patch, role)
if not roi_items:
continue
target = self.get_patch_target_for_role(
patch=patch,
role=role,
fallback=self.target_value,
)
if target is not None:
target = float(target)
roi_results = []
for roi_item in roi_items:
roi_pct = roi_item.get("roi_pct")
if not isinstance(roi_pct, dict):
continue
@ -530,24 +614,44 @@ class RadiometricController:
arr = self._crop_array(img_gray, roi)
stats = self.compute_stats(arr)
target = self.get_patch_target_for_role(
patch=patch,
role=role,
fallback=self.target_value,
roi_results.append({
"name": roi_item.get("name"),
"enabled": bool(roi_item.get("enabled", True)),
"role": role,
"roi": list(roi),
"roi_pct": {"x0": x0, "y0": y0, "x1": x1, "y1": y1},
"roi_source": roi_item.get("roi_source", "roi_list_by_role"),
"index": int(roi_item.get("index", len(roi_results))),
"stats": stats,
})
if not roi_results:
continue
patch_stats, roi_quality = self.aggregate_roi_metrics(
roi_results=roi_results,
patch_type=str(patch.get("type", "reference")).lower(),
)
first_roi = next(
(r for r in roi_results if r.get("stats", {}).get("valid")),
roi_results[0],
)
if target is not None:
target = float(target)
patch_results.append({
"name": patch.get("name", f"patch_{len(patch_results) + 1}"),
"type": patch.get("type", "reference"),
"role": role,
"roi": list(roi),
"roi_pct": {"x0": x0, "y0": y0, "x1": x1, "y1": y1},
"roi_source": roi_source,
"roi": first_roi.get("roi", []),
"roi_pct": first_roi.get("roi_pct", {}),
"roi_source": "roi_list_by_role" if len(roi_results) > 1 else first_roi.get("roi_source", "roi_list_by_role"),
"roi_count": int(len(roi_results)),
"roi_valid_count": int(roi_quality.get("valid_count", 0)),
"roi_results": roi_results,
"roi_quality": roi_quality,
"weight": float(patch.get("weight", 1.0)),
"target_value": target,
"stats": stats,
"stats": patch_stats,
})
if not patch_results:
@ -561,6 +665,120 @@ class RadiometricController:
return metrics
def aggregate_roi_metrics(self, roi_results: list[dict], patch_type: str = "reference") -> tuple[dict, dict]:
"""
Agrega várias ROIs da mesma cor/câmera em uma métrica única.
O controle usa uma estatística robusta, normalmente mediana dos p50.
As guardas de proteção usam p95/saturação máximos para não ignorar
uma ROI branca estourada, mesmo quando outra ROI está boa.
"""
valid = [r for r in roi_results if r.get("stats", {}).get("valid")]
warnings = []
if not valid:
return {
"valid": False,
"pixels": 0,
"mean": 0.0,
"std": 0.0,
"p05": 0.0,
"p50": 0.0,
"p95": 0.0,
"sat_pct": 0.0,
"dark_pct": 0.0,
}, {
"valid": False,
"warnings": ["no_valid_roi"],
"roi_count": len(roi_results),
"valid_count": 0,
"used_count": 0,
"rejected_count": 0,
"p50_values": [],
}
p50_values = np.array([float(r["stats"].get("p50", 0.0)) for r in valid], dtype=np.float32)
median_p50 = float(np.median(p50_values))
spread_p50 = float(np.max(p50_values) - np.min(p50_values)) if len(p50_values) > 1 else 0.0
used = valid
rejected = []
if self.patch_roi_outlier_reject and len(valid) >= 3:
kept = []
for r in valid:
p50 = float(r["stats"].get("p50", 0.0))
if abs(p50 - median_p50) <= self.patch_roi_max_p50_delta:
kept.append(r)
else:
rejected.append(r)
if kept:
used = kept
if rejected:
warnings.append(f"roi_outliers_rejected:{len(rejected)}")
elif self.patch_roi_outlier_reject and len(valid) == 2 and spread_p50 > self.patch_roi_max_p50_delta:
warnings.append(f"roi_p50_spread_high:{spread_p50:.3f}")
if len(used) < self.patch_roi_min_valid_rois:
warnings.append(f"valid_roi_count_low:{len(used)}<{self.patch_roi_min_valid_rois}")
def arr(key: str) -> np.ndarray:
return np.array([float(r["stats"].get(key, 0.0)) for r in used], dtype=np.float32)
means = arr("mean")
stds = arr("std")
p05s = arr("p05")
p50s = arr("p50")
p95s = arr("p95")
sats = arr("sat_pct")
darks = arr("dark_pct")
pixels = int(sum(int(r["stats"].get("pixels", 0)) for r in used))
reduce_method = self.patch_roi_reduce_method
if reduce_method not in ("median_valid_rois", "mean_valid_rois"):
reduce_method = "median_valid_rois"
reducer = np.mean if reduce_method == "mean_valid_rois" else np.median
# Para brilho/controle usamos redução robusta; para guardas de risco,
# mantemos máximos de p95/saturação. Assim um cartão branco saturado
# ainda força redução de exposição.
stats = {
"valid": True,
"pixels": pixels,
"mean": float(reducer(means)),
"std": float(reducer(stds)),
"p05": float(reducer(p05s)),
"p50": float(reducer(p50s)),
"p95": float(np.max(p95s)),
"sat_pct": float(np.max(sats)),
"dark_pct": float(reducer(darks)),
"roi_reduce_method": reduce_method,
"roi_count": int(len(roi_results)),
"roi_valid_count": int(len(valid)),
"roi_used_count": int(len(used)),
"roi_rejected_count": int(len(rejected)),
"roi_p50_values": [float(v) for v in p50_values.tolist()],
"roi_p50_spread": spread_p50,
}
quality = {
"valid": bool(len(used) >= self.patch_roi_min_valid_rois),
"warnings": warnings,
"roi_count": int(len(roi_results)),
"valid_count": int(len(valid)),
"used_count": int(len(used)),
"rejected_count": int(len(rejected)),
"p50_values": [float(v) for v in p50_values.tolist()],
"p50_spread": spread_p50,
"reduce_method": reduce_method,
"used_names": [str(r.get("name")) for r in used],
"rejected_names": [str(r.get("name")) for r in rejected],
"patch_type": str(patch_type).lower(),
}
return stats, quality
def aggregate_patch_metrics(self, patch_results: list[dict]) -> dict:
valid = [p for p in patch_results if p["stats"].get("valid")]
@ -588,6 +806,14 @@ class RadiometricController:
white = self._find_patch_result(valid, "white")
warnings = []
roi_quality_by_type = {}
for p in valid:
ptype = str(p.get("type", "reference")).lower()
rq = p.get("roi_quality", {}) or {}
roi_quality_by_type[ptype] = rq
for warn in rq.get("warnings", []) or []:
warnings.append(f"{ptype}_{warn}")
# Stats gerais de proteção.
p95s = np.array([p["stats"]["p95"] for p in valid], dtype=np.float32)
@ -741,6 +967,7 @@ class RadiometricController:
"black_target": black_target,
"gray_target": gray_target,
"white_target": white_target,
"roi_quality_by_type": roi_quality_by_type,
},
}
@ -1187,6 +1414,7 @@ class RadiometricController:
f" [PATCH] role={p.get('role', role)} "
f"type={p.get('type')} "
f"roi_source={p.get('roi_source')} "
f"roi_count={p.get('roi_valid_count', p.get('roi_count', 1))}/{p.get('roi_count', 1)} "
f"roi_pct={p.get('roi_pct')} "
f"p50={st.get('p50', 0):.3f} "
f"p95={st.get('p95', 0):.3f} "

View File

@ -302,6 +302,24 @@ def default_profile_global():
}
def make_default_patch(patch_type: str, target: float, weight: float):
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()
cfg.update({
@ -309,7 +327,6 @@ def default_profile_patches():
"spectral_control_mode": "shared",
"deadband": 0.035,
"metering_mode": "reference_patches",
"patch_control_mode": "gray_primary",
"patch_require_order": True,
"patch_min_separation": 0.08,
@ -323,34 +340,16 @@ def default_profile_patches():
"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": [
{
"name": "black_reference",
"type": "black",
"roles": ["rgb", "re", "nir"],
"target_value": 0.06,
"weight": 0.25,
"roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
},
{
"name": "gray_reference",
"type": "gray",
"roles": ["rgb", "re", "nir"],
"target_value": 0.40,
"weight": 1.0,
"roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
},
{
"name": "white_reference",
"type": "white",
"roles": ["rgb", "re", "nir"],
"target_value": 0.78,
"weight": 0.7,
"roi_pct": {},
"roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}}
}
make_default_patch("black", 0.06, 0.25),
make_default_patch("gray", 0.40, 1.0),
make_default_patch("white", 0.78, 0.7),
],
})
@ -358,7 +357,6 @@ def default_profile_patches():
"radiometric_config": cfg
}
def get_active_profile_name(data: dict) -> str:
name = str(data.get("active_profile", "global_scene_mode"))
if name not in ("global_scene_mode", "three_reference_patches_mode"):
@ -514,36 +512,122 @@ def get_patches(data: dict):
)
def ensure_patch_roi_by_role(patch: dict):
legacy = patch.get("roi_pct", {})
def make_roi_entry(roi_pct: dict, name: str | None = None, enabled: bool = True) -> dict:
return {
"name": name or "roi_01",
"enabled": bool(enabled),
"roi_pct": clone_roi(roi_pct),
"created_at": now_str(),
"updated_at": now_str(),
}
def normalize_roi_entry(entry, idx: int) -> dict | None:
"""Aceita formatos antigos e novos, devolvendo sempre um item padrão."""
if not entry:
return None
if isinstance(entry, dict) and "roi_pct" in entry:
roi = entry.get("roi_pct") or {}
if not roi:
return None
out = dict(entry)
out["name"] = str(out.get("name") or f"roi_{idx + 1:02d}")
out["enabled"] = bool(out.get("enabled", True))
out["roi_pct"] = clone_roi(roi)
out.setdefault("created_at", now_str())
out["updated_at"] = str(out.get("updated_at") or now_str())
return out
if isinstance(entry, dict) and all(k in entry for k in ("x0", "y0", "x1", "y1")):
return make_roi_entry(entry, name=f"roi_{idx + 1:02d}", enabled=True)
return None
def sync_patch_legacy_roi_fields(patch: dict):
"""Mantém roi_pct e roi_pct_by_role compatíveis com scripts antigos."""
roi_lists = patch.setdefault("roi_list_by_role", {})
by_role = patch.setdefault("roi_pct_by_role", {})
for role in ROLES:
if role not in by_role or not by_role[role]:
by_role[role] = clone_roi(legacy) if legacy else {}
entries = roi_lists.setdefault(role, [])
first_active = next((e.get("roi_pct") for e in entries if e.get("enabled", True) and e.get("roi_pct")), {})
by_role[role] = clone_roi(first_active) if first_active else {}
return by_role
patch["roi_pct"] = by_role.get("rgb", {}) or {}
def ensure_patch_roi_lists_by_role(patch: dict):
"""
Migra o formato antigo:
roi_pct_by_role[role] = {x0,y0,x1,y1}
para o formato novo:
roi_list_by_role[role] = [{name, enabled, roi_pct, ...}, ...]
Também aceita, por tolerância, caso alguém tenha salvo uma lista dentro de roi_pct_by_role.
"""
legacy_global = patch.get("roi_pct", {}) or {}
legacy_by_role = patch.get("roi_pct_by_role", {}) or {}
roi_lists = patch.setdefault("roi_list_by_role", {})
for role in ROLES:
raw_list = roi_lists.get(role, [])
# Caso raro: formato novo foi salvo diretamente em roi_pct_by_role.
if not raw_list and isinstance(legacy_by_role.get(role), list):
raw_list = legacy_by_role.get(role) or []
normalized = []
if isinstance(raw_list, list):
for idx, item in enumerate(raw_list):
entry = normalize_roi_entry(item, idx)
if entry is not None:
normalized.append(entry)
elif isinstance(raw_list, dict) and raw_list:
entry = normalize_roi_entry(raw_list, 0)
if entry is not None:
normalized.append(entry)
# Migração do formato antigo, se ainda não houver lista.
if not normalized:
old_roi = legacy_by_role.get(role, {}) if isinstance(legacy_by_role, dict) else {}
if not old_roi and legacy_global:
old_roi = legacy_global
if isinstance(old_roi, dict) and old_roi:
normalized.append(make_roi_entry(old_roi, name=f"{role}_legacy_01", enabled=True))
# Garante nomes estáveis e únicos.
seen = set()
for idx, entry in enumerate(normalized):
name = str(entry.get("name") or f"roi_{idx + 1:02d}")
if name in seen:
name = f"{name}_{idx + 1:02d}"
seen.add(name)
entry["name"] = name
roi_lists[role] = normalized
sync_patch_legacy_roi_fields(patch)
return roi_lists
# Alias antigo mantido para não quebrar chamadas existentes.
def ensure_patch_roi_by_role(patch: dict):
ensure_patch_roi_lists_by_role(patch)
return patch.setdefault("roi_pct_by_role", {})
def get_patch_by_type(data: dict, patch_type: str):
patch_type = str(patch_type).lower()
for p in get_patches(data):
if str(p.get("type", "")).lower() == patch_type:
ensure_patch_roi_lists_by_role(p)
return p
return None
def get_patch_roi_for_role(data: dict, patch_type: str, role: str):
role = normalize_role(role)
patch = get_patch_by_type(data, patch_type)
if not patch:
return {}
by_role = ensure_patch_roi_by_role(patch)
return by_role.get(role, {}) or patch.get("roi_pct", {}) or {}
def set_patch_roi_for_role(data: dict, patch_type: str, role: str, roi_pct: dict):
def ensure_patch_exists(data: dict, patch_type: str):
data.setdefault("three_reference_patches_mode", default_profile_patches())
cfg = data["three_reference_patches_mode"].setdefault(
"radiometric_config",
@ -556,37 +640,114 @@ def set_patch_roi_for_role(data: dict, patch_type: str, role: str, roi_pct: dict
)
patch_type = str(patch_type).lower()
role = normalize_role(role)
target = {"black": 0.06, "gray": 0.40, "white": 0.78}.get(patch_type, 0.40)
weight = {"black": 0.25, "gray": 1.0, "white": 0.7}.get(patch_type, 1.0)
patch = None
for p in patches:
if str(p.get("type", "")).lower() == patch_type:
patch = p
break
ensure_patch_roi_lists_by_role(p)
return p
if patch is None:
patch = {
"name": f"{patch_type}_reference",
"type": patch_type,
"roles": ROLES[:],
"target_value": target,
"weight": weight,
"roi_pct": {},
"roi_pct_by_role": {},
}
patch = make_default_patch(patch_type, target, weight)
patches.append(patch)
ensure_patch_roi_lists_by_role(patch)
return patch
by_role = ensure_patch_roi_by_role(patch)
by_role[role] = roi_pct
# Compatibilidade com formato antigo.
# Mantém roi_pct como RGB, para scripts antigos não quebrarem.
patch["roi_pct"] = by_role.get("rgb", roi_pct)
def get_patch_roi_entries_for_role(data: dict, patch_type: str, role: str, enabled_only: bool = False):
role = normalize_role(role)
patch = get_patch_by_type(data, patch_type)
if not patch:
return []
roi_lists = ensure_patch_roi_lists_by_role(patch)
entries = list(roi_lists.get(role, []) or [])
if enabled_only:
entries = [e for e in entries if e.get("enabled", True) and e.get("roi_pct")]
return entries
def get_patch_roi_for_role(data: dict, patch_type: str, role: str):
"""Compatibilidade: retorna a primeira ROI ativa da lista."""
entries = get_patch_roi_entries_for_role(data, patch_type, role, enabled_only=True)
if entries:
return entries[0].get("roi_pct", {}) or {}
patch = get_patch_by_type(data, patch_type)
if not patch:
return {}
return patch.get("roi_pct_by_role", {}).get(normalize_role(role), {}) or patch.get("roi_pct", {}) or {}
def get_patch_roi_entry(data: dict, patch_type: str, role: str, index: int):
entries = get_patch_roi_entries_for_role(data, patch_type, role, enabled_only=False)
if not entries:
return None, -1
index = clamp(int(index), 0, len(entries) - 1)
return entries[index], index
def set_patch_roi_for_role(data: dict, patch_type: str, role: str, roi_pct: dict, index: int | None = None, append: bool = False):
patch = ensure_patch_exists(data, patch_type)
role = normalize_role(role)
roi_lists = ensure_patch_roi_lists_by_role(patch)
entries = roi_lists.setdefault(role, [])
if append or index is None or index >= len(entries) or index < 0:
entry = make_roi_entry(
roi_pct,
name=f"{patch_type}_{role}_{len(entries) + 1:02d}",
enabled=True,
)
entries.append(entry)
saved_index = len(entries) - 1
else:
saved_index = int(index)
old = entries[saved_index]
old["roi_pct"] = clone_roi(roi_pct)
old["enabled"] = bool(old.get("enabled", True))
old["updated_at"] = now_str()
sync_patch_legacy_roi_fields(patch)
return saved_index
def delete_patch_roi_for_role(data: dict, patch_type: str, role: str, index: int):
patch = get_patch_by_type(data, patch_type)
if not patch:
return False, 0
role = normalize_role(role)
roi_lists = ensure_patch_roi_lists_by_role(patch)
entries = roi_lists.setdefault(role, [])
if not entries:
return False, 0
index = clamp(int(index), 0, len(entries) - 1)
entries.pop(index)
sync_patch_legacy_roi_fields(patch)
return True, len(entries)
def toggle_patch_roi_enabled_for_role(data: dict, patch_type: str, role: str, index: int):
entry, idx = get_patch_roi_entry(data, patch_type, role, index)
if entry is None:
return False, False
entry["enabled"] = not bool(entry.get("enabled", True))
entry["updated_at"] = now_str()
patch = get_patch_by_type(data, patch_type)
if patch:
sync_patch_legacy_roi_fields(patch)
return True, bool(entry["enabled"])
def add_empty_patch_roi_slot(data: dict, patch_type: str, role: str):
# Usa uma ROI pequena central como placeholder, para o usuário arrastar por cima depois.
return set_patch_roi_for_role(
data,
patch_type,
role,
{"x0": 0.45, "y0": 0.45, "x1": 0.55, "y1": 0.55},
append=True,
)
def set_shared_mode(data: dict, shared: bool):
for profile in ("global_scene_mode", "three_reference_patches_mode"):
data.setdefault(profile, default_profile_global() if profile == "global_scene_mode" else default_profile_patches())
@ -624,7 +785,7 @@ def draw_roi_on_panel(panel, roi_pct, label, color, thickness=2):
0.55, color, 1, cv2.LINE_AA)
def draw_all_rois(panel, data, selected_target, mode, panel_role, edit_role):
def draw_all_rois(panel, data, selected_target, mode, panel_role, edit_role, selected_roi_index=0):
panel_role = normalize_role(panel_role)
edit_role = normalize_role(edit_role)
@ -640,11 +801,20 @@ def draw_all_rois(panel, data, selected_target, mode, panel_role, edit_role):
for p in get_patches(data):
typ = str(p.get("type", "")).lower()
color = PATCH_COLORS.get(typ, (0, 255, 255))
roi = get_patch_roi_for_role(data, typ, panel_role)
entries = get_patch_roi_entries_for_role(data, typ, panel_role, enabled_only=False)
label = f"{typ.upper()}/{panel_role.upper()}"
selected = typ == selected_target and is_edit_panel
thickness = 3 if selected else 2
for idx, entry in enumerate(entries):
roi = entry.get("roi_pct", {})
if not roi:
continue
enabled = bool(entry.get("enabled", True))
selected = typ == selected_target and is_edit_panel and idx == selected_roi_index
thickness = 3 if selected else 1 if not enabled else 2
label = f"{typ.upper()}/{panel_role.upper()}#{idx + 1}"
if not enabled:
label += " OFF"
draw_roi_on_panel(panel, roi, label, color, thickness)
@ -655,6 +825,7 @@ def build_board(
mode,
selected_target,
edit_role,
selected_roi_index,
drag_rect_local,
drag_role,
panel_rects,
@ -717,9 +888,9 @@ def build_board(
nir01_show = resize_if_needed(nir01, (base_h, base_w)) if nir01 is not None else None
nir_panel = gray_to_bgr_u8(nir01_show) if nir01_show is not None else np.zeros_like(rgb_panel)
draw_all_rois(rgb_panel, data, selected_target, mode, "rgb", edit_role)
draw_all_rois(re_panel, data, selected_target, mode, "re", edit_role)
draw_all_rois(nir_panel, data, selected_target, mode, "nir", edit_role)
draw_all_rois(rgb_panel, data, selected_target, mode, "rgb", edit_role, selected_roi_index)
draw_all_rois(re_panel, data, selected_target, mode, "re", edit_role, selected_roi_index)
draw_all_rois(nir_panel, data, selected_target, mode, "nir", edit_role, selected_roi_index)
if drag_rect_local is not None:
x0, y0, x1, y1 = drag_rect_local
@ -760,8 +931,8 @@ def build_board(
board = np.vstack([top, bottom])
x0, y0, x1, y1 = panel_rects["data"]
lines = build_data_lines(decoded, data, mode, selected_target, edit_role, base_w, base_h)
overlay_hud(board, lines, x=x0 + 16, y=y0 + 28, font_scale=0.53, line_step=21)
lines = build_data_lines(decoded, data, mode, selected_target, edit_role, selected_roi_index, base_w, base_h)
overlay_hud(board, lines, x=x0 + 16, y=y0 + 28, font_scale=0.50, line_step=20)
if preview_scale != 1.0:
board = cv2.resize(
@ -773,19 +944,19 @@ def build_board(
return board
def build_data_lines(decoded, data, mode, selected_target, edit_role, base_w, base_h):
def build_data_lines(decoded, data, mode, selected_target, edit_role, selected_roi_index, base_w, base_h):
edit_role = normalize_role(edit_role)
active_profile = get_active_profile_name(data)
active_cfg = get_active_radiometric_config(data)
lines = [
"RADIOMETRIC CONFIG TOOL",
f"modo_edicao={mode.upper()} | camera_editada={edit_role.upper()} | active={active_profile}",
f"modo={mode.upper()} | camera={edit_role.upper()} | active={active_profile}",
f"spectral={active_cfg.get('spectral_control_mode')} | strategy={active_cfg.get('control_strategy')}",
f"interval={active_cfg.get('interval_s')}s | ratio_alpha={active_cfg.get('ratio_alpha')} | ready={active_cfg.get('ready_required_cycles')}",
f"roi_contract={active_cfg.get('patch_roi_contract', 'legacy_single_roi')}",
"",
"Arraste no painel da camera editada para definir a ROI.",
"Cada camera salva sua propria ROI: RGB / RE / NIR.",
"Arraste no painel da camera editada para definir/atualizar ROI.",
"PATCHES agora suportam N ROIs por cor e por camera.",
"",
]
@ -801,39 +972,73 @@ def build_data_lines(decoded, data, mode, selected_target, edit_role, base_w, ba
lines.extend(stats_lines_for_mode(data, decoded, mode="global", patch_type=None))
else:
entries_edit = get_patch_roi_entries_for_role(data, selected_target, edit_role, enabled_only=False)
n_edit = len(entries_edit)
selected_roi_index = clamp(selected_roi_index, 0, max(0, n_edit - 1)) if n_edit else 0
lines.append(f"PATCH selecionado: {selected_target.upper()}")
lines.append("ROIs do patch selecionado:")
lines.append(f"ROI selecionada {edit_role.upper()}: #{selected_roi_index + 1 if n_edit else 0}/{n_edit}")
lines.append("Contagem de ROIs por camera:")
for role in ROLES:
roi_pct = get_patch_roi_for_role(data, selected_target, role)
entries = get_patch_roi_entries_for_role(data, selected_target, role, enabled_only=False)
enabled = sum(1 for e in entries if e.get("enabled", True))
marker = "*" if role == edit_role else " "
lines.append(f"{marker} {role.upper()}: roi={roi_pct}")
lines.append(f"{marker} {role.upper()}: {enabled}/{len(entries)} ativas")
if n_edit:
entry = entries_edit[selected_roi_index]
lines.append(f"ROI atual: {entry.get('name')} | enabled={entry.get('enabled', True)}")
lines.append(f"rect={entry.get('roi_pct')}")
else:
lines.append("ROI atual: nenhuma. Arraste para criar a primeira.")
sel_patch = get_patch_by_type(data, selected_target)
if sel_patch:
lines.append("")
lines.append(
f"target={float(sel_patch.get('target_value', 0.0)):.2f} "
f"weight={float(sel_patch.get('weight', 1.0)):.2f}"
)
lines.append("")
lines.append(f"Stats do patch {selected_target.upper()}:")
lines.append(f"Stats robustas {selected_target.upper()}:")
lines.extend(stats_lines_for_mode(data, decoded, mode="patches", patch_type=selected_target))
lines.extend([
"",
"M = alterna GLOBAL / 3 PATCHES",
"C = alterna camera RGB / RE / NIR",
"V = alterna preview bonito / bruto",
"1/2/3 = BLACK / GRAY / WHITE",
"S = alterna spectral shared/independent",
"P ou SPACE = salva JSON",
"R = restaura defaults | Q/Esc = sai",
"M = GLOBAL/PATCHES | C = camera | V = preview bonito/bruto",
"1/2/3 = BLACK/GRAY/WHITE | S = shared/independent",
"N = nova ROI | [ ] = troca ROI | D = apaga ROI | T = liga/desliga ROI",
"P ou SPACE = salva JSON | R = defaults | Q/Esc = sai",
])
return lines
def aggregate_roi_stats(stats_list: list[dict]) -> dict:
valid = [s for s in stats_list if s.get("valid")]
if not valid:
return {"valid": False, "count": 0}
p50 = np.array([s["p50"] for s in valid], dtype=np.float32)
p95 = np.array([s["p95"] for s in valid], dtype=np.float32)
sat = np.array([s["sat_pct"] for s in valid], dtype=np.float32)
dark = np.array([s["dark_pct"] for s in valid], dtype=np.float32)
std = np.array([s["std"] for s in valid], dtype=np.float32)
return {
"valid": True,
"count": len(valid),
"p50": float(np.median(p50)),
"p95": float(np.median(p95)),
"sat_pct": float(np.median(sat)),
"dark_pct": float(np.median(dark)),
"std": float(np.median(std)),
"p50_spread": float(p50.max() - p50.min()) if len(p50) > 1 else 0.0,
"p50_min": float(p50.min()),
"p50_max": float(p50.max()),
}
def stats_lines_for_mode(data, decoded, mode: str, patch_type: str | None = None):
lines = []
@ -843,27 +1048,55 @@ def stats_lines_for_mode(data, decoded, mode: str, patch_type: str | None = None
lines.append(f"{role.upper()}: sem frame")
continue
h, w = img.shape[:2]
if mode == "global":
roi_pct = get_global_roi_for_role(data, role)
else:
roi_pct = get_patch_roi_for_role(data, patch_type, role)
if not roi_pct:
lines.append(f"{role.upper()}: sem ROI")
continue
h, w = img.shape[:2]
roi = pct_to_px(roi_pct, w, h)
st = compute_stats(img, roi)
lines.append(
f"{role.upper()}: p50={st['p50']:.3f} p95={st['p95']:.3f} "
f"sat={st['sat_pct']:.2f}% dark={st['dark_pct']:.1f}%"
)
continue
entries = get_patch_roi_entries_for_role(data, patch_type, role, enabled_only=True)
if not entries:
lines.append(f"{role.upper()}: sem ROI ativa")
continue
stats = []
p50_each = []
for entry in entries:
roi_pct = entry.get("roi_pct", {})
if not roi_pct:
continue
roi = pct_to_px(roi_pct, w, h)
st = compute_stats(img, roi)
stats.append(st)
if st.get("valid"):
p50_each.append(st["p50"])
ag = aggregate_roi_stats(stats)
if not ag.get("valid"):
lines.append(f"{role.upper()}: ROIs invalidas")
continue
mini = ",".join(f"{v:.2f}" for v in p50_each[:4])
if len(p50_each) > 4:
mini += ",..."
lines.append(
f"{role.upper()}: n={ag['count']} p50_med={ag['p50']:.3f} "
f"spread={ag['p50_spread']:.3f} sat_med={ag['sat_pct']:.2f}%"
)
lines.append(f" p50_each=[{mini}]")
return lines
def rect_inside(rect, x, y):
if rect is None:
return False
@ -904,6 +1137,7 @@ def main():
mode = "global"
selected_target = "gray"
selected_roi_index = 0
edit_role = "rgb"
drag_role = None
beauty_preview = True
@ -923,7 +1157,7 @@ def main():
window_name = "Radiometric Config Tool"
def on_mouse(event, x, y, flags, param):
nonlocal dragging, drag_start, drag_rect_local, last_msg, last_msg_t, data, drag_role
nonlocal dragging, drag_start, drag_rect_local, last_msg, last_msg_t, data, drag_role, selected_roi_index
# Coordenadas vêm depois do preview_scale. Reescala para board real.
if args.preview_scale != 1.0:
@ -970,8 +1204,13 @@ def main():
set_global_roi_for_role(data, role_to_save, roi_pct)
last_msg = f"GLOBAL ROI {role_to_save.upper()} atualizada: {roi_pct}"
else:
set_patch_roi_for_role(data, selected_target, role_to_save, roi_pct)
last_msg = f"{selected_target.upper()} ROI {role_to_save.upper()} atualizada: {roi_pct}"
selected_roi_index = set_patch_roi_for_role(
data, selected_target, role_to_save, roi_pct, index=selected_roi_index, append=False
)
last_msg = (
f"{selected_target.upper()} ROI {role_to_save.upper()} "
f"#{selected_roi_index + 1} atualizada: {roi_pct}"
)
drag_role = None
last_msg_t = time.time()
@ -1018,6 +1257,7 @@ def main():
mode=mode,
selected_target=selected_target,
edit_role=edit_role,
selected_roi_index=selected_roi_index,
drag_rect_local=drag_rect_local,
drag_role=drag_role,
panel_rects=panel_rects,
@ -1052,12 +1292,14 @@ def main():
update_root_radiometric_config(data)
selected_roi_index = 0
last_msg = f"Modo -> {mode} | active_profile={data['active_profile']}"
last_msg_t = time.time()
elif k == ord("1"):
mode = "patches"
selected_target = "black"
selected_roi_index = 0
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: BLACK"
@ -1066,6 +1308,7 @@ def main():
elif k == ord("2"):
mode = "patches"
selected_target = "gray"
selected_roi_index = 0
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: GRAY"
@ -1074,6 +1317,7 @@ def main():
elif k == ord("3"):
mode = "patches"
selected_target = "white"
selected_roi_index = 0
set_active_profile_name(data, "three_reference_patches_mode")
update_root_radiometric_config(data)
last_msg = "Selecionado: WHITE"
@ -1131,9 +1375,44 @@ def main():
elif k in (ord("c"), ord("C")):
idx = ROLES.index(edit_role) if edit_role in ROLES else 0
edit_role = ROLES[(idx + 1) % len(ROLES)]
selected_roi_index = 0
last_msg = f"Camera editada -> {edit_role.upper()}"
last_msg_t = time.time()
elif mode == "patches" and k in (ord("n"), ord("N")):
selected_roi_index = add_empty_patch_roi_slot(data, selected_target, edit_role)
last_msg = f"Nova ROI {selected_target.upper()}/{edit_role.upper()} #{selected_roi_index + 1}. Arraste para posicionar."
last_msg_t = time.time()
elif mode == "patches" and k in (ord("["), ord(",")):
entries = get_patch_roi_entries_for_role(data, selected_target, edit_role, enabled_only=False)
if entries:
selected_roi_index = (selected_roi_index - 1) % len(entries)
last_msg = f"ROI selecionada -> #{selected_roi_index + 1}/{len(entries)}"
else:
last_msg = "Nenhuma ROI para selecionar"
last_msg_t = time.time()
elif mode == "patches" and k in (ord("]"), ord(".")):
entries = get_patch_roi_entries_for_role(data, selected_target, edit_role, enabled_only=False)
if entries:
selected_roi_index = (selected_roi_index + 1) % len(entries)
last_msg = f"ROI selecionada -> #{selected_roi_index + 1}/{len(entries)}"
else:
last_msg = "Nenhuma ROI para selecionar"
last_msg_t = time.time()
elif mode == "patches" and k in (ord("d"), ord("D")):
ok, n_left = delete_patch_roi_for_role(data, selected_target, edit_role, selected_roi_index)
selected_roi_index = clamp(selected_roi_index, 0, max(0, n_left - 1))
last_msg = f"ROI apagada. Restam {n_left}." if ok else "Nenhuma ROI para apagar"
last_msg_t = time.time()
elif mode == "patches" and k in (ord("t"), ord("T")):
ok, enabled = toggle_patch_roi_enabled_for_role(data, selected_target, edit_role, selected_roi_index)
last_msg = f"ROI #{selected_roi_index + 1} enabled={enabled}" if ok else "Nenhuma ROI para alternar"
last_msg_t = time.time()
elif k in (ord("v"), ord("V")):
beauty_preview = not beauty_preview
last_msg = f"Beauty Preview -> {beauty_preview}"