diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/EXEMPLO_ESTRUTURA/labelmap.txt.COLOQUE_AQUI b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/EXEMPLO_ESTRUTURA/labelmap.txt.COLOQUE_AQUI
new file mode 100644
index 000000000..313ee5b9d
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/EXEMPLO_ESTRUTURA/labelmap.txt.COLOQUE_AQUI
@@ -0,0 +1 @@
+Coloque seu labelmap.txt real ao lado do EXE.
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.py b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.py
new file mode 100644
index 000000000..e376cba04
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.py
@@ -0,0 +1,1449 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+MaskReviewer.py
+===============
+
+Ferramenta standalone para revisão humana de máscaras sem inferência/modelo.
+
+Estrutura preferida ao lado do EXE:
+ MaskReviewer.exe
+ labelmap.txt
+ group/
+ chao/
+ previews/
+ masks/
+ predictions/
+ final_masks/
+ review_order.csv
+ chao_cana/
+ chao_cana_erva/
+ chao_erva/
+ ...
+
+Se "group" e/ou "labelmap.txt" não forem encontrados automaticamente,
+abre seletor do Windows para o usuário apontá-los.
+
+Decisões da sessão:
+ group/review_choices.csv
+
+Controles principais:
+ 1 = manter máscara HUMANA
+ 2 = escolher máscara do MODELO
+ 3 = nenhuma serve, marcar REMAP
+ E = editar
+ A / D = anterior / próxima
+ S = pular
+ V = mostrar/ocultar a final_mask salva
+ X = apagar decisão atual
+ Q / ESC = sair
+
+Overlay:
+ Trackbar "Overlay %" controla a mesma opacidade nos dois lados.
+
+Editor:
+ Ao pressionar E:
+ 1 = editar HUMANA como base
+ 2 = editar MODELO como base
+ 3 = começar de CHÃO, visualmente transparente
+
+ Mouse esquerdo = adiciona ponto
+ Mouse direito = remove último ponto aberto
+ ENTER = fecha/preenche polígono com a classe selecionada
+ C = próxima classe
+ 0..9 = seleciona ID de classe diretamente
+ Z = desfaz último polígono confirmado
+ R = restaura máscara base
+ [ / ] = diminui / aumenta o pincel
+ + / - ou roda do mouse = zoom
+ Setas ou arrasto do botão do meio = pan
+ F = fit/visão completa
+ G = centraliza no último ponto sob o cursor
+ M = liga/desliga o mini mapa
+ Clique no mini mapa = centraliza a visão
+ Arrasto com o botão do meio sobre o mini mapa = move o mini mapa
+ BACKSPACE / DEL = limpa pontos abertos
+ S = salva edição em final_masks
+ ESC / Q = cancela edição
+
+O programa NUNCA altera o dataset bruto.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import shutil
+import sys
+import time
+import traceback
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+import cv2
+import numpy as np
+
+
+APP_NAME = "MaskReviewer"
+SETTINGS_NAME = "MaskReviewer.settings.json"
+CHOICES_NAME = "review_choices.csv"
+
+
+# ============================================================
+# Estruturas
+# ============================================================
+
+@dataclass
+class ReviewItem:
+ group: str
+ base: str
+ preview: Path
+ human_mask: Path
+ model_mask: Path
+ final_mask: Path
+ rank: Optional[int] = None
+ suspicion_pct: Optional[float] = None
+ reasons: str = ""
+
+
+# ============================================================
+# Paths / dialogs
+# ============================================================
+
+def app_dir() -> Path:
+ if getattr(sys, "frozen", False):
+ return Path(sys.executable).resolve().parent
+ return Path(__file__).resolve().parent
+
+
+def ensure_dir(path: Path) -> None:
+ path.mkdir(parents=True, exist_ok=True)
+
+
+def load_settings(path: Path) -> dict:
+ try:
+ if path.is_file():
+ with path.open("r", encoding="utf-8") as f:
+ js = json.load(f)
+ return js if isinstance(js, dict) else {}
+ except Exception:
+ pass
+ return {}
+
+
+def save_settings(path: Path, settings: dict) -> None:
+ try:
+ with path.open("w", encoding="utf-8") as f:
+ json.dump(settings, f, ensure_ascii=False, indent=2)
+ except Exception:
+ # Preferimos não impedir a revisão por uma falha de persistência.
+ pass
+
+
+def _tk_root():
+ import tkinter as tk
+ root = tk.Tk()
+ root.withdraw()
+ root.attributes("-topmost", True)
+ return root
+
+
+def choose_group_dialog(initial: Optional[Path] = None) -> Optional[Path]:
+ from tkinter import filedialog
+ root = _tk_root()
+ try:
+ selected = filedialog.askdirectory(
+ parent=root,
+ title="Selecione a pasta GROUP da revisão",
+ initialdir=str(initial) if initial and initial.exists() else str(app_dir()),
+ mustexist=True,
+ )
+ return Path(selected).resolve() if selected else None
+ finally:
+ root.destroy()
+
+
+def choose_labelmap_dialog(initial: Optional[Path] = None) -> Optional[Path]:
+ from tkinter import filedialog
+ root = _tk_root()
+ try:
+ selected = filedialog.askopenfilename(
+ parent=root,
+ title="Selecione o labelmap.txt",
+ initialdir=str(initial) if initial and initial.exists() else str(app_dir()),
+ filetypes=[("Labelmap", "labelmap.txt"), ("Arquivos TXT", "*.txt"), ("Todos", "*.*")],
+ )
+ return Path(selected).resolve() if selected else None
+ finally:
+ root.destroy()
+
+
+def show_error_dialog(message: str) -> None:
+ try:
+ from tkinter import messagebox
+ root = _tk_root()
+ try:
+ messagebox.showerror(APP_NAME, message, parent=root)
+ finally:
+ root.destroy()
+ except Exception:
+ print(message)
+
+
+def resolve_group_root(cli_group: Optional[str], settings: dict) -> Path:
+ base = app_dir()
+
+ candidates: List[Path] = []
+ if cli_group:
+ candidates.append(Path(cli_group))
+ saved = settings.get("group_root")
+ if saved:
+ candidates.append(Path(str(saved)))
+
+ candidates += [
+ base / "group",
+ base / "dataset" / "revisao" / "group",
+ base / "revisao" / "group",
+ ]
+
+ for c in candidates:
+ try:
+ p = c.expanduser().resolve()
+ if p.is_dir():
+ return p
+ except Exception:
+ continue
+
+ selected = choose_group_dialog(base)
+ if selected is None:
+ raise RuntimeError("Nenhuma pasta group foi selecionada.")
+ return selected
+
+
+def resolve_labelmap(cli_labelmap: Optional[str], group_root: Path, settings: dict) -> Path:
+ base = app_dir()
+
+ candidates: List[Path] = []
+ if cli_labelmap:
+ candidates.append(Path(cli_labelmap))
+ saved = settings.get("labelmap")
+ if saved:
+ candidates.append(Path(str(saved)))
+
+ # Pacote portátil: EXE + labelmap.txt + group/
+ candidates.append(base / "labelmap.txt")
+
+ # Estrutura do projeto: dataset/revisao/group -> dataset/labelmap.txt
+ candidates += [
+ group_root.parent / "labelmap.txt",
+ group_root.parent.parent / "labelmap.txt",
+ base / "dataset" / "labelmap.txt",
+ ]
+
+ for c in candidates:
+ try:
+ p = c.expanduser().resolve()
+ if p.is_file():
+ return p
+ except Exception:
+ continue
+
+ selected = choose_labelmap_dialog(group_root.parent)
+ if selected is None:
+ raise RuntimeError("Nenhum labelmap.txt foi selecionado.")
+ return selected
+
+
+# ============================================================
+# CSV / coleta
+# ============================================================
+
+def read_csv_rows(path: Path) -> List[dict]:
+ if not path.is_file():
+ return []
+ with path.open("r", newline="", encoding="utf-8-sig") as f:
+ return list(csv.DictReader(f))
+
+
+def write_choices(path: Path, choices: Dict[Tuple[str, str], dict]) -> None:
+ ensure_dir(path.parent)
+ fieldnames = [
+ "group",
+ "base",
+ "choice",
+ "source_mask",
+ "edit_base",
+ "final_mask",
+ "reviewed_at",
+ "suspicion_pct",
+ "review_reasons",
+ ]
+ rows = sorted(choices.values(), key=lambda r: (str(r["group"]), str(r["base"])))
+ with path.open("w", newline="", encoding="utf-8-sig") as f:
+ w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
+ w.writeheader()
+ w.writerows(rows)
+
+
+def load_choices(path: Path) -> Dict[Tuple[str, str], dict]:
+ out: Dict[Tuple[str, str], dict] = {}
+ for row in read_csv_rows(path):
+ key = (str(row.get("group", "")), str(row.get("base", "")))
+ if all(key):
+ out[key] = row
+ return out
+
+
+def load_order(group_dir: Path) -> Dict[str, dict]:
+ path = group_dir / "review_order.csv"
+ out: Dict[str, dict] = {}
+ for row in read_csv_rows(path):
+ base = str(row.get("base", ""))
+ if base:
+ out[base] = row
+ return out
+
+
+def collect_items(group_root: Path, groups: Optional[List[str]]) -> List[ReviewItem]:
+ if not group_root.is_dir():
+ raise FileNotFoundError(f"Pasta GROUP não encontrada: {group_root}")
+
+ allowed = set(groups or [])
+ items: List[ReviewItem] = []
+
+ for group_dir in sorted(p for p in group_root.iterdir() if p.is_dir()):
+ group = group_dir.name
+ if allowed and group not in allowed:
+ continue
+
+ previews_dir = group_dir / "previews"
+ masks_dir = group_dir / "masks"
+ preds_dir = group_dir / "predictions"
+ final_dir = group_dir / "final_masks"
+ ensure_dir(final_dir)
+
+ if not masks_dir.is_dir() or not preds_dir.is_dir() or not previews_dir.is_dir():
+ continue
+
+ order = load_order(group_dir)
+
+ # O auditor standalone normaliza tudo para PNG com mesmo basename.
+ bases = sorted(
+ p.stem
+ for p in masks_dir.glob("*.png")
+ if (preds_dir / p.name).is_file() and (previews_dir / p.name).is_file()
+ )
+
+ def sort_key(base: str):
+ row = order.get(base, {})
+ try:
+ rank = int(float(row.get("review_rank_group", "")))
+ except Exception:
+ rank = 10**9
+ return (rank, base)
+
+ bases.sort(key=sort_key)
+
+ for base in bases:
+ row = order.get(base, {})
+ try:
+ rank = int(float(row.get("review_rank_group", "")))
+ except Exception:
+ rank = None
+ try:
+ suspicion = float(row.get("suspicion_pct", ""))
+ except Exception:
+ suspicion = None
+
+ items.append(
+ ReviewItem(
+ group=group,
+ base=base,
+ preview=previews_dir / f"{base}.png",
+ human_mask=masks_dir / f"{base}.png",
+ model_mask=preds_dir / f"{base}.png",
+ final_mask=final_dir / f"{base}.png",
+ rank=rank,
+ suspicion_pct=suspicion,
+ reasons=str(row.get("review_reasons", "")),
+ )
+ )
+
+ return items
+
+
+# ============================================================
+# Imagens
+# ============================================================
+
+def imread_required(path: Path) -> np.ndarray:
+ """
+ Leitura robusta de imagens, especialmente para Windows + PyInstaller.
+
+ 1) Tenta cv2.imread normalmente.
+ 2) Se falhar, lê os bytes com NumPy e usa cv2.imdecode.
+ 3) Repete algumas vezes para tolerar falhas transitórias de I/O.
+
+ Não altera nem regrava a imagem.
+ """
+ path = Path(path)
+
+ if not path.is_file():
+ raise RuntimeError(f"Arquivo nao existe: {path}")
+
+ errors = []
+
+ for attempt in range(3):
+ try:
+ img = cv2.imread(str(path), cv2.IMREAD_COLOR)
+ if img is not None and img.size > 0:
+ return img
+ errors.append(f"tentativa {attempt + 1}: cv2.imread retornou None")
+ except Exception as exc:
+ errors.append(f"tentativa {attempt + 1}: cv2.imread: {exc}")
+
+ try:
+ raw = np.fromfile(str(path), dtype=np.uint8)
+ if raw.size > 0:
+ img = cv2.imdecode(raw, cv2.IMREAD_COLOR)
+ if img is not None and img.size > 0:
+ return img
+ errors.append(f"tentativa {attempt + 1}: cv2.imdecode retornou None")
+ except Exception as exc:
+ errors.append(f"tentativa {attempt + 1}: cv2.imdecode: {exc}")
+
+ time.sleep(0.05 * (attempt + 1))
+
+ try:
+ file_size = path.stat().st_size
+ except Exception:
+ file_size = -1
+
+ raise RuntimeError(
+ "Falha ao abrir imagem apos leitura robusta:\n"
+ f"{path}\n"
+ f"Tamanho do arquivo: {file_size} bytes\n"
+ + "\n".join(errors)
+ )
+
+
+def fit_to(img: np.ndarray, wh: Tuple[int, int], interpolation: int) -> np.ndarray:
+ w, h = wh
+ if img.shape[1] == w and img.shape[0] == h:
+ return img
+ return cv2.resize(img, (w, h), interpolation=interpolation)
+
+
+def add_label(img: np.ndarray, title: str, subtitle: str = "") -> np.ndarray:
+ out = img.copy()
+ h, w = out.shape[:2]
+ bar_h = max(52, int(h * 0.085))
+ overlay = out.copy()
+ cv2.rectangle(overlay, (0, 0), (w, bar_h), (0, 0, 0), -1)
+ out = cv2.addWeighted(overlay, 0.72, out, 0.28, 0)
+ cv2.putText(out, title, (14, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 255), 2, cv2.LINE_AA)
+ if subtitle:
+ cv2.putText(out, subtitle, (14, 46), cv2.FONT_HERSHEY_SIMPLEX, 0.46, (220, 220, 220), 1, cv2.LINE_AA)
+ return out
+
+
+def now_iso() -> str:
+ return datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+# ============================================================
+# Labelmap
+# ============================================================
+
+FALLBACK_CLASSES_RGB = {
+ 0: ("chao", (85, 85, 85)),
+ 1: ("cana", (0, 190, 0)),
+ 2: ("erva", (230, 55, 55)),
+}
+
+
+def load_labelmap_classes(path: Path) -> Dict[int, Tuple[str, Tuple[int, int, int]]]:
+ classes: Dict[int, Tuple[str, Tuple[int, int, int]]] = {}
+
+ if not path.is_file():
+ raise FileNotFoundError(path)
+
+ next_id = 0
+ with path.open("r", encoding="utf-8") as f:
+ for raw_line in f:
+ s = raw_line.strip()
+ if not s or s.startswith("#"):
+ continue
+
+ cid = None
+ name = None
+ color = None
+
+ if ":" in s and not s.split(":", 1)[0].strip().isdigit():
+ name_part, rest = s.split(":", 1)
+ name = name_part.strip()
+ color_txt = rest.split("::", 1)[0].strip().strip(":")
+ parts = [p.strip() for p in color_txt.split(",") if p.strip()]
+ if len(parts) >= 3:
+ try:
+ color = tuple(int(float(x)) for x in parts[:3])
+ except Exception:
+ color = None
+ else:
+ parts = s.replace(",", " ").replace(":", " ").split()
+ if len(parts) >= 2 and parts[0].isdigit():
+ cid = int(parts[0])
+ name = parts[1]
+ if len(parts) >= 5:
+ try:
+ color = tuple(int(float(x)) for x in parts[2:5])
+ except Exception:
+ color = None
+ elif parts:
+ name = parts[0]
+
+ if not name:
+ continue
+
+ lname = name.lower()
+ if lname in ("ignore", "void", "background_ignore"):
+ continue
+
+ if cid is None:
+ cid = next_id
+ next_id = max(next_id, cid + 1)
+
+ if color is None:
+ color = FALLBACK_CLASSES_RGB.get(cid, (name, (255, 255, 255)))[1]
+
+ classes[int(cid)] = (str(name), tuple(map(int, color)))
+
+ if not classes:
+ raise RuntimeError(f"Labelmap não contém classes utilizáveis: {path}")
+
+ return classes
+
+
+def class_color_bgr(classes, cid: int) -> Tuple[int, int, int]:
+ _name, rgb = classes[cid]
+ return int(rgb[2]), int(rgb[1]), int(rgb[0])
+
+
+def find_chao_id(classes) -> int:
+ for cid, (name, _rgb) in classes.items():
+ if str(name).strip().lower() in ("chao", "chão", "ground", "solo"):
+ return int(cid)
+ return min(classes.keys())
+
+
+def colored_mask_to_ids(mask_bgr: np.ndarray, classes) -> np.ndarray:
+ h, w = mask_bgr.shape[:2]
+ ids = np.full((h, w), 255, dtype=np.uint8)
+
+ for cid in classes:
+ bgr = np.array(class_color_bgr(classes, cid), dtype=np.uint8)
+ match = np.all(mask_bgr == bgr[None, None, :], axis=2)
+ ids[match] = int(cid)
+
+ unmatched = int((ids == 255).sum())
+ if unmatched:
+ raise RuntimeError(
+ f"A máscara possui {unmatched} pixels com cores que não existem no labelmap. "
+ "Confira se o labelmap.txt selecionado pertence a este dataset."
+ )
+ return ids
+
+
+def ids_to_colored_mask(ids: np.ndarray, classes) -> np.ndarray:
+ h, w = ids.shape[:2]
+ out = np.zeros((h, w, 3), dtype=np.uint8)
+ for cid in classes:
+ out[ids == cid] = class_color_bgr(classes, cid)
+ return out
+
+
+# ============================================================
+# Painel principal
+# ============================================================
+
+def compose_panel(
+ item: ReviewItem,
+ choice: Optional[str],
+ index: int,
+ total: int,
+ alpha: float,
+ max_width: int,
+) -> np.ndarray:
+ preview = imread_required(item.preview)
+ human = imread_required(item.human_mask)
+ model = imread_required(item.model_mask)
+
+ h, w = preview.shape[:2]
+ human = fit_to(human, (w, h), cv2.INTER_NEAREST)
+ model = fit_to(model, (w, h), cv2.INTER_NEAREST)
+
+ overlay_human = cv2.addWeighted(preview, 1.0 - alpha, human, alpha, 0.0)
+ overlay_model = cv2.addWeighted(preview, 1.0 - alpha, model, alpha, 0.0)
+
+ sus = f"{item.suspicion_pct:.1f}/100" if item.suspicion_pct is not None else "n/a"
+ rank = str(item.rank) if item.rank is not None else "n/a"
+ common = f"{item.group}/{item.base} | suspeita={sus} | rank={rank}"
+
+ tl = add_label(human, "1 MASCARA HUMANA", common)
+ tr = add_label(model, "2 MASCARA MODELO", common)
+ bl = add_label(overlay_human, "OVERLAY HUMANA", f"alpha={alpha:.2f}")
+ br = add_label(overlay_model, "OVERLAY MODELO", f"alpha={alpha:.2f}")
+
+ top = np.hstack([tl, tr])
+ bottom = np.hstack([bl, br])
+ canvas = np.vstack([top, bottom])
+
+ footer_h = 92
+ footer = np.zeros((footer_h, canvas.shape[1], 3), dtype=np.uint8)
+ status = choice or "NAO REVISADO"
+ final_exists = item.final_mask.is_file()
+ final_status = f"FINAL_MASK={'SIM' if final_exists else 'NAO'}"
+ line1 = f"[{index + 1}/{total}] escolha atual: {status} | {final_status}"
+ line2 = "1=humana 2=modelo 3=REMAP E=editar V=ver final A/D=navegar S=pular X=apagar Q=sair"
+ line3 = "Se a imagem ja foi revisada/ editada, aperte V para ver exatamente o PNG salvo em final_masks."
+ cv2.putText(footer, line1, (14, 27), cv2.FONT_HERSHEY_SIMPLEX, 0.60, (255, 255, 255), 2, cv2.LINE_AA)
+ cv2.putText(footer, line2, (14, 57), cv2.FONT_HERSHEY_SIMPLEX, 0.48, (210, 210, 210), 1, cv2.LINE_AA)
+ cv2.putText(footer, line3, (14, 84), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (170, 230, 170), 1, cv2.LINE_AA)
+ canvas = np.vstack([canvas, footer])
+
+ if max_width > 0 and canvas.shape[1] > max_width:
+ scale = max_width / float(canvas.shape[1])
+ canvas = cv2.resize(
+ canvas,
+ (max_width, max(1, int(round(canvas.shape[0] * scale)))),
+ interpolation=cv2.INTER_AREA,
+ )
+
+ return canvas
+
+
+def compose_final_panel(
+ item: ReviewItem,
+ choice: Optional[str],
+ alpha: float,
+ max_width: int,
+) -> np.ndarray:
+ preview = imread_required(item.preview)
+ h, w = preview.shape[:2]
+
+ if item.final_mask.is_file():
+ final_mask = fit_to(imread_required(item.final_mask), (w, h), cv2.INTER_NEAREST)
+ final_overlay = cv2.addWeighted(preview, 1.0 - alpha, final_mask, alpha, 0.0)
+ left = add_label(final_mask, "FINAL_MASK SALVA", f"{item.group}/{item.base}")
+ right = add_label(final_overlay, "OVERLAY FINAL", f"choice={choice or 'NAO REVISADO'} | alpha={alpha:.2f}")
+ else:
+ blank = preview.copy()
+ dark = np.zeros_like(blank)
+ blank = cv2.addWeighted(blank, 0.35, dark, 0.65, 0.0)
+ cv2.putText(blank, "SEM FINAL_MASK SALVA", (30, max(60, h // 2 - 10)), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 2, cv2.LINE_AA)
+ cv2.putText(blank, "Escolha 1, 2 ou edite antes de visualizar.", (30, max(95, h // 2 + 28)), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (210, 210, 210), 1, cv2.LINE_AA)
+ left = add_label(blank, "FINAL_MASK SALVA", f"{item.group}/{item.base}")
+ right = add_label(blank, "OVERLAY FINAL", f"choice={choice or 'NAO REVISADO'}")
+
+ canvas = np.hstack([left, right])
+ footer_h = 56
+ footer = np.zeros((footer_h, canvas.shape[1], 3), dtype=np.uint8)
+ cv2.putText(footer, "Janela auxiliar da mascara final | V = mostrar/ocultar | mostra exatamente o arquivo salvo em final_masks", (14, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.48, (230, 230, 230), 1, cv2.LINE_AA)
+ canvas = np.vstack([canvas, footer])
+
+ if max_width > 0 and canvas.shape[1] > max_width:
+ scale = max_width / float(canvas.shape[1])
+ canvas = cv2.resize(
+ canvas,
+ (max_width, max(1, int(round(canvas.shape[0] * scale)))),
+ interpolation=cv2.INTER_AREA,
+ )
+
+ return canvas
+
+
+# ============================================================
+# Decisões
+# ============================================================
+
+def record_choice(
+ item: ReviewItem,
+ choice_name: str,
+ source: Optional[Path],
+ choices: Dict[Tuple[str, str], dict],
+ choices_csv: Path,
+ edit_base: str = "",
+) -> None:
+ key = (item.group, item.base)
+
+ if choice_name in {"HUMAN", "MODEL"}:
+ if source is None or not source.is_file():
+ raise FileNotFoundError(f"Máscara fonte ausente: {source}")
+ ensure_dir(item.final_mask.parent)
+ shutil.copy2(source, item.final_mask)
+ final_path = str(item.final_mask)
+ source_path = str(source)
+
+ elif choice_name == "EDITED":
+ if source is None or not source.is_file():
+ raise FileNotFoundError(f"Máscara editada ausente: {source}")
+ ensure_dir(item.final_mask.parent)
+ if source.resolve() != item.final_mask.resolve():
+ shutil.copy2(source, item.final_mask)
+ final_path = str(item.final_mask)
+ source_path = str(source)
+
+ elif choice_name == "REMAP":
+ if item.final_mask.exists():
+ item.final_mask.unlink()
+ final_path = ""
+ source_path = ""
+
+ else:
+ raise ValueError(choice_name)
+
+ choices[key] = {
+ "group": item.group,
+ "base": item.base,
+ "choice": choice_name,
+ "source_mask": source_path,
+ "edit_base": edit_base,
+ "final_mask": final_path,
+ "reviewed_at": now_iso(),
+ "suspicion_pct": "" if item.suspicion_pct is None else f"{item.suspicion_pct:.6f}",
+ "review_reasons": item.reasons,
+ }
+ write_choices(choices_csv, choices)
+
+
+def remove_choice(
+ item: ReviewItem,
+ choices: Dict[Tuple[str, str], dict],
+ choices_csv: Path,
+) -> None:
+ key = (item.group, item.base)
+ choices.pop(key, None)
+ if item.final_mask.exists():
+ item.final_mask.unlink()
+ write_choices(choices_csv, choices)
+
+
+# ============================================================
+# Editor
+# ============================================================
+
+def overlay_ids(
+ preview: np.ndarray,
+ ids: np.ndarray,
+ classes,
+ alpha: float,
+ transparent_class_id: Optional[int] = None,
+) -> np.ndarray:
+ mask = ids_to_colored_mask(ids, classes)
+
+ if transparent_class_id is None:
+ return cv2.addWeighted(preview, 1.0 - alpha, mask, alpha, 0.0)
+
+ out = preview.copy()
+ region = ids != transparent_class_id
+ if np.any(region):
+ blended = cv2.addWeighted(preview, 1.0 - alpha, mask, alpha, 0.0)
+ out[region] = blended[region]
+ return out
+
+
+def choose_edit_base(window: str, panel: np.ndarray) -> Optional[str]:
+ prompt = panel.copy()
+ h, w = prompt.shape[:2]
+ bar_h = 108
+ overlay = prompt.copy()
+ cv2.rectangle(overlay, (0, 0), (w, bar_h), (0, 0, 0), -1)
+ prompt = cv2.addWeighted(overlay, 0.82, prompt, 0.18, 0)
+
+ cv2.putText(
+ prompt, "EDITOR: escolha a base",
+ (18, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.85,
+ (255, 255, 255), 2, cv2.LINE_AA,
+ )
+ cv2.putText(
+ prompt, "1=HUMANA 2=MODELO 3=CHAO/VAZIO VISUALMENTE ESC=cancelar",
+ (18, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.62,
+ (80, 255, 120), 2, cv2.LINE_AA,
+ )
+
+ cv2.imshow(window, prompt)
+
+ while True:
+ k = cv2.waitKeyEx(0)
+ if k == ord("1"):
+ return "HUMAN"
+ if k == ord("2"):
+ return "MODEL"
+ if k == ord("3"):
+ return "GROUND"
+ if k in (27, ord("q"), ord("Q")):
+ return None
+
+
+def run_polygon_editor(
+ window: str,
+ item: ReviewItem,
+ base_choice: str,
+ classes,
+ alpha_state: dict,
+) -> Optional[Tuple[np.ndarray, str]]:
+ preview = imread_required(item.preview)
+ human_bgr = fit_to(imread_required(item.human_mask), (preview.shape[1], preview.shape[0]), cv2.INTER_NEAREST)
+ model_bgr = fit_to(imread_required(item.model_mask), (preview.shape[1], preview.shape[0]), cv2.INTER_NEAREST)
+
+ chao_id = find_chao_id(classes)
+
+ if base_choice == "HUMAN":
+ base_ids = colored_mask_to_ids(human_bgr, classes)
+ transparent_class_id = None
+ elif base_choice == "MODEL":
+ base_ids = colored_mask_to_ids(model_bgr, classes)
+ transparent_class_id = None
+ elif base_choice == "GROUND":
+ base_ids = np.full(preview.shape[:2], chao_id, dtype=np.uint8)
+ transparent_class_id = chao_id
+ else:
+ raise ValueError(base_choice)
+
+ work_ids = base_ids.copy()
+ history: List[np.ndarray] = []
+ points: List[Tuple[int, int]] = []
+
+ class_ids = sorted(classes.keys())
+ selected_pos = class_ids.index(chao_id) if chao_id in class_ids else 0
+ selected_cid = class_ids[selected_pos]
+
+ tool_mode = "POLY"
+ brush_radius = 12
+ brush_radius_min = 1
+ brush_radius_max = 128
+
+ zoom = 1.0
+ zoom_min = 1.0
+ zoom_max = 12.0
+ zoom_step = 1.25
+ view_x = 0.0
+ view_y = 0.0
+
+ img_h, img_w = preview.shape[:2]
+ header_h = 92
+ footer_h = 96
+ canvas_w = img_w
+ canvas_h = img_h
+
+ is_panning = False
+ pan_anchor = (0, 0)
+ pan_anchor_view = (0.0, 0.0)
+ is_painting = False
+ last_paint_point: Optional[Tuple[int, int]] = None
+ last_mouse_img = (img_w // 2, img_h // 2)
+
+ minimap_visible = True
+ minimap_rect = None
+ is_dragging_minimap = False
+ minimap_drag_offset = (0, 0)
+
+ def minimap_size() -> Tuple[int, int]:
+ mm_max_w = 220
+ mm_max_h = 150
+ scale = min(mm_max_w / img_w, mm_max_h / img_h)
+ mm_w = max(1, int(round(img_w * scale)))
+ mm_h = max(1, int(round(img_h * scale)))
+ return mm_w, mm_h
+
+ mm_w, mm_h = minimap_size()
+ minimap_x = max(10, canvas_w - mm_w - 10)
+ minimap_y = 10
+
+ def clamp_minimap() -> None:
+ nonlocal minimap_x, minimap_y
+ minimap_x = int(min(max(0, minimap_x), max(0, canvas_w - mm_w)))
+ minimap_y = int(min(max(0, minimap_y), max(0, canvas_h - mm_h)))
+
+ def inside_minimap_display(x: int, y: int) -> bool:
+ if not minimap_visible or minimap_rect is None:
+ return False
+ x0, y0, x1, y1 = minimap_rect
+ return x0 <= x < x1 and y0 <= y < y1
+
+ def max_offsets(scale: float) -> Tuple[float, float]:
+ vw = min(img_w, img_w / scale)
+ vh = min(img_h, img_h / scale)
+ return max(0.0, img_w - vw), max(0.0, img_h - vh)
+
+ def clamp_view() -> None:
+ nonlocal view_x, view_y
+ mx, my = max_offsets(zoom)
+ view_x = min(max(0.0, view_x), mx)
+ view_y = min(max(0.0, view_y), my)
+
+ def screen_to_image(x: int, y: int) -> Optional[Tuple[int, int]]:
+ yy = float(y - header_h)
+ xx = float(x)
+ if xx < 0 or yy < 0 or xx >= canvas_w or yy >= canvas_h:
+ return None
+ ix = int(np.floor(view_x + xx / zoom))
+ iy = int(np.floor(view_y + yy / zoom))
+ ix = min(max(ix, 0), img_w - 1)
+ iy = min(max(iy, 0), img_h - 1)
+ return ix, iy
+
+ def set_zoom(new_zoom: float, anchor_screen: Optional[Tuple[int, int]] = None) -> None:
+ nonlocal zoom, view_x, view_y
+ old_zoom = zoom
+ new_zoom = min(max(float(new_zoom), zoom_min), zoom_max)
+ if abs(new_zoom - old_zoom) < 1e-9:
+ return
+ if anchor_screen is None:
+ ax = canvas_w / 2.0
+ ay = canvas_h / 2.0
+ else:
+ ax = float(anchor_screen[0])
+ ay = float(anchor_screen[1] - header_h)
+ ax = min(max(ax, 0.0), float(canvas_w - 1))
+ ay = min(max(ay, 0.0), float(canvas_h - 1))
+ anchor_ix = view_x + ax / old_zoom
+ anchor_iy = view_y + ay / old_zoom
+ zoom = new_zoom
+ view_x = anchor_ix - ax / zoom
+ view_y = anchor_iy - ay / zoom
+ clamp_view()
+
+ def fit_view() -> None:
+ nonlocal zoom, view_x, view_y
+ zoom = 1.0
+ view_x = 0.0
+ view_y = 0.0
+
+ def center_on(ix: int, iy: int) -> None:
+ nonlocal view_x, view_y
+ view_x = float(ix) - (canvas_w / zoom) / 2.0
+ view_y = float(iy) - (canvas_h / zoom) / 2.0
+ clamp_view()
+
+ def center_on_from_minimap(display_x: int, display_y: int) -> None:
+ if mm_w <= 1 or mm_h <= 1:
+ return
+ rel_x = (display_x - minimap_x) / float(mm_w)
+ rel_y = (display_y - minimap_y) / float(mm_h)
+ rel_x = min(max(rel_x, 0.0), 1.0)
+ rel_y = min(max(rel_y, 0.0), 1.0)
+ ix = int(round(rel_x * (img_w - 1)))
+ iy = int(round(rel_y * (img_h - 1)))
+ center_on(ix, iy)
+
+ def pan(dx_px: float, dy_px: float) -> None:
+ nonlocal view_x, view_y
+ view_x += dx_px / zoom
+ view_y += dy_px / zoom
+ clamp_view()
+
+ def push_history() -> None:
+ history.append(work_ids.copy())
+ if len(history) > 100:
+ del history[0]
+
+ def apply_circle(mask: np.ndarray, ix: int, iy: int, radius: int, value: int) -> None:
+ cv2.circle(mask, (int(ix), int(iy)), int(radius), int(value), thickness=-1)
+
+ def apply_from_base(mask: np.ndarray, ix: int, iy: int, radius: int) -> None:
+ yy, xx = np.ogrid[:img_h, :img_w]
+ circle = (xx - int(ix)) ** 2 + (yy - int(iy)) ** 2 <= int(radius) ** 2
+ mask[circle] = base_ids[circle]
+
+ def paint_line(mask: np.ndarray, p0: Tuple[int, int], p1: Tuple[int, int], radius: int, value: Optional[int], restore_from_base: bool) -> None:
+ x0, y0 = p0
+ x1, y1 = p1
+ dist = max(abs(x1 - x0), abs(y1 - y0), 1)
+ for t in range(dist + 1):
+ a = t / dist
+ x = int(round(x0 + (x1 - x0) * a))
+ y = int(round(y0 + (y1 - y0) * a))
+ if restore_from_base:
+ apply_from_base(mask, x, y, radius)
+ else:
+ assert value is not None
+ apply_circle(mask, x, y, radius, value)
+
+ def begin_stroke(ix: int, iy: int) -> None:
+ nonlocal is_painting, last_paint_point
+ push_history()
+ is_painting = True
+ last_paint_point = (ix, iy)
+ if tool_mode == "BRUSH":
+ apply_circle(work_ids, ix, iy, brush_radius, selected_cid)
+ elif tool_mode == "ERASER":
+ apply_from_base(work_ids, ix, iy, brush_radius)
+
+ def continue_stroke(ix: int, iy: int) -> None:
+ nonlocal last_paint_point
+ if not is_painting or last_paint_point is None:
+ return
+ if tool_mode == "BRUSH":
+ paint_line(work_ids, last_paint_point, (ix, iy), brush_radius, selected_cid, restore_from_base=False)
+ elif tool_mode == "ERASER":
+ paint_line(work_ids, last_paint_point, (ix, iy), brush_radius, None, restore_from_base=True)
+ last_paint_point = (ix, iy)
+
+ def end_stroke() -> None:
+ nonlocal is_painting, last_paint_point
+ is_painting = False
+ last_paint_point = None
+
+ def flood_component(ix: int, iy: int, new_cid: int) -> None:
+ old_cid = int(work_ids[iy, ix])
+ if old_cid == int(new_cid):
+ return
+ region = (work_ids == old_cid).astype(np.uint8)
+ flood = np.zeros((img_h + 2, img_w + 2), dtype=np.uint8)
+ flags = 4 | cv2.FLOODFILL_MASK_ONLY | (255 << 8)
+ region_copy = region.copy()
+ cv2.floodFill(region_copy, flood, (int(ix), int(iy)), 0, flags=flags)
+ selected = flood[1:-1, 1:-1] != 0
+ if not np.any(selected):
+ return
+ push_history()
+ work_ids[selected] = int(new_cid)
+
+ def draw_minimap(target: np.ndarray) -> None:
+ nonlocal minimap_rect
+ if not minimap_visible:
+ minimap_rect = None
+ return
+ thumb = cv2.resize(preview, (mm_w, mm_h), interpolation=cv2.INTER_AREA)
+ if mm_w <= 6 or mm_h <= 6:
+ minimap_rect = None
+ return
+ clamp_minimap()
+ x0 = int(minimap_x)
+ y0 = int(minimap_y)
+ x1 = x0 + mm_w
+ y1 = y0 + mm_h
+ minimap_rect = (x0, y0, x1, y1)
+ overlay = target.copy()
+ cv2.rectangle(overlay, (x0 - 3, y0 - 3), (x1 + 3, y1 + 20), (0, 0, 0), -1)
+ cv2.addWeighted(overlay, 0.55, target, 0.45, 0, target)
+ target[y0:y1, x0:x1] = thumb
+ vw = img_w / zoom
+ vh = img_h / zoom
+ rx0 = x0 + int(round((view_x / img_w) * mm_w))
+ ry0 = y0 + int(round((view_y / img_h) * mm_h))
+ rx1 = x0 + int(round(((view_x + vw) / img_w) * mm_w))
+ ry1 = y0 + int(round(((view_y + vh) / img_h) * mm_h))
+ rx1 = min(max(rx1, rx0 + 1), x1)
+ ry1 = min(max(ry1, ry0 + 1), y1)
+ cv2.rectangle(target, (x0 - 1, y0 - 1), (x1 + 1, y1 + 1), (255, 255, 255), 1)
+ cv2.rectangle(target, (rx0, ry0), (rx1, ry1), (0, 255, 255), 2)
+ cv2.putText(target, "mini mapa [M]", (x0, min(target.shape[0] - 5, y1 + 16)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (230, 230, 230), 1, cv2.LINE_AA)
+
+ def current_view_overlay() -> np.ndarray:
+ alpha = float(alpha_state["value"]) / 100.0
+ base_overlay = overlay_ids(preview, work_ids, classes, alpha=alpha, transparent_class_id=transparent_class_id)
+ if tool_mode in {"BRUSH", "ERASER"}:
+ cx, cy = last_mouse_img
+ color = class_color_bgr(classes, selected_cid) if tool_mode == "BRUSH" else (255, 255, 255)
+ cv2.circle(base_overlay, (int(cx), int(cy)), int(brush_radius), color, 2, cv2.LINE_AA)
+ view_w = max(1, int(round(canvas_w / zoom)))
+ view_h = max(1, int(round(canvas_h / zoom)))
+ x0 = int(round(view_x))
+ y0 = int(round(view_y))
+ x1 = min(img_w, x0 + view_w)
+ y1 = min(img_h, y0 + view_h)
+ crop = base_overlay[y0:y1, x0:x1]
+ display = cv2.resize(crop, (canvas_w, canvas_h), interpolation=cv2.INTER_NEAREST)
+ return display
+
+ def on_mouse(event, x, y, flags, param):
+ nonlocal points, tool_mode, is_panning, pan_anchor, pan_anchor_view, last_mouse_img, view_x, view_y
+ nonlocal is_dragging_minimap, minimap_drag_offset, minimap_x, minimap_y
+ display_y = y - header_h
+ over_minimap = inside_minimap_display(x, display_y)
+ pos = screen_to_image(x, y)
+ if pos is not None:
+ last_mouse_img = pos
+ ix, iy = pos
+ else:
+ ix = iy = None
+ if event == cv2.EVENT_MOUSEWHEEL:
+ if over_minimap:
+ return
+ direction = 1 if flags > 0 else -1
+ if direction > 0:
+ set_zoom(zoom * zoom_step, (x, y))
+ else:
+ set_zoom(zoom / zoom_step, (x, y))
+ return
+ if event == cv2.EVENT_MBUTTONDOWN:
+ if over_minimap:
+ is_dragging_minimap = True
+ minimap_drag_offset = (x - minimap_x, display_y - minimap_y)
+ else:
+ is_panning = True
+ pan_anchor = (x, y)
+ pan_anchor_view = (view_x, view_y)
+ return
+ if event == cv2.EVENT_MBUTTONUP:
+ is_panning = False
+ is_dragging_minimap = False
+ return
+ if event == cv2.EVENT_MOUSEMOVE and is_dragging_minimap:
+ minimap_x = x - minimap_drag_offset[0]
+ minimap_y = display_y - minimap_drag_offset[1]
+ clamp_minimap()
+ return
+ if event == cv2.EVENT_MOUSEMOVE and is_panning:
+ dx = pan_anchor[0] - x
+ dy = pan_anchor[1] - y
+ view_x = pan_anchor_view[0] + dx / zoom
+ view_y = pan_anchor_view[1] + dy / zoom
+ clamp_view()
+ return
+ if over_minimap:
+ if event == cv2.EVENT_LBUTTONDOWN:
+ center_on_from_minimap(x, display_y)
+ if event == cv2.EVENT_LBUTTONUP:
+ end_stroke()
+ return
+ if pos is None:
+ if event == cv2.EVENT_LBUTTONUP:
+ end_stroke()
+ return
+ if tool_mode == "POLY":
+ if event == cv2.EVENT_LBUTTONDOWN:
+ points.append((ix, iy))
+ elif event == cv2.EVENT_RBUTTONDOWN and points:
+ points.pop()
+ elif tool_mode == "BUCKET":
+ if event == cv2.EVENT_LBUTTONDOWN:
+ points = []
+ flood_component(ix, iy, selected_cid)
+ elif tool_mode in {"BRUSH", "ERASER"}:
+ if event == cv2.EVENT_LBUTTONDOWN:
+ points = []
+ begin_stroke(ix, iy)
+ elif event == cv2.EVENT_MOUSEMOVE and (flags & cv2.EVENT_FLAG_LBUTTON):
+ continue_stroke(ix, iy)
+ elif event == cv2.EVENT_LBUTTONUP:
+ continue_stroke(ix, iy)
+ end_stroke()
+
+ cv2.setMouseCallback(window, on_mouse)
+
+ def render() -> np.ndarray:
+ alpha = float(alpha_state["value"]) / 100.0
+ display = current_view_overlay()
+ if points:
+ draw_color = class_color_bgr(classes, selected_cid)
+ visible_points = []
+ for px, py in points:
+ sx = int(round((float(px) - view_x) * zoom))
+ sy = int(round((float(py) - view_y) * zoom))
+ if -50 <= sx <= canvas_w + 50 and -50 <= sy <= canvas_h + 50:
+ visible_points.append((sx, sy))
+ for sx, sy in visible_points:
+ cv2.circle(display, (sx, sy), 4, draw_color, -1, cv2.LINE_AA)
+ cv2.circle(display, (sx, sy), 6, (255, 255, 255), 1, cv2.LINE_AA)
+ if len(visible_points) >= 2:
+ pts = np.array(visible_points, dtype=np.int32)
+ cv2.polylines(display, [pts], False, draw_color, 2, cv2.LINE_AA)
+ draw_minimap(display)
+ header = np.zeros((header_h, canvas_w, 3), dtype=np.uint8)
+ footer = np.zeros((footer_h, canvas_w, 3), dtype=np.uint8)
+ cname, _rgb = classes[selected_cid]
+ bgr = class_color_bgr(classes, selected_cid)
+ minimap_txt = "ON" if minimap_visible else "OFF"
+ cv2.putText(header, f"EDITANDO {item.group}/{item.base} | base={base_choice} | tool={tool_mode}", (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (245, 245, 245), 2, cv2.LINE_AA)
+ cv2.rectangle(header, (12, 48), (42, 78), bgr, -1)
+ cv2.rectangle(header, (12, 48), (42, 78), (255, 255, 255), 1)
+ cv2.putText(header, f"classe {selected_cid}: {cname} | brush={brush_radius}px | alpha={alpha:.2f} | zoom={zoom:.2f}x | minimapa={minimap_txt}", (54, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.48, (220, 220, 220), 1, cv2.LINE_AA)
+ cv2.putText(footer, "P=poly B=balde N=pincel E=borracha | Esq=usar Dir=remove ponto | ENTER=preenche poligono", (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.44, (230, 230, 230), 1, cv2.LINE_AA)
+ cv2.putText(footer, "0..9=id C=proxima classe [ / ] pincel + / - ou roda=zoom setas=pan meio=pan | meio no minimapa=arrasta", (12, 58), cv2.FONT_HERSHEY_SIMPLEX, 0.44, (230, 230, 230), 1, cv2.LINE_AA)
+ cv2.putText(footer, "F=fit G=cursor M=liga/desliga minimapa | clique no minimapa=centraliza | Z=undo R=reset S=SALVA ESC/Q=cancela", (12, 86), cv2.FONT_HERSHEY_SIMPLEX, 0.44, (80, 255, 120), 1, cv2.LINE_AA)
+ return np.vstack([header, display, footer])
+
+ try:
+ while True:
+ cv2.imshow(window, render())
+ k = cv2.waitKeyEx(30)
+ if k == -1:
+ continue
+ if k in (27, ord("q"), ord("Q")):
+ return None
+ if k in (ord("s"), ord("S")):
+ return work_ids.copy(), base_choice
+ if k in (ord("c"), ord("C")):
+ selected_pos = (selected_pos + 1) % len(class_ids)
+ selected_cid = class_ids[selected_pos]
+ continue
+ if ord("0") <= k <= ord("9"):
+ cid = int(chr(k))
+ if cid in classes:
+ selected_cid = cid
+ selected_pos = class_ids.index(cid)
+ continue
+ if k in (ord("p"), ord("P")):
+ tool_mode = "POLY"
+ continue
+ if k in (ord("b"), ord("B")):
+ tool_mode = "BUCKET"
+ points = []
+ end_stroke()
+ continue
+ if k in (ord("n"), ord("N")):
+ tool_mode = "BRUSH"
+ points = []
+ end_stroke()
+ continue
+ if k in (ord("e"), ord("E")):
+ tool_mode = "ERASER"
+ points = []
+ end_stroke()
+ continue
+ if k in (10, 13):
+ if tool_mode == "POLY" and len(points) >= 3:
+ push_history()
+ poly = np.array(points, dtype=np.int32)
+ cv2.fillPoly(work_ids, [poly], int(selected_cid))
+ points = []
+ continue
+ if k in (ord("z"), ord("Z")):
+ if history:
+ work_ids = history.pop()
+ points = []
+ end_stroke()
+ continue
+ if k in (ord("r"), ord("R")):
+ work_ids = base_ids.copy()
+ history = []
+ points = []
+ end_stroke()
+ fit_view()
+ tool_mode = "POLY"
+ continue
+ if k in (8, 127, 3014656):
+ points = []
+ end_stroke()
+ continue
+ if k in (ord("f"), ord("F")):
+ fit_view()
+ continue
+ if k in (ord("g"), ord("G")):
+ center_on(*last_mouse_img)
+ continue
+ if k in (ord("m"), ord("M")):
+ minimap_visible = not minimap_visible
+ continue
+ if k == ord("["):
+ brush_radius = max(brush_radius_min, brush_radius - 1)
+ continue
+ if k == ord("]"):
+ brush_radius = min(brush_radius_max, brush_radius + 1)
+ continue
+ if k in (ord("+"), ord("="), 171):
+ set_zoom(zoom * zoom_step)
+ continue
+ if k in (ord("-"), ord("_"), 173):
+ set_zoom(zoom / zoom_step)
+ continue
+ if k in (2424832, 81):
+ pan(-80, 0)
+ continue
+ if k in (2555904, 83):
+ pan(80, 0)
+ continue
+ if k == 2490368:
+ pan(0, -80)
+ continue
+ if k == 2621440:
+ pan(0, 80)
+ continue
+ finally:
+ cv2.setMouseCallback(window, lambda *args: None)
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def run_main() -> None:
+ parser = argparse.ArgumentParser(description="MaskReviewer standalone")
+ parser.add_argument("--group", default=None, help="Pasta group. Se ausente, procura ao lado do EXE ou abre seletor.")
+ parser.add_argument("--labelmap", default=None, help="labelmap.txt. Se ausente, procura automaticamente ou abre seletor.")
+ parser.add_argument("--groups", nargs="*", default=None, help="Opcional: limita grupos")
+ parser.add_argument("--alpha", type=float, default=0.45, help="Overlay inicial 0..1")
+ parser.add_argument("--max_width", type=int, default=1800)
+ parser.add_argument("--start_idx", type=int, default=None)
+ args = parser.parse_args()
+
+ if not 0.0 <= args.alpha <= 1.0:
+ raise RuntimeError("--alpha deve ficar entre 0 e 1")
+
+ base = app_dir()
+ settings_path = base / SETTINGS_NAME
+ settings = load_settings(settings_path)
+
+ group_root = resolve_group_root(args.group, settings)
+ labelmap_path = resolve_labelmap(args.labelmap, group_root, settings)
+
+ settings["group_root"] = str(group_root)
+ settings["labelmap"] = str(labelmap_path)
+ save_settings(settings_path, settings)
+
+ classes = load_labelmap_classes(labelmap_path)
+ items = collect_items(group_root, args.groups)
+ if not items:
+ raise RuntimeError(
+ "Nenhum trio preview/mask/prediction foi encontrado.\n\n"
+ f"GROUP selecionado:\n{group_root}\n\n"
+ "Estrutura esperada por grupo:\n"
+ "previews/.png\nmasks/.png\npredictions/.png"
+ )
+
+ choices_csv = group_root / CHOICES_NAME
+ choices = load_choices(choices_csv)
+
+ if args.start_idx is not None:
+ idx = max(0, min(len(items) - 1, int(args.start_idx)))
+ else:
+ idx = 0
+ for i, item in enumerate(items):
+ if (item.group, item.base) not in choices:
+ idx = i
+ break
+ else:
+ idx = 0
+
+ alpha_state = {"value": int(round(args.alpha * 100.0))}
+ window = APP_NAME
+ final_window = f"{APP_NAME} - FINAL"
+ show_final_window = False
+
+ def on_alpha(v):
+ alpha_state["value"] = max(0, min(100, int(v)))
+
+ cv2.namedWindow(window, cv2.WINDOW_NORMAL)
+ cv2.createTrackbar("Overlay %", window, alpha_state["value"], 100, on_alpha)
+
+ while True:
+ item = items[idx]
+ current = choices.get((item.group, item.base), {}).get("choice")
+
+ alpha = float(alpha_state["value"]) / 100.0
+ canvas = compose_panel(
+ item=item,
+ choice=current,
+ index=idx,
+ total=len(items),
+ alpha=alpha,
+ max_width=args.max_width,
+ )
+ cv2.imshow(window, canvas)
+
+ if show_final_window:
+ final_canvas = compose_final_panel(item=item, choice=current, alpha=alpha, max_width=args.max_width)
+ cv2.namedWindow(final_window, cv2.WINDOW_NORMAL)
+ cv2.imshow(final_window, final_canvas)
+ else:
+ try:
+ cv2.destroyWindow(final_window)
+ except Exception:
+ pass
+
+ k = cv2.waitKeyEx(30)
+ if k == -1:
+ continue
+
+ if k in (ord("q"), ord("Q"), 27):
+ break
+
+ if k in (ord("v"), ord("V")):
+ show_final_window = not show_final_window
+ continue
+
+ if k == ord("1"):
+ record_choice(item, "HUMAN", item.human_mask, choices, choices_csv)
+ idx = (idx + 1) % len(items)
+ continue
+
+ if k == ord("2"):
+ record_choice(item, "MODEL", item.model_mask, choices, choices_csv)
+ idx = (idx + 1) % len(items)
+ continue
+
+ if k == ord("3"):
+ record_choice(item, "REMAP", None, choices, choices_csv)
+ idx = (idx + 1) % len(items)
+ continue
+
+ if k in (ord("e"), ord("E")):
+ base_choice = choose_edit_base(window, canvas)
+ if base_choice is None:
+ continue
+ edited = run_polygon_editor(
+ window=window,
+ item=item,
+ base_choice=base_choice,
+ classes=classes,
+ alpha_state=alpha_state,
+ )
+ if edited is not None:
+ edited_ids, edit_base = edited
+ edited_bgr = ids_to_colored_mask(edited_ids, classes)
+ ensure_dir(item.final_mask.parent)
+ if not cv2.imwrite(str(item.final_mask), edited_bgr):
+ raise RuntimeError(f"Falha ao salvar máscara editada: {item.final_mask}")
+ record_choice(item, "EDITED", item.final_mask, choices, choices_csv, edit_base=edit_base)
+ idx = (idx + 1) % len(items)
+ continue
+
+ if k in (ord("x"), ord("X")):
+ remove_choice(item, choices, choices_csv)
+ continue
+
+ if k in (ord("s"), ord("S")):
+ idx = (idx + 1) % len(items)
+ continue
+
+ if k in (ord("d"), ord("D"), 83, 2555904):
+ idx = (idx + 1) % len(items)
+ continue
+
+ if k in (ord("a"), ord("A"), 81, 2424832):
+ idx = (idx - 1 + len(items)) % len(items)
+ continue
+
+ try:
+ cv2.destroyWindow(final_window)
+ except Exception:
+ pass
+ cv2.destroyAllWindows()
+
+
+def main() -> None:
+ try:
+ run_main()
+ except Exception as exc:
+ # Em build --windowed não existe console. Gravamos log ao lado do EXE.
+ log_path = app_dir() / "MaskReviewer_error.log"
+ try:
+ log_path.write_text(traceback.format_exc(), encoding="utf-8")
+ except Exception:
+ pass
+
+ show_error_dialog(
+ f"O MaskReviewer encontrou um erro:\n\n{exc}\n\n"
+ f"Detalhes foram gravados em:\n{log_path}"
+ )
+ raise
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.settings.json b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.settings.json
new file mode 100644
index 000000000..785f2553a
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.settings.json
@@ -0,0 +1,4 @@
+{
+ "group_root": "C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\dist\\MaskReviewer\\group",
+ "labelmap": "C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\dist\\MaskReviewer\\labelmap.txt"
+}
\ No newline at end of file
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.spec b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.spec
new file mode 100644
index 000000000..b3e3eb0f2
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/MaskReviewer.spec
@@ -0,0 +1,51 @@
+# -*- mode: python ; coding: utf-8 -*-
+from PyInstaller.utils.hooks import collect_all
+
+datas = []
+binaries = []
+hiddenimports = []
+tmp_ret = collect_all('cv2')
+datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
+
+
+a = Analysis(
+ ['MaskReviewer.py'],
+ pathex=[],
+ binaries=binaries,
+ datas=datas,
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+ optimize=0,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name='MaskReviewer',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ console=False,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+)
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ name='MaskReviewer',
+)
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/README_MASKREVIEWER.txt b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/README_MASKREVIEWER.txt
new file mode 100644
index 000000000..529818576
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/README_MASKREVIEWER.txt
@@ -0,0 +1,110 @@
+MASKREVIEWER - PACOTE WINDOWS
+================================
+
+OBJETIVO
+--------
+Revisar, comparar e eventualmente editar máscaras já separadas pelo auditor.
+Não carrega modelo, PyTorch, CUDA ou checkpoint.
+
+PACOTE PARA O ANOTADOR
+----------------------
+A estrutura recomendada é:
+
+MaskReviewer\
+ MaskReviewer.exe
+ _internal\
+ labelmap.txt
+ group\
+ chao\
+ previews\
+ masks\
+ predictions\
+ final_masks\
+ review_order.csv
+ chao_cana\
+ chao_cana_erva\
+ chao_erva\
+ ...
+
+Ao abrir MaskReviewer.exe:
+
+1. Se "group" estiver ao lado do EXE, ele usa automaticamente.
+2. Se "labelmap.txt" estiver ao lado do EXE, ele usa automaticamente.
+3. Se algum deles não existir, abre o seletor do Windows.
+4. Os caminhos escolhidos ficam lembrados em MaskReviewer.settings.json.
+5. O estado do lote fica em group\review_choices.csv.
+6. As escolhas finais ficam em cada:
+ group\\final_masks\
+
+NOVO LOTE
+---------
+Para mandar uma nova revisão à mesma pessoa:
+
+1. Ela mantém:
+ MaskReviewer.exe
+ _internal\
+ labelmap.txt
+
+2. Substitui a pasta:
+ group\
+
+3. Abre o mesmo EXE.
+
+Como review_choices.csv fica DENTRO de group\, a sessão nova começa limpa junto
+com a nova pasta group.
+
+CONTROLES
+---------
+Painel 2x2:
+ 1 = máscara humana
+ 2 = máscara do modelo
+ 3 = REMAP
+ E = editar
+ A/D = anterior/próxima
+ S = pular
+ X = apagar decisão
+ Q/ESC = sair
+
+A barra "Overlay %" controla a opacidade dos dois overlays ao mesmo tempo.
+
+Editor:
+ E e depois:
+ 1 = humana como base
+ 2 = modelo como base
+ 3 = começar de chão
+
+ Mouse esquerdo = adiciona ponto
+ Mouse direito = remove último ponto
+ ENTER = preenche polígono
+ C = próxima classe
+ 0..9 = ID de classe
+ Z = desfazer
+ R = restaurar base
+ BACKSPACE/DEL = limpar pontos abertos
+ S = salvar
+ ESC/Q = cancelar edição
+
+COMO GERAR O EXE
+----------------
+Execute com duplo clique:
+
+ build_windows.bat
+
+Recomendado:
+ Windows 10/11
+ Python 3.11 ou 3.12 instalado SOMENTE na máquina de build
+
+O script cria:
+ dist\MaskReviewer\MaskReviewer.exe
+
+Depois do build, a máquina do anotador NÃO precisa ter Python.
+
+POR QUE --ONEDIR?
+-----------------
+O build usa PyInstaller --onedir porque OpenCV/Numpy ficam mais confiáveis,
+iniciam mais rápido e são mais fáceis de diagnosticar do que um EXE --onefile.
+
+IMPORTANTE
+----------
+O MaskReviewer nunca altera o dataset bruto.
+Ele só escreve final_masks e review_choices.csv dentro do lote enviado.
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/Analysis-00.toc b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/Analysis-00.toc
new file mode 100644
index 000000000..5192afbae
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/Analysis-00.toc
@@ -0,0 +1,4402 @@
+(['C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\MaskReviewer.py'],
+ ['C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package'],
+ ['cv2',
+ 'cv2.config',
+ 'cv2.config-3',
+ 'cv2.cv2',
+ 'cv2.data',
+ 'cv2.load_config_py2',
+ 'cv2.load_config_py3',
+ 'cv2.mat_wrapper',
+ 'cv2.misc',
+ 'cv2.misc.version',
+ 'cv2.typing',
+ 'cv2.utils',
+ 'cv2.version'],
+ [('C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_pyinstaller',
+ 0),
+ ('C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks',
+ -1000),
+ ('C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\_pyinstaller_hooks_contrib',
+ -1000)],
+ {},
+ [],
+ [],
+ False,
+ {},
+ 0,
+ [('cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'BINARY')],
+ [('cv2\\Error\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\Error\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\LICENSE-3RD-PARTY.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\LICENSE-3RD-PARTY.txt',
+ 'DATA'),
+ ('cv2\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\LICENSE.txt',
+ 'DATA'),
+ ('cv2\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\__init__.py',
+ 'DATA'),
+ ('cv2\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\aruco\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\aruco\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\barcode\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\barcode\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ccm\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ccm\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\config-3.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config-3.py',
+ 'DATA'),
+ ('cv2\\config.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config.py',
+ 'DATA'),
+ ('cv2\\cuda\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\cuda\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\data\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\data\\__init__.py',
+ 'DATA'),
+ ('cv2\\detail\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\detail\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\dnn\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\dnn\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\fisheye\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\fisheye\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\flann\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\flann\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\instr\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\instr\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ipp\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ipp\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\load_config_py2.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py2.py',
+ 'DATA'),
+ ('cv2\\load_config_py3.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py3.py',
+ 'DATA'),
+ ('cv2\\mat_wrapper\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\mat_wrapper\\__init__.py',
+ 'DATA'),
+ ('cv2\\mcc\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\mcc\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\misc\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\__init__.py',
+ 'DATA'),
+ ('cv2\\misc\\version.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\version.py',
+ 'DATA'),
+ ('cv2\\ocl\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ocl\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ogl\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ogl\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'DATA'),
+ ('cv2\\parallel\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\parallel\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\py.typed',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\py.typed',
+ 'DATA'),
+ ('cv2\\samples\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\samples\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\segmentation\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\segmentation\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\typing\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\typing\\__init__.py',
+ 'DATA'),
+ ('cv2\\utils\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\__init__.py',
+ 'DATA'),
+ ('cv2\\utils\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\fs\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\fs\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\logging\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\logging\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\nested\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\nested\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\version.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\version.py',
+ 'DATA'),
+ ('cv2\\videoio_registry\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\videoio_registry\\__init__.pyi',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\INSTALLER',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\INSTALLER',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\LICENSE-3RD-PARTY.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\LICENSE-3RD-PARTY.txt',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\LICENSE.txt',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\METADATA',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\METADATA',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\RECORD',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\RECORD',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\REQUESTED',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\REQUESTED',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\WHEEL',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\WHEEL',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\top_level.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\top_level.txt',
+ 'DATA')],
+ '3.12.2 (tags/v3.12.2:6abddd9, Feb 6 2024, 21:26:36) [MSC v.1937 64 bit '
+ '(AMD64)]',
+ [('pyi_rth__tkinter',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth__tkinter.py',
+ 'PYSOURCE'),
+ ('pyi_rth_inspect',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
+ 'PYSOURCE'),
+ ('pyi_rth_pkgutil',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
+ 'PYSOURCE'),
+ ('pyi_rth_multiprocessing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
+ 'PYSOURCE'),
+ ('MaskReviewer',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\MaskReviewer.py',
+ 'PYSOURCE')],
+ [('subprocess', 'C:\\Python312\\Lib\\subprocess.py', 'PYMODULE'),
+ ('selectors', 'C:\\Python312\\Lib\\selectors.py', 'PYMODULE'),
+ ('contextlib', 'C:\\Python312\\Lib\\contextlib.py', 'PYMODULE'),
+ ('threading', 'C:\\Python312\\Lib\\threading.py', 'PYMODULE'),
+ ('_threading_local', 'C:\\Python312\\Lib\\_threading_local.py', 'PYMODULE'),
+ ('signal', 'C:\\Python312\\Lib\\signal.py', 'PYMODULE'),
+ ('multiprocessing.spawn',
+ 'C:\\Python312\\Lib\\multiprocessing\\spawn.py',
+ 'PYMODULE'),
+ ('multiprocessing.resource_tracker',
+ 'C:\\Python312\\Lib\\multiprocessing\\resource_tracker.py',
+ 'PYMODULE'),
+ ('multiprocessing.util',
+ 'C:\\Python312\\Lib\\multiprocessing\\util.py',
+ 'PYMODULE'),
+ ('multiprocessing.forkserver',
+ 'C:\\Python312\\Lib\\multiprocessing\\forkserver.py',
+ 'PYMODULE'),
+ ('multiprocessing.connection',
+ 'C:\\Python312\\Lib\\multiprocessing\\connection.py',
+ 'PYMODULE'),
+ ('multiprocessing.resource_sharer',
+ 'C:\\Python312\\Lib\\multiprocessing\\resource_sharer.py',
+ 'PYMODULE'),
+ ('xmlrpc.client', 'C:\\Python312\\Lib\\xmlrpc\\client.py', 'PYMODULE'),
+ ('xmlrpc', 'C:\\Python312\\Lib\\xmlrpc\\__init__.py', 'PYMODULE'),
+ ('gzip', 'C:\\Python312\\Lib\\gzip.py', 'PYMODULE'),
+ ('_compression', 'C:\\Python312\\Lib\\_compression.py', 'PYMODULE'),
+ ('xml.parsers.expat',
+ 'C:\\Python312\\Lib\\xml\\parsers\\expat.py',
+ 'PYMODULE'),
+ ('xml.parsers', 'C:\\Python312\\Lib\\xml\\parsers\\__init__.py', 'PYMODULE'),
+ ('xml', 'C:\\Python312\\Lib\\xml\\__init__.py', 'PYMODULE'),
+ ('xml.sax.expatreader',
+ 'C:\\Python312\\Lib\\xml\\sax\\expatreader.py',
+ 'PYMODULE'),
+ ('xml.sax.saxutils', 'C:\\Python312\\Lib\\xml\\sax\\saxutils.py', 'PYMODULE'),
+ ('urllib.request', 'C:\\Python312\\Lib\\urllib\\request.py', 'PYMODULE'),
+ ('fnmatch', 'C:\\Python312\\Lib\\fnmatch.py', 'PYMODULE'),
+ ('getpass', 'C:\\Python312\\Lib\\getpass.py', 'PYMODULE'),
+ ('nturl2path', 'C:\\Python312\\Lib\\nturl2path.py', 'PYMODULE'),
+ ('ftplib', 'C:\\Python312\\Lib\\ftplib.py', 'PYMODULE'),
+ ('netrc', 'C:\\Python312\\Lib\\netrc.py', 'PYMODULE'),
+ ('mimetypes', 'C:\\Python312\\Lib\\mimetypes.py', 'PYMODULE'),
+ ('getopt', 'C:\\Python312\\Lib\\getopt.py', 'PYMODULE'),
+ ('gettext', 'C:\\Python312\\Lib\\gettext.py', 'PYMODULE'),
+ ('copy', 'C:\\Python312\\Lib\\copy.py', 'PYMODULE'),
+ ('email.utils', 'C:\\Python312\\Lib\\email\\utils.py', 'PYMODULE'),
+ ('email.charset', 'C:\\Python312\\Lib\\email\\charset.py', 'PYMODULE'),
+ ('email.encoders', 'C:\\Python312\\Lib\\email\\encoders.py', 'PYMODULE'),
+ ('quopri', 'C:\\Python312\\Lib\\quopri.py', 'PYMODULE'),
+ ('email.errors', 'C:\\Python312\\Lib\\email\\errors.py', 'PYMODULE'),
+ ('email.quoprimime', 'C:\\Python312\\Lib\\email\\quoprimime.py', 'PYMODULE'),
+ ('email.base64mime', 'C:\\Python312\\Lib\\email\\base64mime.py', 'PYMODULE'),
+ ('email._parseaddr', 'C:\\Python312\\Lib\\email\\_parseaddr.py', 'PYMODULE'),
+ ('calendar', 'C:\\Python312\\Lib\\calendar.py', 'PYMODULE'),
+ ('random', 'C:\\Python312\\Lib\\random.py', 'PYMODULE'),
+ ('statistics', 'C:\\Python312\\Lib\\statistics.py', 'PYMODULE'),
+ ('fractions', 'C:\\Python312\\Lib\\fractions.py', 'PYMODULE'),
+ ('numbers', 'C:\\Python312\\Lib\\numbers.py', 'PYMODULE'),
+ ('http.cookiejar', 'C:\\Python312\\Lib\\http\\cookiejar.py', 'PYMODULE'),
+ ('http', 'C:\\Python312\\Lib\\http\\__init__.py', 'PYMODULE'),
+ ('urllib', 'C:\\Python312\\Lib\\urllib\\__init__.py', 'PYMODULE'),
+ ('ssl', 'C:\\Python312\\Lib\\ssl.py', 'PYMODULE'),
+ ('urllib.response', 'C:\\Python312\\Lib\\urllib\\response.py', 'PYMODULE'),
+ ('urllib.error', 'C:\\Python312\\Lib\\urllib\\error.py', 'PYMODULE'),
+ ('string', 'C:\\Python312\\Lib\\string.py', 'PYMODULE'),
+ ('hashlib', 'C:\\Python312\\Lib\\hashlib.py', 'PYMODULE'),
+ ('email', 'C:\\Python312\\Lib\\email\\__init__.py', 'PYMODULE'),
+ ('email.parser', 'C:\\Python312\\Lib\\email\\parser.py', 'PYMODULE'),
+ ('email._policybase',
+ 'C:\\Python312\\Lib\\email\\_policybase.py',
+ 'PYMODULE'),
+ ('email.feedparser', 'C:\\Python312\\Lib\\email\\feedparser.py', 'PYMODULE'),
+ ('email.message', 'C:\\Python312\\Lib\\email\\message.py', 'PYMODULE'),
+ ('email.policy', 'C:\\Python312\\Lib\\email\\policy.py', 'PYMODULE'),
+ ('email.contentmanager',
+ 'C:\\Python312\\Lib\\email\\contentmanager.py',
+ 'PYMODULE'),
+ ('email.headerregistry',
+ 'C:\\Python312\\Lib\\email\\headerregistry.py',
+ 'PYMODULE'),
+ ('email.iterators', 'C:\\Python312\\Lib\\email\\iterators.py', 'PYMODULE'),
+ ('email.generator', 'C:\\Python312\\Lib\\email\\generator.py', 'PYMODULE'),
+ ('email._encoded_words',
+ 'C:\\Python312\\Lib\\email\\_encoded_words.py',
+ 'PYMODULE'),
+ ('email._header_value_parser',
+ 'C:\\Python312\\Lib\\email\\_header_value_parser.py',
+ 'PYMODULE'),
+ ('email.header', 'C:\\Python312\\Lib\\email\\header.py', 'PYMODULE'),
+ ('bisect', 'C:\\Python312\\Lib\\bisect.py', 'PYMODULE'),
+ ('xml.sax', 'C:\\Python312\\Lib\\xml\\sax\\__init__.py', 'PYMODULE'),
+ ('xml.sax.handler', 'C:\\Python312\\Lib\\xml\\sax\\handler.py', 'PYMODULE'),
+ ('xml.sax._exceptions',
+ 'C:\\Python312\\Lib\\xml\\sax\\_exceptions.py',
+ 'PYMODULE'),
+ ('xml.sax.xmlreader',
+ 'C:\\Python312\\Lib\\xml\\sax\\xmlreader.py',
+ 'PYMODULE'),
+ ('urllib.parse', 'C:\\Python312\\Lib\\urllib\\parse.py', 'PYMODULE'),
+ ('ipaddress', 'C:\\Python312\\Lib\\ipaddress.py', 'PYMODULE'),
+ ('http.client', 'C:\\Python312\\Lib\\http\\client.py', 'PYMODULE'),
+ ('decimal', 'C:\\Python312\\Lib\\decimal.py', 'PYMODULE'),
+ ('_pydecimal', 'C:\\Python312\\Lib\\_pydecimal.py', 'PYMODULE'),
+ ('contextvars', 'C:\\Python312\\Lib\\contextvars.py', 'PYMODULE'),
+ ('base64', 'C:\\Python312\\Lib\\base64.py', 'PYMODULE'),
+ ('hmac', 'C:\\Python312\\Lib\\hmac.py', 'PYMODULE'),
+ ('struct', 'C:\\Python312\\Lib\\struct.py', 'PYMODULE'),
+ ('socket', 'C:\\Python312\\Lib\\socket.py', 'PYMODULE'),
+ ('tempfile', 'C:\\Python312\\Lib\\tempfile.py', 'PYMODULE'),
+ ('logging', 'C:\\Python312\\Lib\\logging\\__init__.py', 'PYMODULE'),
+ ('pickle', 'C:\\Python312\\Lib\\pickle.py', 'PYMODULE'),
+ ('pprint', 'C:\\Python312\\Lib\\pprint.py', 'PYMODULE'),
+ ('_compat_pickle', 'C:\\Python312\\Lib\\_compat_pickle.py', 'PYMODULE'),
+ ('multiprocessing.context',
+ 'C:\\Python312\\Lib\\multiprocessing\\context.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_spawn_win32',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_spawn_win32.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_forkserver',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_forkserver.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_spawn_posix',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_spawn_posix.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_fork',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_fork.py',
+ 'PYMODULE'),
+ ('multiprocessing.sharedctypes',
+ 'C:\\Python312\\Lib\\multiprocessing\\sharedctypes.py',
+ 'PYMODULE'),
+ ('multiprocessing.heap',
+ 'C:\\Python312\\Lib\\multiprocessing\\heap.py',
+ 'PYMODULE'),
+ ('ctypes', 'C:\\Python312\\Lib\\ctypes\\__init__.py', 'PYMODULE'),
+ ('ctypes._endian', 'C:\\Python312\\Lib\\ctypes\\_endian.py', 'PYMODULE'),
+ ('multiprocessing.pool',
+ 'C:\\Python312\\Lib\\multiprocessing\\pool.py',
+ 'PYMODULE'),
+ ('multiprocessing.dummy',
+ 'C:\\Python312\\Lib\\multiprocessing\\dummy\\__init__.py',
+ 'PYMODULE'),
+ ('multiprocessing.dummy.connection',
+ 'C:\\Python312\\Lib\\multiprocessing\\dummy\\connection.py',
+ 'PYMODULE'),
+ ('queue', 'C:\\Python312\\Lib\\queue.py', 'PYMODULE'),
+ ('multiprocessing.queues',
+ 'C:\\Python312\\Lib\\multiprocessing\\queues.py',
+ 'PYMODULE'),
+ ('multiprocessing.synchronize',
+ 'C:\\Python312\\Lib\\multiprocessing\\synchronize.py',
+ 'PYMODULE'),
+ ('multiprocessing.managers',
+ 'C:\\Python312\\Lib\\multiprocessing\\managers.py',
+ 'PYMODULE'),
+ ('multiprocessing.shared_memory',
+ 'C:\\Python312\\Lib\\multiprocessing\\shared_memory.py',
+ 'PYMODULE'),
+ ('secrets', 'C:\\Python312\\Lib\\secrets.py', 'PYMODULE'),
+ ('multiprocessing.reduction',
+ 'C:\\Python312\\Lib\\multiprocessing\\reduction.py',
+ 'PYMODULE'),
+ ('multiprocessing.process',
+ 'C:\\Python312\\Lib\\multiprocessing\\process.py',
+ 'PYMODULE'),
+ ('runpy', 'C:\\Python312\\Lib\\runpy.py', 'PYMODULE'),
+ ('pkgutil', 'C:\\Python312\\Lib\\pkgutil.py', 'PYMODULE'),
+ ('zipimport', 'C:\\Python312\\Lib\\zipimport.py', 'PYMODULE'),
+ ('importlib.readers',
+ 'C:\\Python312\\Lib\\importlib\\readers.py',
+ 'PYMODULE'),
+ ('importlib.resources.readers',
+ 'C:\\Python312\\Lib\\importlib\\resources\\readers.py',
+ 'PYMODULE'),
+ ('importlib.resources._itertools',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_itertools.py',
+ 'PYMODULE'),
+ ('importlib.resources.abc',
+ 'C:\\Python312\\Lib\\importlib\\resources\\abc.py',
+ 'PYMODULE'),
+ ('importlib.resources',
+ 'C:\\Python312\\Lib\\importlib\\resources\\__init__.py',
+ 'PYMODULE'),
+ ('importlib.resources._legacy',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_legacy.py',
+ 'PYMODULE'),
+ ('importlib.resources._common',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_common.py',
+ 'PYMODULE'),
+ ('importlib.resources._adapters',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_adapters.py',
+ 'PYMODULE'),
+ ('zipfile', 'C:\\Python312\\Lib\\zipfile\\__init__.py', 'PYMODULE'),
+ ('zipfile._path',
+ 'C:\\Python312\\Lib\\zipfile\\_path\\__init__.py',
+ 'PYMODULE'),
+ ('zipfile._path.glob',
+ 'C:\\Python312\\Lib\\zipfile\\_path\\glob.py',
+ 'PYMODULE'),
+ ('py_compile', 'C:\\Python312\\Lib\\py_compile.py', 'PYMODULE'),
+ ('importlib._bootstrap_external',
+ 'C:\\Python312\\Lib\\importlib\\_bootstrap_external.py',
+ 'PYMODULE'),
+ ('importlib.metadata',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\__init__.py',
+ 'PYMODULE'),
+ ('importlib.abc', 'C:\\Python312\\Lib\\importlib\\abc.py', 'PYMODULE'),
+ ('importlib._abc', 'C:\\Python312\\Lib\\importlib\\_abc.py', 'PYMODULE'),
+ ('importlib._bootstrap',
+ 'C:\\Python312\\Lib\\importlib\\_bootstrap.py',
+ 'PYMODULE'),
+ ('importlib.metadata._itertools',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_itertools.py',
+ 'PYMODULE'),
+ ('importlib.metadata._functools',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_functools.py',
+ 'PYMODULE'),
+ ('importlib.metadata._collections',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_collections.py',
+ 'PYMODULE'),
+ ('importlib.metadata._meta',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_meta.py',
+ 'PYMODULE'),
+ ('importlib.metadata._adapters',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_adapters.py',
+ 'PYMODULE'),
+ ('importlib.metadata._text',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_text.py',
+ 'PYMODULE'),
+ ('textwrap', 'C:\\Python312\\Lib\\textwrap.py', 'PYMODULE'),
+ ('tokenize', 'C:\\Python312\\Lib\\tokenize.py', 'PYMODULE'),
+ ('token', 'C:\\Python312\\Lib\\token.py', 'PYMODULE'),
+ ('lzma', 'C:\\Python312\\Lib\\lzma.py', 'PYMODULE'),
+ ('bz2', 'C:\\Python312\\Lib\\bz2.py', 'PYMODULE'),
+ ('inspect', 'C:\\Python312\\Lib\\inspect.py', 'PYMODULE'),
+ ('dis', 'C:\\Python312\\Lib\\dis.py', 'PYMODULE'),
+ ('opcode', 'C:\\Python312\\Lib\\opcode.py', 'PYMODULE'),
+ ('ast', 'C:\\Python312\\Lib\\ast.py', 'PYMODULE'),
+ ('importlib', 'C:\\Python312\\Lib\\importlib\\__init__.py', 'PYMODULE'),
+ ('importlib.util', 'C:\\Python312\\Lib\\importlib\\util.py', 'PYMODULE'),
+ ('importlib.machinery',
+ 'C:\\Python312\\Lib\\importlib\\machinery.py',
+ 'PYMODULE'),
+ ('multiprocessing',
+ 'C:\\Python312\\Lib\\multiprocessing\\__init__.py',
+ 'PYMODULE'),
+ ('_py_abc', 'C:\\Python312\\Lib\\_py_abc.py', 'PYMODULE'),
+ ('tracemalloc', 'C:\\Python312\\Lib\\tracemalloc.py', 'PYMODULE'),
+ ('stringprep', 'C:\\Python312\\Lib\\stringprep.py', 'PYMODULE'),
+ ('tkinter.messagebox',
+ 'C:\\Python312\\Lib\\tkinter\\messagebox.py',
+ 'PYMODULE'),
+ ('tkinter.commondialog',
+ 'C:\\Python312\\Lib\\tkinter\\commondialog.py',
+ 'PYMODULE'),
+ ('tkinter.filedialog',
+ 'C:\\Python312\\Lib\\tkinter\\filedialog.py',
+ 'PYMODULE'),
+ ('tkinter.simpledialog',
+ 'C:\\Python312\\Lib\\tkinter\\simpledialog.py',
+ 'PYMODULE'),
+ ('tkinter.dialog', 'C:\\Python312\\Lib\\tkinter\\dialog.py', 'PYMODULE'),
+ ('tkinter', 'C:\\Python312\\Lib\\tkinter\\__init__.py', 'PYMODULE'),
+ ('tkinter.constants',
+ 'C:\\Python312\\Lib\\tkinter\\constants.py',
+ 'PYMODULE'),
+ ('numpy',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._core._exceptions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_exceptions.py',
+ 'PYMODULE'),
+ ('numpy._core._dtype_ctypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_dtype_ctypes.py',
+ 'PYMODULE'),
+ ('numpy._pytesttester',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_pytesttester.py',
+ 'PYMODULE'),
+ ('numpy.strings',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\strings\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._core.strings',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\strings.py',
+ 'PYMODULE'),
+ ('numpy._core.umath',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\umath.py',
+ 'PYMODULE'),
+ ('numpy._core.overrides',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\overrides.py',
+ 'PYMODULE'),
+ ('numpy._utils._inspect',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_utils\\_inspect.py',
+ 'PYMODULE'),
+ ('numpy._utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_utils\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._utils._conversions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_utils\\_conversions.py',
+ 'PYMODULE'),
+ ('numpy._core.multiarray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\multiarray.py',
+ 'PYMODULE'),
+ ('numpy.core',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\core\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.core._utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\core\\_utils.py',
+ 'PYMODULE'),
+ ('numpy.char',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\char\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._core.defchararray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\defchararray.py',
+ 'PYMODULE'),
+ ('numpy._core.numerictypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\numerictypes.py',
+ 'PYMODULE'),
+ ('numpy._core._type_aliases',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_type_aliases.py',
+ 'PYMODULE'),
+ ('numpy._core._string_helpers',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_string_helpers.py',
+ 'PYMODULE'),
+ ('numpy._core.numeric',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\numeric.py',
+ 'PYMODULE'),
+ ('numpy._core.fromnumeric',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\fromnumeric.py',
+ 'PYMODULE'),
+ ('numpy._core._methods',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_methods.py',
+ 'PYMODULE'),
+ ('numpy._core.arrayprint',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\arrayprint.py',
+ 'PYMODULE'),
+ ('numpy._core._asarray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_asarray.py',
+ 'PYMODULE'),
+ ('numpy._core._ufunc_config',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_ufunc_config.py',
+ 'PYMODULE'),
+ ('numpy._core.shape_base',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\shape_base.py',
+ 'PYMODULE'),
+ ('numpy.rec',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\rec\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._core.records',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\records.py',
+ 'PYMODULE'),
+ ('numpy.typing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\typing\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._typing._add_docstring',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_add_docstring.py',
+ 'PYMODULE'),
+ ('numpy._typing._array_like',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_array_like.py',
+ 'PYMODULE'),
+ ('numpy._typing._shape',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_shape.py',
+ 'PYMODULE'),
+ ('numpy._typing._nested_sequence',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_nested_sequence.py',
+ 'PYMODULE'),
+ ('numpy._typing._nbit_base',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_nbit_base.py',
+ 'PYMODULE'),
+ ('numpy._typing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._typing._ufunc',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_ufunc.py',
+ 'PYMODULE'),
+ ('numpy._typing._scalars',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_scalars.py',
+ 'PYMODULE'),
+ ('numpy._typing._nbit',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_nbit.py',
+ 'PYMODULE'),
+ ('numpy._typing._dtype_like',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_dtype_like.py',
+ 'PYMODULE'),
+ ('numpy._typing._char_codes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_char_codes.py',
+ 'PYMODULE'),
+ ('numpy.f2py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.f2py.diagnose',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\diagnose.py',
+ 'PYMODULE'),
+ ('numpy.f2py.f2py2e',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\f2py2e.py',
+ 'PYMODULE'),
+ ('numpy.f2py.crackfortran',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\crackfortran.py',
+ 'PYMODULE'),
+ ('platform', 'C:\\Python312\\Lib\\platform.py', 'PYMODULE'),
+ ('fileinput', 'C:\\Python312\\Lib\\fileinput.py', 'PYMODULE'),
+ ('numpy.f2py._backends',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_backends\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.f2py._backends._meson',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_backends\\_meson.py',
+ 'PYMODULE'),
+ ('numpy.f2py._backends._backend',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_backends\\_backend.py',
+ 'PYMODULE'),
+ ('numpy.f2py.rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.use_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\use_rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.f90mod_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\f90mod_rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.common_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\common_rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.func2subr',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\func2subr.py',
+ 'PYMODULE'),
+ ('numpy.f2py._isocbind',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_isocbind.py',
+ 'PYMODULE'),
+ ('numpy.f2py.auxfuncs',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\auxfuncs.py',
+ 'PYMODULE'),
+ ('numpy.f2py.symbolic',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\symbolic.py',
+ 'PYMODULE'),
+ ('numpy.f2py.cb_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\cb_rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.capi_maps',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\capi_maps.py',
+ 'PYMODULE'),
+ ('numpy.f2py.cfuncs',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\cfuncs.py',
+ 'PYMODULE'),
+ ('numpy.f2py.__version__',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\__version__.py',
+ 'PYMODULE'),
+ ('numpy.matlib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\matlib.py',
+ 'PYMODULE'),
+ ('numpy.matrixlib.defmatrix',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\matrixlib\\defmatrix.py',
+ 'PYMODULE'),
+ ('numpy.testing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.testing._private.utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\_private\\utils.py',
+ 'PYMODULE'),
+ ('doctest', 'C:\\Python312\\Lib\\doctest.py', 'PYMODULE'),
+ ('pdb', 'C:\\Python312\\Lib\\pdb.py', 'PYMODULE'),
+ ('pydoc', 'C:\\Python312\\Lib\\pydoc.py', 'PYMODULE'),
+ ('webbrowser', 'C:\\Python312\\Lib\\webbrowser.py', 'PYMODULE'),
+ ('http.server', 'C:\\Python312\\Lib\\http\\server.py', 'PYMODULE'),
+ ('socketserver', 'C:\\Python312\\Lib\\socketserver.py', 'PYMODULE'),
+ ('html', 'C:\\Python312\\Lib\\html\\__init__.py', 'PYMODULE'),
+ ('html.entities', 'C:\\Python312\\Lib\\html\\entities.py', 'PYMODULE'),
+ ('pydoc_data.topics',
+ 'C:\\Python312\\Lib\\pydoc_data\\topics.py',
+ 'PYMODULE'),
+ ('pydoc_data', 'C:\\Python312\\Lib\\pydoc_data\\__init__.py', 'PYMODULE'),
+ ('tty', 'C:\\Python312\\Lib\\tty.py', 'PYMODULE'),
+ ('shlex', 'C:\\Python312\\Lib\\shlex.py', 'PYMODULE'),
+ ('glob', 'C:\\Python312\\Lib\\glob.py', 'PYMODULE'),
+ ('code', 'C:\\Python312\\Lib\\code.py', 'PYMODULE'),
+ ('codeop', 'C:\\Python312\\Lib\\codeop.py', 'PYMODULE'),
+ ('bdb', 'C:\\Python312\\Lib\\bdb.py', 'PYMODULE'),
+ ('cmd', 'C:\\Python312\\Lib\\cmd.py', 'PYMODULE'),
+ ('difflib', 'C:\\Python312\\Lib\\difflib.py', 'PYMODULE'),
+ ('unittest.case', 'C:\\Python312\\Lib\\unittest\\case.py', 'PYMODULE'),
+ ('unittest._log', 'C:\\Python312\\Lib\\unittest\\_log.py', 'PYMODULE'),
+ ('unittest.util', 'C:\\Python312\\Lib\\unittest\\util.py', 'PYMODULE'),
+ ('unittest.result', 'C:\\Python312\\Lib\\unittest\\result.py', 'PYMODULE'),
+ ('sysconfig', 'C:\\Python312\\Lib\\sysconfig.py', 'PYMODULE'),
+ ('_aix_support', 'C:\\Python312\\Lib\\_aix_support.py', 'PYMODULE'),
+ ('concurrent.futures',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\__init__.py',
+ 'PYMODULE'),
+ ('concurrent.futures.thread',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\thread.py',
+ 'PYMODULE'),
+ ('concurrent.futures.process',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\process.py',
+ 'PYMODULE'),
+ ('concurrent.futures._base',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\_base.py',
+ 'PYMODULE'),
+ ('concurrent', 'C:\\Python312\\Lib\\concurrent\\__init__.py', 'PYMODULE'),
+ ('numpy.testing._private.extbuild',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\_private\\extbuild.py',
+ 'PYMODULE'),
+ ('numpy.testing.overrides',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\overrides.py',
+ 'PYMODULE'),
+ ('numpy.lib.recfunctions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\recfunctions.py',
+ 'PYMODULE'),
+ ('numpy.lib._iotools',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_iotools.py',
+ 'PYMODULE'),
+ ('numpy.ma.mrecords',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\mrecords.py',
+ 'PYMODULE'),
+ ('numpy.testing._private',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\_private\\__init__.py',
+ 'PYMODULE'),
+ ('unittest', 'C:\\Python312\\Lib\\unittest\\__init__.py', 'PYMODULE'),
+ ('unittest.async_case',
+ 'C:\\Python312\\Lib\\unittest\\async_case.py',
+ 'PYMODULE'),
+ ('asyncio', 'C:\\Python312\\Lib\\asyncio\\__init__.py', 'PYMODULE'),
+ ('asyncio.unix_events',
+ 'C:\\Python312\\Lib\\asyncio\\unix_events.py',
+ 'PYMODULE'),
+ ('asyncio.log', 'C:\\Python312\\Lib\\asyncio\\log.py', 'PYMODULE'),
+ ('asyncio.windows_events',
+ 'C:\\Python312\\Lib\\asyncio\\windows_events.py',
+ 'PYMODULE'),
+ ('asyncio.windows_utils',
+ 'C:\\Python312\\Lib\\asyncio\\windows_utils.py',
+ 'PYMODULE'),
+ ('asyncio.selector_events',
+ 'C:\\Python312\\Lib\\asyncio\\selector_events.py',
+ 'PYMODULE'),
+ ('asyncio.proactor_events',
+ 'C:\\Python312\\Lib\\asyncio\\proactor_events.py',
+ 'PYMODULE'),
+ ('asyncio.base_subprocess',
+ 'C:\\Python312\\Lib\\asyncio\\base_subprocess.py',
+ 'PYMODULE'),
+ ('asyncio.threads', 'C:\\Python312\\Lib\\asyncio\\threads.py', 'PYMODULE'),
+ ('asyncio.taskgroups',
+ 'C:\\Python312\\Lib\\asyncio\\taskgroups.py',
+ 'PYMODULE'),
+ ('asyncio.subprocess',
+ 'C:\\Python312\\Lib\\asyncio\\subprocess.py',
+ 'PYMODULE'),
+ ('asyncio.streams', 'C:\\Python312\\Lib\\asyncio\\streams.py', 'PYMODULE'),
+ ('asyncio.queues', 'C:\\Python312\\Lib\\asyncio\\queues.py', 'PYMODULE'),
+ ('asyncio.runners', 'C:\\Python312\\Lib\\asyncio\\runners.py', 'PYMODULE'),
+ ('asyncio.base_events',
+ 'C:\\Python312\\Lib\\asyncio\\base_events.py',
+ 'PYMODULE'),
+ ('asyncio.trsock', 'C:\\Python312\\Lib\\asyncio\\trsock.py', 'PYMODULE'),
+ ('asyncio.staggered',
+ 'C:\\Python312\\Lib\\asyncio\\staggered.py',
+ 'PYMODULE'),
+ ('asyncio.timeouts', 'C:\\Python312\\Lib\\asyncio\\timeouts.py', 'PYMODULE'),
+ ('asyncio.tasks', 'C:\\Python312\\Lib\\asyncio\\tasks.py', 'PYMODULE'),
+ ('asyncio.base_tasks',
+ 'C:\\Python312\\Lib\\asyncio\\base_tasks.py',
+ 'PYMODULE'),
+ ('asyncio.locks', 'C:\\Python312\\Lib\\asyncio\\locks.py', 'PYMODULE'),
+ ('asyncio.mixins', 'C:\\Python312\\Lib\\asyncio\\mixins.py', 'PYMODULE'),
+ ('asyncio.sslproto', 'C:\\Python312\\Lib\\asyncio\\sslproto.py', 'PYMODULE'),
+ ('asyncio.transports',
+ 'C:\\Python312\\Lib\\asyncio\\transports.py',
+ 'PYMODULE'),
+ ('asyncio.protocols',
+ 'C:\\Python312\\Lib\\asyncio\\protocols.py',
+ 'PYMODULE'),
+ ('asyncio.futures', 'C:\\Python312\\Lib\\asyncio\\futures.py', 'PYMODULE'),
+ ('asyncio.base_futures',
+ 'C:\\Python312\\Lib\\asyncio\\base_futures.py',
+ 'PYMODULE'),
+ ('asyncio.exceptions',
+ 'C:\\Python312\\Lib\\asyncio\\exceptions.py',
+ 'PYMODULE'),
+ ('asyncio.events', 'C:\\Python312\\Lib\\asyncio\\events.py', 'PYMODULE'),
+ ('asyncio.format_helpers',
+ 'C:\\Python312\\Lib\\asyncio\\format_helpers.py',
+ 'PYMODULE'),
+ ('asyncio.coroutines',
+ 'C:\\Python312\\Lib\\asyncio\\coroutines.py',
+ 'PYMODULE'),
+ ('asyncio.constants',
+ 'C:\\Python312\\Lib\\asyncio\\constants.py',
+ 'PYMODULE'),
+ ('unittest.signals', 'C:\\Python312\\Lib\\unittest\\signals.py', 'PYMODULE'),
+ ('unittest.main', 'C:\\Python312\\Lib\\unittest\\main.py', 'PYMODULE'),
+ ('unittest.runner', 'C:\\Python312\\Lib\\unittest\\runner.py', 'PYMODULE'),
+ ('unittest.loader', 'C:\\Python312\\Lib\\unittest\\loader.py', 'PYMODULE'),
+ ('unittest.suite', 'C:\\Python312\\Lib\\unittest\\suite.py', 'PYMODULE'),
+ ('numpy.exceptions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\exceptions.py',
+ 'PYMODULE'),
+ ('numpy.ctypeslib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ctypeslib\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.ctypeslib._ctypeslib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ctypeslib\\_ctypeslib.py',
+ 'PYMODULE'),
+ ('numpy._core._internal',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_internal.py',
+ 'PYMODULE'),
+ ('numpy.ma',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.ma.extras',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\extras.py',
+ 'PYMODULE'),
+ ('numpy.lib.array_utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\array_utils.py',
+ 'PYMODULE'),
+ ('numpy.lib._array_utils_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_array_utils_impl.py',
+ 'PYMODULE'),
+ ('numpy.ma.core',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\core.py',
+ 'PYMODULE'),
+ ('numpy.polynomial',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.polynomial._polybase',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\_polybase.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.polynomial',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\polynomial.py',
+ 'PYMODULE'),
+ ('numpy.linalg._linalg',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\linalg\\_linalg.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.legendre',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\legendre.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.laguerre',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\laguerre.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.hermite_e',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\hermite_e.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.hermite',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\hermite.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.chebyshev',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\chebyshev.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.polyutils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\polyutils.py',
+ 'PYMODULE'),
+ ('numpy.random',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.random._pickle',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_pickle.py',
+ 'PYMODULE'),
+ ('numpy.fft',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.fft._pocketfft',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\_pocketfft.py',
+ 'PYMODULE'),
+ ('numpy.fft._helper',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\_helper.py',
+ 'PYMODULE'),
+ ('numpy.linalg',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\linalg\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._array_api_info',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_array_api_info.py',
+ 'PYMODULE'),
+ ('numpy.lib._utils_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_utils_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._ufunclike_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_ufunclike_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._type_check_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_type_check_impl.py',
+ 'PYMODULE'),
+ ('numpy._core.getlimits',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\getlimits.py',
+ 'PYMODULE'),
+ ('numpy.lib._twodim_base_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_twodim_base_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._stride_tricks_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_stride_tricks_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._shape_base_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_shape_base_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._polynomial_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_polynomial_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._npyio_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_npyio_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._format_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_format_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._datasource',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_datasource.py',
+ 'PYMODULE'),
+ ('numpy.lib.format',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\format.py',
+ 'PYMODULE'),
+ ('numpy.lib._nanfunctions_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_nanfunctions_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._index_tricks_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_index_tricks_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._histograms_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_histograms_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._function_base_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_function_base_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._arraysetops_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_arraysetops_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._arraypad_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_arraypad_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib.scimath',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\scimath.py',
+ 'PYMODULE'),
+ ('numpy.lib._scimath_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_scimath_impl.py',
+ 'PYMODULE'),
+ ('numpy.matrixlib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\matrixlib\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.lib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.lib.stride_tricks',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\stride_tricks.py',
+ 'PYMODULE'),
+ ('numpy.lib.npyio',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\npyio.py',
+ 'PYMODULE'),
+ ('numpy.lib.mixins',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\mixins.py',
+ 'PYMODULE'),
+ ('numpy.lib.introspect',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\introspect.py',
+ 'PYMODULE'),
+ ('numpy.lib._version',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_version.py',
+ 'PYMODULE'),
+ ('numpy.lib._arrayterator_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_arrayterator_impl.py',
+ 'PYMODULE'),
+ ('numpy._core.function_base',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\function_base.py',
+ 'PYMODULE'),
+ ('numpy._core.printoptions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\printoptions.py',
+ 'PYMODULE'),
+ ('numpy._core.memmap',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\memmap.py',
+ 'PYMODULE'),
+ ('numpy._core',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._core._dtype',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_dtype.py',
+ 'PYMODULE'),
+ ('numpy._core._add_newdocs_scalars',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_add_newdocs_scalars.py',
+ 'PYMODULE'),
+ ('numpy._core._add_newdocs',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_add_newdocs.py',
+ 'PYMODULE'),
+ ('numpy._core.einsumfunc',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\einsumfunc.py',
+ 'PYMODULE'),
+ ('numpy.__config__',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\__config__.py',
+ 'PYMODULE'),
+ ('numpy._distributor_init',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_distributor_init.py',
+ 'PYMODULE'),
+ ('numpy._globals',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_globals.py',
+ 'PYMODULE'),
+ ('numpy.dtypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\dtypes.py',
+ 'PYMODULE'),
+ ('numpy._expired_attrs_2_0',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_expired_attrs_2_0.py',
+ 'PYMODULE'),
+ ('numpy.version',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\version.py',
+ 'PYMODULE'),
+ ('numpy.core.multiarray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\core\\multiarray.py',
+ 'PYMODULE'),
+ ('typing', 'C:\\Python312\\Lib\\typing.py', 'PYMODULE'),
+ ('pathlib', 'C:\\Python312\\Lib\\pathlib.py', 'PYMODULE'),
+ ('datetime', 'C:\\Python312\\Lib\\datetime.py', 'PYMODULE'),
+ ('_pydatetime', 'C:\\Python312\\Lib\\_pydatetime.py', 'PYMODULE'),
+ ('_strptime', 'C:\\Python312\\Lib\\_strptime.py', 'PYMODULE'),
+ ('dataclasses', 'C:\\Python312\\Lib\\dataclasses.py', 'PYMODULE'),
+ ('shutil', 'C:\\Python312\\Lib\\shutil.py', 'PYMODULE'),
+ ('tarfile', 'C:\\Python312\\Lib\\tarfile.py', 'PYMODULE'),
+ ('json', 'C:\\Python312\\Lib\\json\\__init__.py', 'PYMODULE'),
+ ('json.encoder', 'C:\\Python312\\Lib\\json\\encoder.py', 'PYMODULE'),
+ ('json.decoder', 'C:\\Python312\\Lib\\json\\decoder.py', 'PYMODULE'),
+ ('json.scanner', 'C:\\Python312\\Lib\\json\\scanner.py', 'PYMODULE'),
+ ('csv', 'C:\\Python312\\Lib\\csv.py', 'PYMODULE'),
+ ('argparse', 'C:\\Python312\\Lib\\argparse.py', 'PYMODULE'),
+ ('__future__', 'C:\\Python312\\Lib\\__future__.py', 'PYMODULE')],
+ [('cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'BINARY'),
+ ('python312.dll', 'C:\\Python312\\python312.dll', 'BINARY'),
+ ('numpy.libs\\msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy.libs\\msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll',
+ 'BINARY'),
+ ('numpy.libs\\libscipy_openblas64_-327b2e0bcffce2882e0dc04cdeb4eaa6.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy.libs\\libscipy_openblas64_-327b2e0bcffce2882e0dc04cdeb4eaa6.dll',
+ 'BINARY'),
+ ('select.pyd', 'C:\\Python312\\DLLs\\select.pyd', 'EXTENSION'),
+ ('_multiprocessing.pyd',
+ 'C:\\Python312\\DLLs\\_multiprocessing.pyd',
+ 'EXTENSION'),
+ ('pyexpat.pyd', 'C:\\Python312\\DLLs\\pyexpat.pyd', 'EXTENSION'),
+ ('_ssl.pyd', 'C:\\Python312\\DLLs\\_ssl.pyd', 'EXTENSION'),
+ ('_hashlib.pyd', 'C:\\Python312\\DLLs\\_hashlib.pyd', 'EXTENSION'),
+ ('unicodedata.pyd', 'C:\\Python312\\DLLs\\unicodedata.pyd', 'EXTENSION'),
+ ('_decimal.pyd', 'C:\\Python312\\DLLs\\_decimal.pyd', 'EXTENSION'),
+ ('_socket.pyd', 'C:\\Python312\\DLLs\\_socket.pyd', 'EXTENSION'),
+ ('_ctypes.pyd', 'C:\\Python312\\DLLs\\_ctypes.pyd', 'EXTENSION'),
+ ('_queue.pyd', 'C:\\Python312\\DLLs\\_queue.pyd', 'EXTENSION'),
+ ('_lzma.pyd', 'C:\\Python312\\DLLs\\_lzma.pyd', 'EXTENSION'),
+ ('_bz2.pyd', 'C:\\Python312\\DLLs\\_bz2.pyd', 'EXTENSION'),
+ ('cv2\\cv2.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\cv2.pyd',
+ 'EXTENSION'),
+ ('_tkinter.pyd', 'C:\\Python312\\DLLs\\_tkinter.pyd', 'EXTENSION'),
+ ('numpy\\_core\\_multiarray_tests.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_multiarray_tests.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\_core\\_multiarray_umath.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_multiarray_umath.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('_wmi.pyd', 'C:\\Python312\\DLLs\\_wmi.pyd', 'EXTENSION'),
+ ('numpy\\linalg\\_umath_linalg.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\linalg\\_umath_linalg.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('_overlapped.pyd', 'C:\\Python312\\DLLs\\_overlapped.pyd', 'EXTENSION'),
+ ('_asyncio.pyd', 'C:\\Python312\\DLLs\\_asyncio.pyd', 'EXTENSION'),
+ ('numpy\\random\\mtrand.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\mtrand.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\bit_generator.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\bit_generator.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_sfc64.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_sfc64.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_philox.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_philox.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_pcg64.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_pcg64.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_mt19937.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_mt19937.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_generator.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_generator.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_common.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_common.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_bounded_integers.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_bounded_integers.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\fft\\_pocketfft_umath.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\_pocketfft_umath.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('api-ms-win-crt-string-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-string-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-process-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-process-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-stdio-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-stdio-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-math-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-math-l1-1-0.dll',
+ 'BINARY'),
+ ('VCRUNTIME140.dll', 'C:\\Python312\\VCRUNTIME140.dll', 'BINARY'),
+ ('api-ms-win-crt-conio-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-conio-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-convert-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-convert-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-environment-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-environment-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-time-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-time-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-filesystem-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-filesystem-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-heap-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-heap-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-locale-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-locale-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-runtime-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-runtime-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-utility-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-utility-l1-1-0.dll',
+ 'BINARY'),
+ ('VCRUNTIME140_1.dll', 'C:\\Python312\\VCRUNTIME140_1.dll', 'BINARY'),
+ ('api-ms-win-crt-private-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-private-l1-1-0.dll',
+ 'BINARY'),
+ ('libssl-3.dll', 'C:\\Python312\\DLLs\\libssl-3.dll', 'BINARY'),
+ ('libcrypto-3.dll', 'C:\\Python312\\DLLs\\libcrypto-3.dll', 'BINARY'),
+ ('libffi-8.dll', 'C:\\Python312\\DLLs\\libffi-8.dll', 'BINARY'),
+ ('python3.dll', 'C:\\Python312\\python3.dll', 'BINARY'),
+ ('tcl86t.dll', 'C:\\Python312\\DLLs\\tcl86t.dll', 'BINARY'),
+ ('tk86t.dll', 'C:\\Python312\\DLLs\\tk86t.dll', 'BINARY'),
+ ('ucrtbase.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\ucrtbase.dll',
+ 'BINARY'),
+ ('zlib1.dll', 'C:\\Python312\\DLLs\\zlib1.dll', 'BINARY'),
+ ('api-ms-win-core-file-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-file-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-sysinfo-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-sysinfo-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-file-l1-2-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-file-l1-2-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-processthreads-l1-1-1.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-processthreads-l1-1-1.dll',
+ 'BINARY'),
+ ('api-ms-win-core-processenvironment-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-processenvironment-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-libraryloader-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-libraryloader-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-datetime-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-datetime-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-memory-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-memory-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-synch-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-synch-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-debug-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-debug-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-rtlsupport-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-rtlsupport-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-synch-l1-2-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-synch-l1-2-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-string-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-string-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-interlocked-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-interlocked-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-util-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-util-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-processthreads-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-processthreads-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-file-l2-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-file-l2-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-profile-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-profile-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-heap-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-heap-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-console-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-console-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-handle-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-handle-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-timezone-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-timezone-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-namedpipe-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-namedpipe-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-errorhandling-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-errorhandling-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-fibers-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-fibers-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-localization-l1-2-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-localization-l1-2-0.dll',
+ 'BINARY')],
+ [],
+ [],
+ [('cv2\\Error\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\Error\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\LICENSE-3RD-PARTY.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\LICENSE-3RD-PARTY.txt',
+ 'DATA'),
+ ('cv2\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\LICENSE.txt',
+ 'DATA'),
+ ('cv2\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\__init__.py',
+ 'DATA'),
+ ('cv2\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\aruco\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\aruco\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\barcode\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\barcode\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ccm\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ccm\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\config-3.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config-3.py',
+ 'DATA'),
+ ('cv2\\config.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config.py',
+ 'DATA'),
+ ('cv2\\cuda\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\cuda\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\data\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\data\\__init__.py',
+ 'DATA'),
+ ('cv2\\detail\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\detail\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\dnn\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\dnn\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\fisheye\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\fisheye\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\flann\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\flann\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\instr\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\instr\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ipp\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ipp\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\load_config_py2.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py2.py',
+ 'DATA'),
+ ('cv2\\load_config_py3.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py3.py',
+ 'DATA'),
+ ('cv2\\mat_wrapper\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\mat_wrapper\\__init__.py',
+ 'DATA'),
+ ('cv2\\mcc\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\mcc\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\misc\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\__init__.py',
+ 'DATA'),
+ ('cv2\\misc\\version.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\version.py',
+ 'DATA'),
+ ('cv2\\ocl\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ocl\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ogl\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ogl\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\parallel\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\parallel\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\py.typed',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\py.typed',
+ 'DATA'),
+ ('cv2\\samples\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\samples\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\segmentation\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\segmentation\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\typing\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\typing\\__init__.py',
+ 'DATA'),
+ ('cv2\\utils\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\__init__.py',
+ 'DATA'),
+ ('cv2\\utils\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\fs\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\fs\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\logging\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\logging\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\nested\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\nested\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\version.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\version.py',
+ 'DATA'),
+ ('cv2\\videoio_registry\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\videoio_registry\\__init__.pyi',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\INSTALLER',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\INSTALLER',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\LICENSE-3RD-PARTY.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\LICENSE-3RD-PARTY.txt',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\LICENSE.txt',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\METADATA',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\METADATA',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\RECORD',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\RECORD',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\REQUESTED',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\REQUESTED',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\WHEEL',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\WHEEL',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\top_level.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\top_level.txt',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\London',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\London',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb2312-raw.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb2312-raw.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\HST10',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\HST10',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Johns',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Johns',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Port-au-Prince',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Port-au-Prince',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tomsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tomsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Tell_City',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Tell_City',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ru.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ru.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\UCT', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\UCT', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Anchorage',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Anchorage',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\DumontDUrville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\DumontDUrville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Canary',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Canary',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santa_Isabel',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santa_Isabel',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Nairobi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Nairobi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Windhoek',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Windhoek',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Ensenada',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Ensenada',
+ 'DATA'),
+ ('_tk_data\\images\\logoMed.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logoMed.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Dar_es_Salaam',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Dar_es_Salaam',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Knox_IN',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Knox_IN',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Barbados',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Barbados',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Bucharest',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Bucharest',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Chita',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Chita',
+ 'DATA'),
+ ('_tcl_data\\msgs\\th.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\th.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Barthelemy',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Barthelemy',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Port_of_Spain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Port_of_Spain',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Mountain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Mountain',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Brazzaville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Brazzaville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Luanda',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Luanda',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Maceio',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Maceio',
+ 'DATA'),
+ ('_tk_data\\msgs\\it.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\it.msg', 'DATA'),
+ ('_tcl_data\\msgs\\es_sv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_sv.msg',
+ 'DATA'),
+ ('_tcl_data\\http1.0\\http.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\http1.0\\http.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Atyrau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Atyrau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Cape_Verde',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Cape_Verde',
+ 'DATA'),
+ ('_tk_data\\pkgIndex.tcl', 'C:\\Python312\\tcl\\tk8.6\\pkgIndex.tcl', 'DATA'),
+ ('_tcl_data\\encoding\\euc-cn.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\euc-cn.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Jan_Mayen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Jan_Mayen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Godthab',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Godthab',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_be.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Wake',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Wake',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sr.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Urumqi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Urumqi',
+ 'DATA'),
+ ('_tk_data\\ttk\\xpTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\xpTheme.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Swift_Current',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Swift_Current',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_ca.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_ca.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\it_ch.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\it_ch.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+12',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+12',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fa_ir.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fa_ir.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Saskatchewan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Saskatchewan',
+ 'DATA'),
+ ('_tk_data\\msgbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\msgbox.tcl', 'DATA'),
+ ('_tcl_data\\msgs\\ms_my.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ms_my.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Port_Moresby',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Port_Moresby',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Gibraltar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Gibraltar',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_zw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_zw.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso2022-jp.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso2022-jp.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Volgograd',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Volgograd',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Casablanca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Casablanca',
+ 'DATA'),
+ ('_tk_data\\images\\logo.eps',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logo.eps',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Indiana-Starke',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Indiana-Starke',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Moscow',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Moscow',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Vincent',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Vincent',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1257.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1257.enc',
+ 'DATA'),
+ ('tcl8\\8.6\\http-2.9.8.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.6\\http-2.9.8.tm',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Arizona',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Arizona',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macDingbats.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macDingbats.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Maputo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Maputo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\ROK', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\ROK', 'DATA'),
+ ('_tcl_data\\msgs\\ar_jo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_jo.msg',
+ 'DATA'),
+ ('tcl8\\8.4\\platform-1.0.19.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.4\\platform-1.0.19.tm',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nn.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cancun',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cancun',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Irkutsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Irkutsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Regina',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Regina',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Ljubljana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Ljubljana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Manila',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Manila',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Aqtobe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Aqtobe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Noronha',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Noronha',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Johannesburg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Johannesburg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Kinshasa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Kinshasa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Libreville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Libreville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yangon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yangon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Nouakchott',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Nouakchott',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Iceland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Iceland',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-3.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-3.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-1.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-1.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Sitka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Sitka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Punta_Arenas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Punta_Arenas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Stanley',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Stanley',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Tegucigalpa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Tegucigalpa',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp775.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp775.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Christmas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Christmas',
+ 'DATA'),
+ ('_tcl_data\\tm.tcl', 'C:\\Python312\\tcl\\tcl8.6\\tm.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Catamarca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Catamarca',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Sao_Paulo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Sao_Paulo',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hr.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Jakarta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Jakarta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Fortaleza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Fortaleza',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Marigot',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Marigot',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Antananarivo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Antananarivo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Abidjan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Abidjan',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ta_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ta_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-2',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-2',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Riyadh',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Riyadh',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\ComodRivadavia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\ComodRivadavia',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_mx.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_mx.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\pt_br.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\pt_br.msg',
+ 'DATA'),
+ ('_tk_data\\msgs\\fi.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\fi.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Asmera',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Asmera',
+ 'DATA'),
+ ('_tk_data\\bgerror.tcl', 'C:\\Python312\\tcl\\tk8.6\\bgerror.tcl', 'DATA'),
+ ('_tcl_data\\msgs\\en_hk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_hk.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sl.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Currie',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Currie',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Pyongyang',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Pyongyang',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Universal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Universal',
+ 'DATA'),
+ ('_tk_data\\megawidget.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\megawidget.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Araguaina',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Araguaina',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Isle_of_Man',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Isle_of_Man',
+ 'DATA'),
+ ('_tk_data\\iconlist.tcl', 'C:\\Python312\\tcl\\tk8.6\\iconlist.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\South',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\South',
+ 'DATA'),
+ ('_tcl_data\\msgs\\af_za.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\af_za.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sv.msg',
+ 'DATA'),
+ ('_tk_data\\comdlg.tcl', 'C:\\Python312\\tcl\\tk8.6\\comdlg.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\GB', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GB', 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Douala',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Douala',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Managua',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Managua',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp949.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp949.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Chungking',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Chungking',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Broken_Hill',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Broken_Hill',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Grand_Turk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Grand_Turk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Macquarie',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Macquarie',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Atlantic',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Atlantic',
+ 'DATA'),
+ ('_tcl_data\\msgs\\is.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\is.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kiev',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kiev',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Simferopol',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Simferopol',
+ 'DATA'),
+ ('_tcl_data\\msgs\\tr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\tr.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Whitehorse',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Whitehorse',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Yap',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Yap',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santo_Domingo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santo_Domingo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Singapore',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Singapore',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Lucia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Lucia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Muscat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Muscat',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\San_Juan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\San_Juan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guatemala',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guatemala',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\DeNoronha',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\DeNoronha',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Dawson_Creek',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Dawson_Creek',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo75.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo75.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Dominica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Dominica',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-9.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-9.enc',
+ 'DATA'),
+ ('_tk_data\\msgs\\pl.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\pl.msg', 'DATA'),
+ ('_tcl_data\\msgs\\ro.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ro.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Almaty',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Almaty',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Porto-Novo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Porto-Novo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Resolute',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Resolute',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Asuncion',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Asuncion',
+ 'DATA'),
+ ('_tcl_data\\word.tcl', 'C:\\Python312\\tcl\\tcl8.6\\word.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\MST7MDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\MST7MDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Coral_Harbour',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Coral_Harbour',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+10',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+10',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Hong_Kong',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Hong_Kong',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Azores',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Azores',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Palmer',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Palmer',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Magadan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Magadan',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_ie.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_ie.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Mexico_City',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Mexico_City',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp869.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp869.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\euc-kr.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\euc-kr.enc',
+ 'DATA'),
+ ('_tk_data\\msgs\\hu.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\hu.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Bahrain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Bahrain',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Belgrade',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Belgrade',
+ 'DATA'),
+ ('_tk_data\\ttk\\spinbox.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\spinbox.tcl',
+ 'DATA'),
+ ('_tk_data\\button.tcl', 'C:\\Python312\\tcl\\tk8.6\\button.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Tijuana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Tijuana',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_au.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_au.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Brisbane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Brisbane',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+5',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+5',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\West',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\West',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_tw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_tw.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Ouagadougou',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Ouagadougou',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\W-SU',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\W-SU',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kosrae',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kosrae',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Chatham',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Chatham',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Fakaofo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Fakaofo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Shiprock',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Shiprock',
+ 'DATA'),
+ ('_tcl_data\\msgs\\af.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\af.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Dublin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Dublin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Budapest',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Budapest',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Mauritius',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Mauritius',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Juneau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Juneau',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-7.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-7.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Tarawa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Tarawa',
+ 'DATA'),
+ ('_tk_data\\ttk\\utils.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\utils.tcl',
+ 'DATA'),
+ ('_tcl_data\\msgs\\uk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\uk.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GMT+0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT+0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Mawson',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Mawson',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Hermosillo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Hermosillo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+6',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+6',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kamchatka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kamchatka',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp857.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp857.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Jamaica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Jamaica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Athens',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Athens',
+ 'DATA'),
+ ('_tcl_data\\msgs\\id_id.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\id_id.msg',
+ 'DATA'),
+ ('_tk_data\\optMenu.tcl', 'C:\\Python312\\tcl\\tk8.6\\optMenu.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ho_Chi_Minh',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ho_Chi_Minh',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Samara',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Samara',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lubumbashi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lubumbashi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Thomas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Thomas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Porto_Acre',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Porto_Acre',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Winamac',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Winamac',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\UTC',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\UTC',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Freetown',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Freetown',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Bougainville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Bougainville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Famagusta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Famagusta',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es.msg',
+ 'DATA'),
+ ('_tk_data\\license.terms',
+ 'C:\\Python312\\tcl\\tk8.6\\license.terms',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Gaza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Gaza',
+ 'DATA'),
+ ('_tk_data\\images\\logo100.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logo100.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-7',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-7',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tashkent',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tashkent',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_gb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_gb.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ta.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ta.msg',
+ 'DATA'),
+ ('_tk_data\\msgs\\zh_cn.msg',
+ 'C:\\Python312\\tcl\\tk8.6\\msgs\\zh_cn.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Kampala',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Kampala',
+ 'DATA'),
+ ('_tk_data\\ttk\\combobox.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\combobox.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Makassar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Makassar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vatican',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vatican',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_cr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_cr.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Faroe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Faroe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Aqtau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Aqtau',
+ 'DATA'),
+ ('_tcl_data\\init.tcl', 'C:\\Python312\\tcl\\tcl8.6\\init.tcl', 'DATA'),
+ ('_tcl_data\\encoding\\cns11643.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cns11643.enc',
+ 'DATA'),
+ ('_tk_data\\ttk\\ttk.tcl', 'C:\\Python312\\tcl\\tk8.6\\ttk\\ttk.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Queensland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Queensland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\LHI',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\LHI',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fi.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fi.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Troll',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Troll',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Jamaica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Jamaica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Comoro',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Comoro',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Reykjavik',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Reykjavik',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Qyzylorda',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Qyzylorda',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar_sy.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_sy.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Shanghai',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Shanghai',
+ 'DATA'),
+ ('_tk_data\\ttk\\cursors.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\cursors.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\notebook.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\notebook.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yekaterinburg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yekaterinburg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Galapagos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Galapagos',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kw.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_co.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_co.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Anguilla',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Anguilla',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Martinique',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Martinique',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Ojinaga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Ojinaga',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Petersburg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Petersburg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ve.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ve.msg',
+ 'DATA'),
+ ('_tk_data\\scrlbar.tcl', 'C:\\Python312\\tcl\\tk8.6\\scrlbar.tcl', 'DATA'),
+ ('_tk_data\\ttk\\fonts.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\fonts.tcl',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo200.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo200.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kathmandu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kathmandu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Nicosia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Nicosia',
+ 'DATA'),
+ ('_tcl_data\\msgs\\mk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mk.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Luxembourg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Luxembourg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Blantyre',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Blantyre',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+7',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+7',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Khandyga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Khandyga',
+ 'DATA'),
+ ('_tcl_data\\encoding\\shiftjis.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\shiftjis.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fo_fo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fo_fo.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\La_Rioja',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\La_Rioja',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Algiers',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Algiers',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Bratislava',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Bratislava',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Monaco',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Monaco',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Eastern',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Eastern',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sq.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sq.msg',
+ 'DATA'),
+ ('_tk_data\\msgs\\eo.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\eo.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Copenhagen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Copenhagen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Baku',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Baku',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Antigua',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Antigua',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp936.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp936.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\be.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Chongqing',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Chongqing',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Andorra',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Andorra',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Djibouti',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Djibouti',
+ 'DATA'),
+ ('_tcl_data\\msgs\\bn_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\bn_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\St_Helena',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\St_Helena',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_nz.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_nz.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\eu_es.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\eu_es.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Amman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Amman',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\PST8PDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\PST8PDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Harbin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Harbin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\Greenwich',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\Greenwich',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Creston',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Creston',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Inuvik',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Inuvik',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kok_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kok_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Tahiti',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Tahiti',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Maldives',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Maldives',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Malabo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Malabo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Sydney',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Sydney',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Chicago',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Chicago',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Phoenix',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Phoenix',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_py.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_py.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hu.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hu.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Detroit',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Detroit',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Belize',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Belize',
+ 'DATA'),
+ ('_tk_data\\images\\tai-ku.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\tai-ku.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Palau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Palau',
+ 'DATA'),
+ ('_tcl_data\\parray.tcl', 'C:\\Python312\\tcl\\tcl8.6\\parray.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Eucla',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Eucla',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kashgar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kashgar',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_pe.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_pe.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Fiji',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Fiji',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GMT-0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT-0',
+ 'DATA'),
+ ('_tcl_data\\encoding\\symbol.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\symbol.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Davis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Davis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Kentucky\\Monticello',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Kentucky\\Monticello',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Ushuaia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Ushuaia',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1253.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1253.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp862.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp862.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-10.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-10.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Eastern',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Eastern',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-15.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-15.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\EST5EDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\EST5EDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Zagreb',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Zagreb',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Cordoba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Cordoba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Juba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Juba',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp874.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp874.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_cn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_cn.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Rome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Rome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Niamey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Niamey',
+ 'DATA'),
+ ('_tk_data\\icons.tcl', 'C:\\Python312\\tcl\\tk8.6\\icons.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Thunder_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Thunder_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Johnston',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Johnston',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp932.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp932.enc',
+ 'DATA'),
+ ('_tcl_data\\package.tcl', 'C:\\Python312\\tcl\\tcl8.6\\package.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Lima',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Lima',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macCroatian.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macCroatian.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr_ca.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr_ca.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Yancowinna',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Yancowinna',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\MET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\MET', 'DATA'),
+ ('_tcl_data\\msgs\\bg.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\bg.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ko.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ko.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Hebron',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Hebron',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-2.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-2.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Ponape',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Ponape',
+ 'DATA'),
+ ('_tk_data\\ttk\\altTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\altTheme.tcl',
+ 'DATA'),
+ ('_tcl_data\\opt0.4\\optparse.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\opt0.4\\optparse.tcl',
+ 'DATA'),
+ ('_tk_data\\msgs\\en_gb.msg',
+ 'C:\\Python312\\tcl\\tk8.6\\msgs\\en_gb.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Lord_Howe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Lord_Howe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vaduz',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vaduz',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\East-Indiana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\East-Indiana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\South_Georgia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\South_Georgia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Puerto_Rico',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Puerto_Rico',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Khartoum',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Khartoum',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Israel',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Israel',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Jujuy',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Jujuy',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Denver',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Denver',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Porto_Velho',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Porto_Velho',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_pr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_pr.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Truk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Truk',
+ 'DATA'),
+ ('_tk_data\\mkpsenc.tcl', 'C:\\Python312\\tcl\\tk8.6\\mkpsenc.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Bogota',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Bogota',
+ 'DATA'),
+ ('_tk_data\\safetk.tcl', 'C:\\Python312\\tcl\\tk8.6\\safetk.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Bahia_Banderas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Bahia_Banderas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Pohnpei',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Pohnpei',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\North',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\North',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Chagos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Chagos',
+ 'DATA'),
+ ('_tk_data\\clrpick.tcl', 'C:\\Python312\\tcl\\tk8.6\\clrpick.tcl', 'DATA'),
+ ('_tk_data\\ttk\\button.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\button.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\panedwindow.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\panedwindow.tcl',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp865.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp865.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Reunion',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Reunion',
+ 'DATA'),
+ ('_tk_data\\choosedir.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\choosedir.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Lisbon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Lisbon',
+ 'DATA'),
+ ('_tk_data\\dialog.tcl', 'C:\\Python312\\tcl\\tk8.6\\dialog.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Brunei',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Brunei',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Brussels',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Brussels',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Grenada',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Grenada',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kirov',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kirov',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Madeira',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Madeira',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\North_Dakota\\New_Salem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\North_Dakota\\New_Salem',
+ 'DATA'),
+ ('tcl8\\8.4\\platform\\shell-1.1.4.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.4\\platform\\shell-1.1.4.tm',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kiritimati',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kiritimati',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Marengo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Marengo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Sakhalin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Sakhalin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\MST7',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\MST7',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Tongatapu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Tongatapu',
+ 'DATA'),
+ ('_tk_data\\msgs\\ru.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\ru.msg', 'DATA'),
+ ('_tcl_data\\msgs\\mt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mt.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb1988.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb1988.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\te_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\te_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tehran',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tehran',
+ 'DATA'),
+ ('_tk_data\\ttk\\defaults.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\defaults.tcl',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_gt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_gt.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\CET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\CET', 'DATA'),
+ ('_tcl_data\\tzdata\\GB-Eire',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GB-Eire',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Guadalcanal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Guadalcanal',
+ 'DATA'),
+ ('_tk_data\\ttk\\sizegrip.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\sizegrip.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Qostanay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Qostanay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Jujuy',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Jujuy',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Easter',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Easter',
+ 'DATA'),
+ ('_tcl_data\\msgs\\lv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\lv.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-6.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-6.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GMT0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT0',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp737.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp737.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-16.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-16.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Cuba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Cuba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+1',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+1',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_pa.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_pa.msg',
+ 'DATA'),
+ ('_tk_data\\ttk\\entry.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\entry.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ust-Nera',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ust-Nera',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-1',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-1',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp855.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp855.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Guernsey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Guernsey',
+ 'DATA'),
+ ('_tcl_data\\tclIndex', 'C:\\Python312\\tcl\\tcl8.6\\tclIndex', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Omsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Omsk',
+ 'DATA'),
+ ('_tcl_data\\msgs\\eu.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\eu.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sk.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Dawson',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Dawson',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Buenos_Aires',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Buenos_Aires',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rainy_River',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rainy_River',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Caracas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Caracas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Wallis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Wallis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Seoul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Seoul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kanton',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kanton',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Tasmania',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Tasmania',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Vancouver',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Vancouver',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nassau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nassau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guadeloupe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guadeloupe',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_sg.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_sg.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp860.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp860.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Los_Angeles',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Los_Angeles',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Egypt',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Egypt',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lagos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lagos',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso2022-kr.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso2022-kr.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Pacific',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Pacific',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Enderbury',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Enderbury',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Chuuk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Chuuk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-13',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-13',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_bo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_bo.msg',
+ 'DATA'),
+ ('_tk_data\\ttk\\menubutton.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\menubutton.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Catamarca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Catamarca',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Dakar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Dakar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\North_Dakota\\Center',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\North_Dakota\\Center',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Boa_Vista',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Boa_Vista',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\El_Salvador',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\El_Salvador',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Madrid',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Madrid',
+ 'DATA'),
+ ('_tcl_data\\msgs\\te.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\te.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Efate',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Efate',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\New_York',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\New_York',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-3',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-3',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Phnom_Penh',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Phnom_Penh',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Mendoza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Mendoza',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Salta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Salta',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_ph.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_ph.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ashkhabad',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ashkhabad',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\AST4',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\AST4',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sw.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Mayotte',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Mayotte',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Menominee',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Menominee',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Fort_Nelson',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Fort_Nelson',
+ 'DATA'),
+ ('_tcl_data\\msgs\\de_at.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\de_at.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\PST8PDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\PST8PDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\El_Aaiun',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\El_Aaiun',
+ 'DATA'),
+ ('_tcl_data\\encoding\\koi8-u.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\koi8-u.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Astrakhan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Astrakhan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kaliningrad',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kaliningrad',
+ 'DATA'),
+ ('_tcl_data\\encoding\\ksc5601.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\ksc5601.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macCyrillic.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macCyrillic.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cordoba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cordoba',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ec.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ec.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kwajalein',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kwajalein',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Amsterdam',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Amsterdam',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Adak',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Adak',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vilnius',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vilnius',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Turkey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Turkey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Hongkong',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Hongkong',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Montreal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Montreal',
+ 'DATA'),
+ ('_tk_data\\listbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\listbox.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dhaka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dhaka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Yakutat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Yakutat',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Ceuta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Ceuta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\CST6',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\CST6',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Vladivostok',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Vladivostok',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Chisinau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Chisinau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Pacific',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Pacific',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_cl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_cl.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\jis0212.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\jis0212.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Norfolk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Norfolk',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gv.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Tirane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Tirane',
+ 'DATA'),
+ ('_tcl_data\\http1.0\\pkgIndex.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\http1.0\\pkgIndex.tcl',
+ 'DATA'),
+ ('tcl8\\8.5\\msgcat-1.6.1.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.5\\msgcat-1.6.1.tm',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kyiv',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kyiv',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Mogadishu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Mogadishu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\Universal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\Universal',
+ 'DATA'),
+ ('_tk_data\\obsolete.tcl', 'C:\\Python312\\tcl\\tk8.6\\obsolete.tcl', 'DATA'),
+ ('_tk_data\\ttk\\vistaTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\vistaTheme.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bissau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bissau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cayman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cayman',
+ 'DATA'),
+ ('_tcl_data\\msgs\\el.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\el.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macCentEuro.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macCentEuro.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Vevay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Vevay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Rothera',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Rothera',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ni.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ni.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\South_Pole',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\South_Pole',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Buenos_Aires',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Buenos_Aires',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Cocos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Cocos',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Miquelon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Miquelon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Libya',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Libya',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fa.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fa.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Kerguelen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Kerguelen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ashgabat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ashgabat',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Baghdad',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Baghdad',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Bermuda',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Bermuda',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\ROC', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\ROC', 'DATA'),
+ ('_tcl_data\\msgs\\he.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\he.msg',
+ 'DATA'),
+ ('_tk_data\\msgs\\de.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\de.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Karachi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Karachi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ulaanbaatar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ulaanbaatar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kuwait',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kuwait',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Fort_Wayne',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Fort_Wayne',
+ 'DATA'),
+ ('_tcl_data\\encoding\\dingbats.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\dingbats.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Mahe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Mahe',
+ 'DATA'),
+ ('_tk_data\\panedwindow.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\panedwindow.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Mexico\\BajaNorte',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Mexico\\BajaNorte',
+ 'DATA'),
+ ('_tk_data\\console.tcl', 'C:\\Python312\\tcl\\tk8.6\\console.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rosario',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rosario',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb12345.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb12345.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Jayapura',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Jayapura',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ga_ie.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ga_ie.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Thule',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Thule',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Thimbu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Thimbu',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_hn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_hn.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nb.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Eirunepe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Eirunepe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\NZ-CHAT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\NZ-CHAT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Winnipeg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Winnipeg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\EET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\EET', 'DATA'),
+ ('_tcl_data\\opt0.4\\pkgIndex.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\opt0.4\\pkgIndex.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Arctic\\Longyearbyen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Arctic\\Longyearbyen',
+ 'DATA'),
+ ('_tk_data\\focus.tcl', 'C:\\Python312\\tcl\\tk8.6\\focus.tcl', 'DATA'),
+ ('_tk_data\\tclIndex', 'C:\\Python312\\tcl\\tk8.6\\tclIndex', 'DATA'),
+ ('_tcl_data\\tzdata\\Poland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Poland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Pago_Pago',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Pago_Pago',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tokyo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tokyo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Pangnirtung',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Pangnirtung',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cuiaba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cuiaba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Midway',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Midway',
+ 'DATA'),
+ ('_tcl_data\\msgs\\cs.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\cs.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macThai.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macThai.enc',
+ 'DATA'),
+ ('_tk_data\\palette.tcl', 'C:\\Python312\\tcl\\tk8.6\\palette.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Maseru',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Maseru',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+8',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+8',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Marquesas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Marquesas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Qatar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Qatar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Syowa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Syowa',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sh.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sh.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Scoresbysund',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Scoresbysund',
+ 'DATA'),
+ ('_tcl_data\\msgs\\pt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\pt.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\tis-620.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\tis-620.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Beirut',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Beirut',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dili',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dili',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Thimphu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Thimphu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Calcutta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Calcutta',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kw_gb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kw_gb.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Sofia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Sofia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\San_Luis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\San_Luis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dubai',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dubai',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ko_kr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ko_kr.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Metlakatla',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Metlakatla',
+ 'DATA'),
+ ('_tk_data\\ttk\\classicTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\classicTheme.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Taipei',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Taipei',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vienna',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vienna',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nipigon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nipigon',
+ 'DATA'),
+ ('_tk_data\\ttk\\scrollbar.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\scrollbar.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cayenne',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cayenne',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Goose_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Goose_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+9',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+9',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Saigon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Saigon',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_za.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_za.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Barnaul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Barnaul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Jerusalem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Jerusalem',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kuala_Lumpur',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kuala_Lumpur',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1255.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1255.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Iran',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Iran',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Virgin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Virgin',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp866.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp866.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fo.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp863.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp863.enc',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo.eps',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo.eps',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo175.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo175.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-6',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-6',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Greenwich',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Greenwich',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rio_Branco',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rio_Branco',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gl.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Krasnoyarsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Krasnoyarsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\YST9YDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\YST9YDT',
+ 'DATA'),
+ ('_tcl_data\\msgs\\id.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\id.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Mexico\\General',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Mexico\\General',
+ 'DATA'),
+ ('tcl8\\8.5\\tcltest-2.5.5.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.5\\tcltest-2.5.5.tm',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macTurkish.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macTurkish.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Costa_Rica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Costa_Rica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Montserrat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Montserrat',
+ 'DATA'),
+ ('_tk_data\\ttk\\progress.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\progress.tcl',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kok.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kok.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+2',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+2',
+ 'DATA'),
+ ('_tk_data\\images\\README',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\README',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Zulu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Zulu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Ulyanovsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Ulyanovsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Paramaribo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Paramaribo',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ms.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ms.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Central',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Central',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Choibalsan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Choibalsan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dacca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dacca',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\Zulu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\Zulu',
+ 'DATA'),
+ ('_tk_data\\ttk\\aquaTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\aquaTheme.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tbilisi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tbilisi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\NSW',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\NSW',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kuching',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kuching',
+ 'DATA'),
+ ('_tcl_data\\msgs\\da.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\da.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nl.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Macao',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Macao',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Montevideo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Montevideo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\CST6CDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\CST6CDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Helsinki',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Helsinki',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guayaquil',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guayaquil',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Kitts',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Kitts',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Knox',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Knox',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\EST5EDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\EST5EDT',
+ 'DATA'),
+ ('_tk_data\\images\\logoLarge.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logoLarge.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Tucuman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Tucuman',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Eire',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Eire',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Timbuktu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Timbuktu',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr_be.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Japan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Japan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Louisville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Louisville',
+ 'DATA'),
+ ('_tcl_data\\msgs\\vi.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\vi.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macRoman.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macRoman.enc',
+ 'DATA'),
+ ('_tk_data\\ttk\\clamTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\clamTheme.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-12',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-12',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Manaus',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Manaus',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1252.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1252.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\PST8',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\PST8',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Damascus',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Damascus',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp852.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp852.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-13.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-13.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\pl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\pl.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Zurich',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Zurich',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Accra',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Accra',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Central',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Central',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\EST5',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\EST5',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp950.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp950.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Riga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Riga',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hi.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hi.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-9',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-9',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Asmara',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Asmara',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macIceland.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macIceland.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Aden',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Aden',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santiago',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santiago',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Noumea',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Noumea',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bujumbura',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bujumbura',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\UCT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\UCT',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso2022.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso2022.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\East',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\East',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indianapolis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indianapolis',
+ 'DATA'),
+ ('_tcl_data\\auto.tcl', 'C:\\Python312\\tcl\\tcl8.6\\auto.tcl', 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-4.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-4.enc',
+ 'DATA'),
+ ('_tk_data\\msgs\\cs.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\cs.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Auckland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Auckland',
+ 'DATA'),
+ ('_tk_data\\msgs\\el.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\el.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Malta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Malta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Cairo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Cairo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Bishkek',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Bishkek',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1254.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1254.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Edmonton',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Edmonton',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lusaka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lusaka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Yukon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Yukon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\YST9',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\YST9',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Srednekolymsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Srednekolymsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Nauru',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Nauru',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Nicosia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Nicosia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Bahia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Bahia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Addis_Ababa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Addis_Ababa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\West',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\West',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_do.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_do.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Honolulu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Honolulu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Toronto',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Toronto',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Majuro',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Majuro',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Minsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Minsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Sao_Tome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Sao_Tome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Warsaw',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Warsaw',
+ 'DATA'),
+ ('_tcl_data\\encoding\\jis0201.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\jis0201.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Anadyr',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Anadyr',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar_lb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_lb.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Apia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Apia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Aruba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Aruba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GMT', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT', 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Funafuti',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Funafuti',
+ 'DATA'),
+ ('_tcl_data\\encoding\\big5.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\big5.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Oslo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Oslo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Curacao',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Curacao',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Recife',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Recife',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nuuk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nuuk',
+ 'DATA'),
+ ('_tcl_data\\msgs\\bn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\bn.msg',
+ 'DATA'),
+ ('_tk_data\\tkfbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\tkfbox.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Danmarkshavn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Danmarkshavn',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ru_ua.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ru_ua.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-5.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-5.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ulan_Bator',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ulan_Bator',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\McMurdo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\McMurdo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\AST4ADT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\AST4ADT',
+ 'DATA'),
+ ('_tk_data\\msgs\\da.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\da.msg', 'DATA'),
+ ('_tcl_data\\msgs\\fa_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fa_in.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-11.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-11.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Lower_Princes',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Lower_Princes',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Moncton',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Moncton',
+ 'DATA'),
+ ('_tcl_data\\msgs\\de_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\de_be.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Atikokan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Atikokan',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macRomania.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macRomania.enc',
+ 'DATA'),
+ ('_tk_data\\msgs\\es.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\es.msg', 'DATA'),
+ ('_tk_data\\spinbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\spinbox.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\North_Dakota\\Beulah',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\North_Dakota\\Beulah',
+ 'DATA'),
+ ('_tcl_data\\clock.tcl', 'C:\\Python312\\tcl\\tcl8.6\\clock.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+4',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+4',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Pitcairn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Pitcairn',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gv_gb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gv_gb.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1256.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1256.enc',
+ 'DATA'),
+ ('_tk_data\\tearoff.tcl', 'C:\\Python312\\tcl\\tk8.6\\tearoff.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Casey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Casey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Boise',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Boise',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Havana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Havana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Niue',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Niue',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Mendoza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Mendoza',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\UTC', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\UTC', 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Gambier',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Gambier',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bangui',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bangui',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-8',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-8',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1251.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1251.enc',
+ 'DATA'),
+ ('_tk_data\\xmfbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\xmfbox.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Mariehamn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Mariehamn',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Rangoon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Rangoon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Jersey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Jersey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Mountain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Mountain',
+ 'DATA'),
+ ('_tk_data\\unsupported.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\unsupported.tcl',
+ 'DATA'),
+ ('_tk_data\\msgs\\nl.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\nl.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Prague',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Prague',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Atka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Atka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rankin_Inlet',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rankin_Inlet',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Conakry',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Conakry',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Tallinn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Tallinn',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-5',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-5',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Mazatlan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Mazatlan',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ga.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ga.msg',
+ 'DATA'),
+ ('_tk_data\\tk.tcl', 'C:\\Python312\\tcl\\tk8.6\\tk.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kabul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kabul',
+ 'DATA'),
+ ('_tcl_data\\msgs\\mr_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mr_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cambridge_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cambridge_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Michigan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Michigan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Adelaide',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Adelaide',
+ 'DATA'),
+ ('_tcl_data\\encoding\\koi8-r.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\koi8-r.enc',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_sg.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_sg.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\ACT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\ACT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Paris',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Paris',
+ 'DATA'),
+ ('_tcl_data\\history.tcl', 'C:\\Python312\\tcl\\tcl8.6\\history.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Victoria',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Victoria',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-8.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-8.enc',
+ 'DATA'),
+ ('_tk_data\\fontchooser.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\fontchooser.tcl',
+ 'DATA'),
+ ('_tcl_data\\msgs\\mr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\eo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\eo.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Kwajalein',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Kwajalein',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santarem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santarem',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\WET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\WET', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Macau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Macau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Faeroe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Faeroe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guyana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guyana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Colombo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Colombo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Blanc-Sablon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Blanc-Sablon',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp437.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp437.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp850.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp850.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Tiraspol',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Tiraspol',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hi_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hi_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Indianapolis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Indianapolis',
+ 'DATA'),
+ ('_tk_data\\entry.tcl', 'C:\\Python312\\tcl\\tk8.6\\entry.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Kigali',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Kigali',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Tortola',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Tortola',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dushanbe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dushanbe',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nl_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nl_be.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tel_Aviv',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tel_Aviv',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ujung_Pandang',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ujung_Pandang',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Aleutian',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Aleutian',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Gaborone',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Gaborone',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Tunis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Tunis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Sarajevo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Sarajevo',
+ 'DATA'),
+ ('_tcl_data\\msgs\\lt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\lt.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp864.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp864.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Yellowknife',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Yellowknife',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Hawaii',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Hawaii',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Iqaluit',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Iqaluit',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_bw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_bw.msg',
+ 'DATA'),
+ ('_tk_data\\ttk\\treeview.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\treeview.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Ndjamena',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Ndjamena',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\CST6CDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\CST6CDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\PRC', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\PRC', 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Mbabane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Mbabane',
+ 'DATA'),
+ ('_tcl_data\\msgs\\it.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\it.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_in.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Canberra',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Canberra',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Perth',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Perth',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kl_gl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kl_gl.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Campo_Grande',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Campo_Grande',
+ 'DATA'),
+ ('_tcl_data\\encoding\\ascii.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\ascii.enc',
+ 'DATA'),
+ ('_tk_data\\msgs\\en.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\en.msg', 'DATA'),
+ ('_tk_data\\msgs\\pt.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\pt.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Vincennes',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Vincennes',
+ 'DATA'),
+ ('_tk_data\\msgs\\fr.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\fr.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Banjul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Banjul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-10',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-10',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Guam',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Guam',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\NZ', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\NZ', 'DATA'),
+ ('_tk_data\\text.tcl', 'C:\\Python312\\tcl\\tk8.6\\text.tcl', 'DATA'),
+ ('_tcl_data\\msgs\\es_uy.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_uy.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Samoa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Samoa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Berlin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Berlin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\La_Paz',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\La_Paz',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Busingen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Busingen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Mexico\\BajaSur',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Mexico\\BajaSur',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Melbourne',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Melbourne',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Podgorica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Podgorica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Chihuahua',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Chihuahua',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb2312.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb2312.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Vostok',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Vostok',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Rio_Gallegos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Rio_Gallegos',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-14.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-14.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Newfoundland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Newfoundland',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gl_es.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gl_es.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Belem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Belem',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Kralendijk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Kralendijk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Harare',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Harare',
+ 'DATA'),
+ ('_tcl_data\\encoding\\jis0208.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\jis0208.enc',
+ 'DATA'),
+ ('_tk_data\\msgs\\sv.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\sv.msg', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kolkata',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kolkata',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Uzhgorod',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Uzhgorod',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Stockholm',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Stockholm',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Chile\\EasterIsland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Chile\\EasterIsland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Novokuznetsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Novokuznetsk',
+ 'DATA'),
+ ('_tcl_data\\msgs\\de.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\de.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Halifax',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Halifax',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ar.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ar.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Glace_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Glace_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Matamoros',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Matamoros',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Monterrey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Monterrey',
+ 'DATA'),
+ ('_tk_data\\ttk\\winTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\winTheme.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Alaska',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Alaska',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\Acre',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\Acre',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Oral',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Oral',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-14',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-14',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1250.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1250.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-11',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-11',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Merida',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Merida',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Belfast',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Belfast',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ja.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ja.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+3',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+3',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kl.msg',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo150.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo150.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Kentucky\\Louisville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Kentucky\\Louisville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Tripoli',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Tripoli',
+ 'DATA'),
+ ('_tk_data\\images\\logo64.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logo64.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Navajo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Navajo',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo100.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo100.gif',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Pontianak',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Pontianak',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Samoa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Samoa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\EST', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\EST', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yerevan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yerevan',
+ 'DATA'),
+ ('_tk_data\\menu.tcl', 'C:\\Python312\\tcl\\tk8.6\\menu.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Skopje',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Skopje',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Lindeman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Lindeman',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Rarotonga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Rarotonga',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Samarkand',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Samarkand',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Monrovia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Monrovia',
+ 'DATA'),
+ ('_tcl_data\\safe.tcl', 'C:\\Python312\\tcl\\tcl8.6\\safe.tcl', 'DATA'),
+ ('_tk_data\\scale.tcl', 'C:\\Python312\\tcl\\tk8.6\\scale.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Vientiane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Vientiane',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macJapan.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macJapan.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Bangkok',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Bangkok',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+11',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+11',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bamako',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bamako',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macGreek.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macGreek.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-4',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-4',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\HST', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\HST', 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Panama',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Panama',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Saratov',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Saratov',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Portugal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Portugal',
+ 'DATA'),
+ ('_tcl_data\\encoding\\euc-jp.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\euc-jp.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Saipan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Saipan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Istanbul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Istanbul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Istanbul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Istanbul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Zaporozhye',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Zaporozhye',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Darwin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Darwin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\San_Marino',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\San_Marino',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Novosibirsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Novosibirsk',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ca.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ca.msg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\MST7MDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\MST7MDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Chile\\Continental',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Chile\\Continental',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Hovd',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Hovd',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Katmandu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Katmandu',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_hk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_hk.msg',
+ 'DATA'),
+ ('_tk_data\\ttk\\scale.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\scale.tcl',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\MST', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\MST', 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Singapore',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Singapore',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yakutsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yakutsk',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr_ch.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr_ch.msg',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macUkraine.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macUkraine.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\ebcdic.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\ebcdic.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp861.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp861.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1258.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1258.enc',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Hobart',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Hobart',
+ 'DATA'),
+ ('_tcl_data\\msgs\\et.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\et.msg',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\distributions\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\distributions\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\METADATA',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\METADATA',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\splitmix64\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\splitmix64\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\mt19937\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\mt19937\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\common\\pythoncapi-compat\\COPYING',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\common\\pythoncapi-compat\\COPYING',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\fft\\pocketfft\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\fft\\pocketfft\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\DELVEWHEEL',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\DELVEWHEEL',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\REQUESTED',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\REQUESTED',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\sfc64\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\sfc64\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\INSTALLER',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\INSTALLER',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\linalg\\lapack_lite\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\linalg\\lapack_lite\\LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\WHEEL',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\WHEEL',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\include\\numpy\\libdivide\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\include\\numpy\\libdivide\\LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\highway\\LICENSE',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\highway\\LICENSE',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\ma\\LICENSE',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\ma\\LICENSE',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\pcg64\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\pcg64\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\philox\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\philox\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\umath\\svml\\LICENSE',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\umath\\svml\\LICENSE',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\RECORD',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\RECORD',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\entry_points.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\entry_points.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\multiarray\\dragon4_LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\multiarray\\dragon4_LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\npysort\\x86-simd-sort\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\npysort\\x86-simd-sort\\LICENSE.md',
+ 'DATA'),
+ ('base_library.zip',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\base_library.zip',
+ 'DATA')],
+ [('cv2.version',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\version.py',
+ 'PYMODULE'),
+ ('cv2.utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\__init__.py',
+ 'PYMODULE'),
+ ('cv2.typing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\typing\\__init__.py',
+ 'PYMODULE'),
+ ('cv2.dnn', '-', 'PYMODULE'),
+ ('cv2.misc.version',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\version.py',
+ 'PYMODULE'),
+ ('cv2.misc',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\__init__.py',
+ 'PYMODULE'),
+ ('cv2.mat_wrapper',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\mat_wrapper\\__init__.py',
+ 'PYMODULE'),
+ ('cv2.load_config_py3',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py3.py',
+ 'PYMODULE'),
+ ('cv2.load_config_py2',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py2.py',
+ 'PYMODULE'),
+ ('cv2.data',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\data\\__init__.py',
+ 'PYMODULE'),
+ ('cv2.config-3',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config-3.py',
+ 'PYMODULE'),
+ ('cv2.config',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config.py',
+ 'PYMODULE'),
+ ('enum', 'C:\\Python312\\Lib\\enum.py', 'PYMODULE'),
+ ('os', 'C:\\Python312\\Lib\\os.py', 'PYMODULE'),
+ ('io', 'C:\\Python312\\Lib\\io.py', 'PYMODULE'),
+ ('posixpath', 'C:\\Python312\\Lib\\posixpath.py', 'PYMODULE'),
+ ('genericpath', 'C:\\Python312\\Lib\\genericpath.py', 'PYMODULE'),
+ ('re._parser', 'C:\\Python312\\Lib\\re\\_parser.py', 'PYMODULE'),
+ ('re._constants', 'C:\\Python312\\Lib\\re\\_constants.py', 'PYMODULE'),
+ ('re._compiler', 'C:\\Python312\\Lib\\re\\_compiler.py', 'PYMODULE'),
+ ('re._casefix', 'C:\\Python312\\Lib\\re\\_casefix.py', 'PYMODULE'),
+ ('re', 'C:\\Python312\\Lib\\re\\__init__.py', 'PYMODULE'),
+ ('abc', 'C:\\Python312\\Lib\\abc.py', 'PYMODULE'),
+ ('copyreg', 'C:\\Python312\\Lib\\copyreg.py', 'PYMODULE'),
+ ('warnings', 'C:\\Python312\\Lib\\warnings.py', 'PYMODULE'),
+ ('types', 'C:\\Python312\\Lib\\types.py', 'PYMODULE'),
+ ('linecache', 'C:\\Python312\\Lib\\linecache.py', 'PYMODULE'),
+ ('encodings.zlib_codec',
+ 'C:\\Python312\\Lib\\encodings\\zlib_codec.py',
+ 'PYMODULE'),
+ ('encodings.uu_codec',
+ 'C:\\Python312\\Lib\\encodings\\uu_codec.py',
+ 'PYMODULE'),
+ ('encodings.utf_8_sig',
+ 'C:\\Python312\\Lib\\encodings\\utf_8_sig.py',
+ 'PYMODULE'),
+ ('encodings.utf_8', 'C:\\Python312\\Lib\\encodings\\utf_8.py', 'PYMODULE'),
+ ('encodings.utf_7', 'C:\\Python312\\Lib\\encodings\\utf_7.py', 'PYMODULE'),
+ ('encodings.utf_32_le',
+ 'C:\\Python312\\Lib\\encodings\\utf_32_le.py',
+ 'PYMODULE'),
+ ('encodings.utf_32_be',
+ 'C:\\Python312\\Lib\\encodings\\utf_32_be.py',
+ 'PYMODULE'),
+ ('encodings.utf_32', 'C:\\Python312\\Lib\\encodings\\utf_32.py', 'PYMODULE'),
+ ('encodings.utf_16_le',
+ 'C:\\Python312\\Lib\\encodings\\utf_16_le.py',
+ 'PYMODULE'),
+ ('encodings.utf_16_be',
+ 'C:\\Python312\\Lib\\encodings\\utf_16_be.py',
+ 'PYMODULE'),
+ ('encodings.utf_16', 'C:\\Python312\\Lib\\encodings\\utf_16.py', 'PYMODULE'),
+ ('encodings.unicode_escape',
+ 'C:\\Python312\\Lib\\encodings\\unicode_escape.py',
+ 'PYMODULE'),
+ ('encodings.undefined',
+ 'C:\\Python312\\Lib\\encodings\\undefined.py',
+ 'PYMODULE'),
+ ('encodings.tis_620',
+ 'C:\\Python312\\Lib\\encodings\\tis_620.py',
+ 'PYMODULE'),
+ ('encodings.shift_jisx0213',
+ 'C:\\Python312\\Lib\\encodings\\shift_jisx0213.py',
+ 'PYMODULE'),
+ ('encodings.shift_jis_2004',
+ 'C:\\Python312\\Lib\\encodings\\shift_jis_2004.py',
+ 'PYMODULE'),
+ ('encodings.shift_jis',
+ 'C:\\Python312\\Lib\\encodings\\shift_jis.py',
+ 'PYMODULE'),
+ ('encodings.rot_13', 'C:\\Python312\\Lib\\encodings\\rot_13.py', 'PYMODULE'),
+ ('encodings.raw_unicode_escape',
+ 'C:\\Python312\\Lib\\encodings\\raw_unicode_escape.py',
+ 'PYMODULE'),
+ ('encodings.quopri_codec',
+ 'C:\\Python312\\Lib\\encodings\\quopri_codec.py',
+ 'PYMODULE'),
+ ('encodings.punycode',
+ 'C:\\Python312\\Lib\\encodings\\punycode.py',
+ 'PYMODULE'),
+ ('encodings.ptcp154',
+ 'C:\\Python312\\Lib\\encodings\\ptcp154.py',
+ 'PYMODULE'),
+ ('encodings.palmos', 'C:\\Python312\\Lib\\encodings\\palmos.py', 'PYMODULE'),
+ ('encodings.oem', 'C:\\Python312\\Lib\\encodings\\oem.py', 'PYMODULE'),
+ ('encodings.mbcs', 'C:\\Python312\\Lib\\encodings\\mbcs.py', 'PYMODULE'),
+ ('encodings.mac_turkish',
+ 'C:\\Python312\\Lib\\encodings\\mac_turkish.py',
+ 'PYMODULE'),
+ ('encodings.mac_romanian',
+ 'C:\\Python312\\Lib\\encodings\\mac_romanian.py',
+ 'PYMODULE'),
+ ('encodings.mac_roman',
+ 'C:\\Python312\\Lib\\encodings\\mac_roman.py',
+ 'PYMODULE'),
+ ('encodings.mac_latin2',
+ 'C:\\Python312\\Lib\\encodings\\mac_latin2.py',
+ 'PYMODULE'),
+ ('encodings.mac_iceland',
+ 'C:\\Python312\\Lib\\encodings\\mac_iceland.py',
+ 'PYMODULE'),
+ ('encodings.mac_greek',
+ 'C:\\Python312\\Lib\\encodings\\mac_greek.py',
+ 'PYMODULE'),
+ ('encodings.mac_farsi',
+ 'C:\\Python312\\Lib\\encodings\\mac_farsi.py',
+ 'PYMODULE'),
+ ('encodings.mac_cyrillic',
+ 'C:\\Python312\\Lib\\encodings\\mac_cyrillic.py',
+ 'PYMODULE'),
+ ('encodings.mac_croatian',
+ 'C:\\Python312\\Lib\\encodings\\mac_croatian.py',
+ 'PYMODULE'),
+ ('encodings.mac_arabic',
+ 'C:\\Python312\\Lib\\encodings\\mac_arabic.py',
+ 'PYMODULE'),
+ ('encodings.latin_1',
+ 'C:\\Python312\\Lib\\encodings\\latin_1.py',
+ 'PYMODULE'),
+ ('encodings.kz1048', 'C:\\Python312\\Lib\\encodings\\kz1048.py', 'PYMODULE'),
+ ('encodings.koi8_u', 'C:\\Python312\\Lib\\encodings\\koi8_u.py', 'PYMODULE'),
+ ('encodings.koi8_t', 'C:\\Python312\\Lib\\encodings\\koi8_t.py', 'PYMODULE'),
+ ('encodings.koi8_r', 'C:\\Python312\\Lib\\encodings\\koi8_r.py', 'PYMODULE'),
+ ('encodings.johab', 'C:\\Python312\\Lib\\encodings\\johab.py', 'PYMODULE'),
+ ('encodings.iso8859_9',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_9.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_8',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_8.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_7',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_7.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_6',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_6.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_5',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_5.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_4',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_4.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_3',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_3.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_2',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_2.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_16',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_16.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_15',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_15.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_14',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_14.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_13',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_13.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_11',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_11.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_10',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_10.py',
+ 'PYMODULE'),
+ ('encodings.iso8859_1',
+ 'C:\\Python312\\Lib\\encodings\\iso8859_1.py',
+ 'PYMODULE'),
+ ('encodings.iso2022_kr',
+ 'C:\\Python312\\Lib\\encodings\\iso2022_kr.py',
+ 'PYMODULE'),
+ ('encodings.iso2022_jp_ext',
+ 'C:\\Python312\\Lib\\encodings\\iso2022_jp_ext.py',
+ 'PYMODULE'),
+ ('encodings.iso2022_jp_3',
+ 'C:\\Python312\\Lib\\encodings\\iso2022_jp_3.py',
+ 'PYMODULE'),
+ ('encodings.iso2022_jp_2004',
+ 'C:\\Python312\\Lib\\encodings\\iso2022_jp_2004.py',
+ 'PYMODULE'),
+ ('encodings.iso2022_jp_2',
+ 'C:\\Python312\\Lib\\encodings\\iso2022_jp_2.py',
+ 'PYMODULE'),
+ ('encodings.iso2022_jp_1',
+ 'C:\\Python312\\Lib\\encodings\\iso2022_jp_1.py',
+ 'PYMODULE'),
+ ('encodings.iso2022_jp',
+ 'C:\\Python312\\Lib\\encodings\\iso2022_jp.py',
+ 'PYMODULE'),
+ ('encodings.idna', 'C:\\Python312\\Lib\\encodings\\idna.py', 'PYMODULE'),
+ ('encodings.hz', 'C:\\Python312\\Lib\\encodings\\hz.py', 'PYMODULE'),
+ ('encodings.hp_roman8',
+ 'C:\\Python312\\Lib\\encodings\\hp_roman8.py',
+ 'PYMODULE'),
+ ('encodings.hex_codec',
+ 'C:\\Python312\\Lib\\encodings\\hex_codec.py',
+ 'PYMODULE'),
+ ('encodings.gbk', 'C:\\Python312\\Lib\\encodings\\gbk.py', 'PYMODULE'),
+ ('encodings.gb2312', 'C:\\Python312\\Lib\\encodings\\gb2312.py', 'PYMODULE'),
+ ('encodings.gb18030',
+ 'C:\\Python312\\Lib\\encodings\\gb18030.py',
+ 'PYMODULE'),
+ ('encodings.euc_kr', 'C:\\Python312\\Lib\\encodings\\euc_kr.py', 'PYMODULE'),
+ ('encodings.euc_jp', 'C:\\Python312\\Lib\\encodings\\euc_jp.py', 'PYMODULE'),
+ ('encodings.euc_jisx0213',
+ 'C:\\Python312\\Lib\\encodings\\euc_jisx0213.py',
+ 'PYMODULE'),
+ ('encodings.euc_jis_2004',
+ 'C:\\Python312\\Lib\\encodings\\euc_jis_2004.py',
+ 'PYMODULE'),
+ ('encodings.cp950', 'C:\\Python312\\Lib\\encodings\\cp950.py', 'PYMODULE'),
+ ('encodings.cp949', 'C:\\Python312\\Lib\\encodings\\cp949.py', 'PYMODULE'),
+ ('encodings.cp932', 'C:\\Python312\\Lib\\encodings\\cp932.py', 'PYMODULE'),
+ ('encodings.cp875', 'C:\\Python312\\Lib\\encodings\\cp875.py', 'PYMODULE'),
+ ('encodings.cp874', 'C:\\Python312\\Lib\\encodings\\cp874.py', 'PYMODULE'),
+ ('encodings.cp869', 'C:\\Python312\\Lib\\encodings\\cp869.py', 'PYMODULE'),
+ ('encodings.cp866', 'C:\\Python312\\Lib\\encodings\\cp866.py', 'PYMODULE'),
+ ('encodings.cp865', 'C:\\Python312\\Lib\\encodings\\cp865.py', 'PYMODULE'),
+ ('encodings.cp864', 'C:\\Python312\\Lib\\encodings\\cp864.py', 'PYMODULE'),
+ ('encodings.cp863', 'C:\\Python312\\Lib\\encodings\\cp863.py', 'PYMODULE'),
+ ('encodings.cp862', 'C:\\Python312\\Lib\\encodings\\cp862.py', 'PYMODULE'),
+ ('encodings.cp861', 'C:\\Python312\\Lib\\encodings\\cp861.py', 'PYMODULE'),
+ ('encodings.cp860', 'C:\\Python312\\Lib\\encodings\\cp860.py', 'PYMODULE'),
+ ('encodings.cp858', 'C:\\Python312\\Lib\\encodings\\cp858.py', 'PYMODULE'),
+ ('encodings.cp857', 'C:\\Python312\\Lib\\encodings\\cp857.py', 'PYMODULE'),
+ ('encodings.cp856', 'C:\\Python312\\Lib\\encodings\\cp856.py', 'PYMODULE'),
+ ('encodings.cp855', 'C:\\Python312\\Lib\\encodings\\cp855.py', 'PYMODULE'),
+ ('encodings.cp852', 'C:\\Python312\\Lib\\encodings\\cp852.py', 'PYMODULE'),
+ ('encodings.cp850', 'C:\\Python312\\Lib\\encodings\\cp850.py', 'PYMODULE'),
+ ('encodings.cp775', 'C:\\Python312\\Lib\\encodings\\cp775.py', 'PYMODULE'),
+ ('encodings.cp737', 'C:\\Python312\\Lib\\encodings\\cp737.py', 'PYMODULE'),
+ ('encodings.cp720', 'C:\\Python312\\Lib\\encodings\\cp720.py', 'PYMODULE'),
+ ('encodings.cp500', 'C:\\Python312\\Lib\\encodings\\cp500.py', 'PYMODULE'),
+ ('encodings.cp437', 'C:\\Python312\\Lib\\encodings\\cp437.py', 'PYMODULE'),
+ ('encodings.cp424', 'C:\\Python312\\Lib\\encodings\\cp424.py', 'PYMODULE'),
+ ('encodings.cp273', 'C:\\Python312\\Lib\\encodings\\cp273.py', 'PYMODULE'),
+ ('encodings.cp1258', 'C:\\Python312\\Lib\\encodings\\cp1258.py', 'PYMODULE'),
+ ('encodings.cp1257', 'C:\\Python312\\Lib\\encodings\\cp1257.py', 'PYMODULE'),
+ ('encodings.cp1256', 'C:\\Python312\\Lib\\encodings\\cp1256.py', 'PYMODULE'),
+ ('encodings.cp1255', 'C:\\Python312\\Lib\\encodings\\cp1255.py', 'PYMODULE'),
+ ('encodings.cp1254', 'C:\\Python312\\Lib\\encodings\\cp1254.py', 'PYMODULE'),
+ ('encodings.cp1253', 'C:\\Python312\\Lib\\encodings\\cp1253.py', 'PYMODULE'),
+ ('encodings.cp1252', 'C:\\Python312\\Lib\\encodings\\cp1252.py', 'PYMODULE'),
+ ('encodings.cp1251', 'C:\\Python312\\Lib\\encodings\\cp1251.py', 'PYMODULE'),
+ ('encodings.cp1250', 'C:\\Python312\\Lib\\encodings\\cp1250.py', 'PYMODULE'),
+ ('encodings.cp1140', 'C:\\Python312\\Lib\\encodings\\cp1140.py', 'PYMODULE'),
+ ('encodings.cp1125', 'C:\\Python312\\Lib\\encodings\\cp1125.py', 'PYMODULE'),
+ ('encodings.cp1026', 'C:\\Python312\\Lib\\encodings\\cp1026.py', 'PYMODULE'),
+ ('encodings.cp1006', 'C:\\Python312\\Lib\\encodings\\cp1006.py', 'PYMODULE'),
+ ('encodings.cp037', 'C:\\Python312\\Lib\\encodings\\cp037.py', 'PYMODULE'),
+ ('encodings.charmap',
+ 'C:\\Python312\\Lib\\encodings\\charmap.py',
+ 'PYMODULE'),
+ ('encodings.bz2_codec',
+ 'C:\\Python312\\Lib\\encodings\\bz2_codec.py',
+ 'PYMODULE'),
+ ('encodings.big5hkscs',
+ 'C:\\Python312\\Lib\\encodings\\big5hkscs.py',
+ 'PYMODULE'),
+ ('encodings.big5', 'C:\\Python312\\Lib\\encodings\\big5.py', 'PYMODULE'),
+ ('encodings.base64_codec',
+ 'C:\\Python312\\Lib\\encodings\\base64_codec.py',
+ 'PYMODULE'),
+ ('encodings.ascii', 'C:\\Python312\\Lib\\encodings\\ascii.py', 'PYMODULE'),
+ ('encodings.aliases',
+ 'C:\\Python312\\Lib\\encodings\\aliases.py',
+ 'PYMODULE'),
+ ('encodings', 'C:\\Python312\\Lib\\encodings\\__init__.py', 'PYMODULE'),
+ ('stat', 'C:\\Python312\\Lib\\stat.py', 'PYMODULE'),
+ ('keyword', 'C:\\Python312\\Lib\\keyword.py', 'PYMODULE'),
+ ('sre_parse', 'C:\\Python312\\Lib\\sre_parse.py', 'PYMODULE'),
+ ('functools', 'C:\\Python312\\Lib\\functools.py', 'PYMODULE'),
+ ('sre_compile', 'C:\\Python312\\Lib\\sre_compile.py', 'PYMODULE'),
+ ('collections.abc', 'C:\\Python312\\Lib\\collections\\abc.py', 'PYMODULE'),
+ ('collections', 'C:\\Python312\\Lib\\collections\\__init__.py', 'PYMODULE'),
+ ('heapq', 'C:\\Python312\\Lib\\heapq.py', 'PYMODULE'),
+ ('sre_constants', 'C:\\Python312\\Lib\\sre_constants.py', 'PYMODULE'),
+ ('codecs', 'C:\\Python312\\Lib\\codecs.py', 'PYMODULE'),
+ ('weakref', 'C:\\Python312\\Lib\\weakref.py', 'PYMODULE'),
+ ('operator', 'C:\\Python312\\Lib\\operator.py', 'PYMODULE'),
+ ('reprlib', 'C:\\Python312\\Lib\\reprlib.py', 'PYMODULE'),
+ ('ntpath', 'C:\\Python312\\Lib\\ntpath.py', 'PYMODULE'),
+ ('_weakrefset', 'C:\\Python312\\Lib\\_weakrefset.py', 'PYMODULE'),
+ ('_collections_abc', 'C:\\Python312\\Lib\\_collections_abc.py', 'PYMODULE'),
+ ('locale', 'C:\\Python312\\Lib\\locale.py', 'PYMODULE'),
+ ('cv2',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\__init__.py',
+ 'PYMODULE'),
+ ('traceback', 'C:\\Python312\\Lib\\traceback.py', 'PYMODULE')])
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/COLLECT-00.toc b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/COLLECT-00.toc
new file mode 100644
index 000000000..dce7bd496
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/COLLECT-00.toc
@@ -0,0 +1,3088 @@
+([('MaskReviewer.exe',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\MaskReviewer.exe',
+ 'EXECUTABLE'),
+ ('VCRUNTIME140.dll', 'C:\\Python312\\VCRUNTIME140.dll', 'BINARY'),
+ ('VCRUNTIME140_1.dll', 'C:\\Python312\\VCRUNTIME140_1.dll', 'BINARY'),
+ ('_asyncio.pyd', 'C:\\Python312\\DLLs\\_asyncio.pyd', 'EXTENSION'),
+ ('_bz2.pyd', 'C:\\Python312\\DLLs\\_bz2.pyd', 'EXTENSION'),
+ ('_ctypes.pyd', 'C:\\Python312\\DLLs\\_ctypes.pyd', 'EXTENSION'),
+ ('_decimal.pyd', 'C:\\Python312\\DLLs\\_decimal.pyd', 'EXTENSION'),
+ ('_hashlib.pyd', 'C:\\Python312\\DLLs\\_hashlib.pyd', 'EXTENSION'),
+ ('_lzma.pyd', 'C:\\Python312\\DLLs\\_lzma.pyd', 'EXTENSION'),
+ ('_multiprocessing.pyd',
+ 'C:\\Python312\\DLLs\\_multiprocessing.pyd',
+ 'EXTENSION'),
+ ('_overlapped.pyd', 'C:\\Python312\\DLLs\\_overlapped.pyd', 'EXTENSION'),
+ ('_queue.pyd', 'C:\\Python312\\DLLs\\_queue.pyd', 'EXTENSION'),
+ ('_socket.pyd', 'C:\\Python312\\DLLs\\_socket.pyd', 'EXTENSION'),
+ ('_ssl.pyd', 'C:\\Python312\\DLLs\\_ssl.pyd', 'EXTENSION'),
+ ('_tcl_data\\auto.tcl', 'C:\\Python312\\tcl\\tcl8.6\\auto.tcl', 'DATA'),
+ ('_tcl_data\\clock.tcl', 'C:\\Python312\\tcl\\tcl8.6\\clock.tcl', 'DATA'),
+ ('_tcl_data\\encoding\\ascii.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\ascii.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\big5.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\big5.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cns11643.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cns11643.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1250.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1250.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1251.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1251.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1252.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1252.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1253.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1253.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1254.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1254.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1255.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1255.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1256.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1256.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1257.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1257.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp1258.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp1258.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp437.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp437.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp737.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp737.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp775.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp775.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp850.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp850.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp852.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp852.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp855.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp855.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp857.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp857.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp860.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp860.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp861.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp861.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp862.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp862.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp863.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp863.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp864.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp864.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp865.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp865.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp866.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp866.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp869.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp869.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp874.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp874.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp932.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp932.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp936.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp936.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp949.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp949.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\cp950.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\cp950.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\dingbats.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\dingbats.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\ebcdic.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\ebcdic.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\euc-cn.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\euc-cn.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\euc-jp.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\euc-jp.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\euc-kr.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\euc-kr.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb12345.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb12345.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb1988.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb1988.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb2312-raw.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb2312-raw.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\gb2312.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\gb2312.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso2022-jp.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso2022-jp.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso2022-kr.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso2022-kr.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso2022.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso2022.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-1.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-1.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-10.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-10.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-11.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-11.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-13.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-13.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-14.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-14.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-15.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-15.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-16.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-16.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-2.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-2.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-3.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-3.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-4.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-4.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-5.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-5.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-6.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-6.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-7.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-7.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-8.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-8.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\iso8859-9.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\iso8859-9.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\jis0201.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\jis0201.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\jis0208.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\jis0208.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\jis0212.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\jis0212.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\koi8-r.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\koi8-r.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\koi8-u.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\koi8-u.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\ksc5601.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\ksc5601.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macCentEuro.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macCentEuro.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macCroatian.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macCroatian.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macCyrillic.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macCyrillic.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macDingbats.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macDingbats.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macGreek.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macGreek.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macIceland.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macIceland.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macJapan.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macJapan.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macRoman.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macRoman.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macRomania.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macRomania.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macThai.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macThai.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macTurkish.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macTurkish.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\macUkraine.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\macUkraine.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\shiftjis.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\shiftjis.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\symbol.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\symbol.enc',
+ 'DATA'),
+ ('_tcl_data\\encoding\\tis-620.enc',
+ 'C:\\Python312\\tcl\\tcl8.6\\encoding\\tis-620.enc',
+ 'DATA'),
+ ('_tcl_data\\history.tcl', 'C:\\Python312\\tcl\\tcl8.6\\history.tcl', 'DATA'),
+ ('_tcl_data\\http1.0\\http.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\http1.0\\http.tcl',
+ 'DATA'),
+ ('_tcl_data\\http1.0\\pkgIndex.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\http1.0\\pkgIndex.tcl',
+ 'DATA'),
+ ('_tcl_data\\init.tcl', 'C:\\Python312\\tcl\\tcl8.6\\init.tcl', 'DATA'),
+ ('_tcl_data\\msgs\\af.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\af.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\af_za.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\af_za.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar_jo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_jo.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar_lb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_lb.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ar_sy.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ar_sy.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\be.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\bg.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\bg.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\bn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\bn.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\bn_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\bn_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ca.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ca.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\cs.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\cs.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\da.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\da.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\de.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\de.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\de_at.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\de_at.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\de_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\de_be.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\el.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\el.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_au.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_au.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_be.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_bw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_bw.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_ca.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_ca.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_gb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_gb.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_hk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_hk.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_ie.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_ie.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_nz.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_nz.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_ph.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_ph.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_sg.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_sg.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_za.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_za.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\en_zw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\en_zw.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\eo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\eo.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ar.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ar.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_bo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_bo.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_cl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_cl.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_co.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_co.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_cr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_cr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_do.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_do.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ec.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ec.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_gt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_gt.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_hn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_hn.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_mx.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_mx.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ni.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ni.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_pa.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_pa.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_pe.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_pe.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_pr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_pr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_py.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_py.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_sv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_sv.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_uy.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_uy.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\es_ve.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\es_ve.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\et.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\et.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\eu.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\eu.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\eu_es.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\eu_es.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fa.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fa.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fa_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fa_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fa_ir.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fa_ir.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fi.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fi.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fo.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fo_fo.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fo_fo.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr_be.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr_ca.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr_ca.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\fr_ch.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\fr_ch.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ga.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ga.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ga_ie.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ga_ie.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gl.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gl_es.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gl_es.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gv.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\gv_gb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\gv_gb.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\he.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\he.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hi.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hi.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hi_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hi_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\hu.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\hu.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\id.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\id.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\id_id.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\id_id.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\is.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\is.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\it.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\it.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\it_ch.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\it_ch.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ja.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ja.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kl.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kl_gl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kl_gl.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ko.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ko.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ko_kr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ko_kr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kok.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kok.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kok_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kok_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kw.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\kw_gb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\kw_gb.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\lt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\lt.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\lv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\lv.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\mk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mk.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\mr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\mr_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mr_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ms.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ms.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ms_my.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ms_my.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\mt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\mt.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nb.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nb.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nl.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nl_be.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nl_be.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\nn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\nn.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\pl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\pl.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\pt.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\pt.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\pt_br.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\pt_br.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ro.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ro.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ru.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ru.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ru_ua.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ru_ua.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sh.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sh.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sk.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sl.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sl.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sq.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sq.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sv.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sv.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\sw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\sw.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ta.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ta.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\ta_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\ta_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\te.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\te.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\te_in.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\te_in.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\th.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\th.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\tr.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\tr.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\uk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\uk.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\vi.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\vi.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_cn.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_cn.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_hk.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_hk.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_sg.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_sg.msg',
+ 'DATA'),
+ ('_tcl_data\\msgs\\zh_tw.msg',
+ 'C:\\Python312\\tcl\\tcl8.6\\msgs\\zh_tw.msg',
+ 'DATA'),
+ ('_tcl_data\\opt0.4\\optparse.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\opt0.4\\optparse.tcl',
+ 'DATA'),
+ ('_tcl_data\\opt0.4\\pkgIndex.tcl',
+ 'C:\\Python312\\tcl\\tcl8.6\\opt0.4\\pkgIndex.tcl',
+ 'DATA'),
+ ('_tcl_data\\package.tcl', 'C:\\Python312\\tcl\\tcl8.6\\package.tcl', 'DATA'),
+ ('_tcl_data\\parray.tcl', 'C:\\Python312\\tcl\\tcl8.6\\parray.tcl', 'DATA'),
+ ('_tcl_data\\safe.tcl', 'C:\\Python312\\tcl\\tcl8.6\\safe.tcl', 'DATA'),
+ ('_tcl_data\\tclIndex', 'C:\\Python312\\tcl\\tcl8.6\\tclIndex', 'DATA'),
+ ('_tcl_data\\tm.tcl', 'C:\\Python312\\tcl\\tcl8.6\\tm.tcl', 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Abidjan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Abidjan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Accra',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Accra',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Addis_Ababa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Addis_Ababa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Algiers',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Algiers',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Asmara',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Asmara',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Asmera',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Asmera',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bamako',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bamako',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bangui',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bangui',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Banjul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Banjul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bissau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bissau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Blantyre',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Blantyre',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Brazzaville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Brazzaville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Bujumbura',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Bujumbura',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Cairo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Cairo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Casablanca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Casablanca',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Ceuta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Ceuta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Conakry',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Conakry',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Dakar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Dakar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Dar_es_Salaam',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Dar_es_Salaam',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Djibouti',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Djibouti',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Douala',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Douala',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\El_Aaiun',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\El_Aaiun',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Freetown',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Freetown',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Gaborone',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Gaborone',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Harare',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Harare',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Johannesburg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Johannesburg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Juba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Juba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Kampala',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Kampala',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Khartoum',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Khartoum',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Kigali',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Kigali',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Kinshasa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Kinshasa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lagos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lagos',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Libreville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Libreville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Luanda',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Luanda',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lubumbashi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lubumbashi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Lusaka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Lusaka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Malabo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Malabo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Maputo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Maputo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Maseru',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Maseru',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Mbabane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Mbabane',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Mogadishu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Mogadishu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Monrovia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Monrovia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Nairobi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Nairobi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Ndjamena',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Ndjamena',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Niamey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Niamey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Nouakchott',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Nouakchott',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Ouagadougou',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Ouagadougou',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Porto-Novo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Porto-Novo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Sao_Tome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Sao_Tome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Timbuktu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Timbuktu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Tripoli',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Tripoli',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Tunis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Tunis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Africa\\Windhoek',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Africa\\Windhoek',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Adak',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Adak',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Anchorage',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Anchorage',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Anguilla',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Anguilla',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Antigua',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Antigua',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Araguaina',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Araguaina',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Buenos_Aires',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Buenos_Aires',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Catamarca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Catamarca',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\ComodRivadavia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\ComodRivadavia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Cordoba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Cordoba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Jujuy',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Jujuy',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\La_Rioja',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\La_Rioja',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Mendoza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Mendoza',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Rio_Gallegos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Rio_Gallegos',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Salta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Salta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\San_Juan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\San_Juan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\San_Luis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\San_Luis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Tucuman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Tucuman',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Argentina\\Ushuaia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Argentina\\Ushuaia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Aruba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Aruba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Asuncion',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Asuncion',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Atikokan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Atikokan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Atka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Atka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Bahia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Bahia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Bahia_Banderas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Bahia_Banderas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Barbados',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Barbados',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Belem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Belem',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Belize',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Belize',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Blanc-Sablon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Blanc-Sablon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Boa_Vista',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Boa_Vista',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Bogota',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Bogota',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Boise',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Boise',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Buenos_Aires',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Buenos_Aires',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cambridge_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cambridge_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Campo_Grande',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Campo_Grande',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cancun',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cancun',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Caracas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Caracas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Catamarca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Catamarca',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cayenne',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cayenne',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cayman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cayman',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Chicago',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Chicago',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Chihuahua',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Chihuahua',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Coral_Harbour',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Coral_Harbour',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cordoba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cordoba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Costa_Rica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Costa_Rica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Creston',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Creston',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Cuiaba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Cuiaba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Curacao',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Curacao',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Danmarkshavn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Danmarkshavn',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Dawson',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Dawson',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Dawson_Creek',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Dawson_Creek',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Denver',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Denver',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Detroit',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Detroit',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Dominica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Dominica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Edmonton',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Edmonton',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Eirunepe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Eirunepe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\El_Salvador',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\El_Salvador',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Ensenada',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Ensenada',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Fort_Nelson',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Fort_Nelson',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Fort_Wayne',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Fort_Wayne',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Fortaleza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Fortaleza',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Glace_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Glace_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Godthab',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Godthab',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Goose_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Goose_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Grand_Turk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Grand_Turk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Grenada',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Grenada',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guadeloupe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guadeloupe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guatemala',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guatemala',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guayaquil',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guayaquil',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Guyana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Guyana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Halifax',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Halifax',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Havana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Havana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Hermosillo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Hermosillo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Indianapolis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Indianapolis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Knox',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Knox',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Marengo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Marengo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Petersburg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Petersburg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Tell_City',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Tell_City',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Vevay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Vevay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Vincennes',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Vincennes',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indiana\\Winamac',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indiana\\Winamac',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Indianapolis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Indianapolis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Inuvik',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Inuvik',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Iqaluit',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Iqaluit',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Jamaica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Jamaica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Jujuy',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Jujuy',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Juneau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Juneau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Kentucky\\Louisville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Kentucky\\Louisville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Kentucky\\Monticello',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Kentucky\\Monticello',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Knox_IN',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Knox_IN',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Kralendijk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Kralendijk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\La_Paz',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\La_Paz',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Lima',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Lima',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Los_Angeles',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Los_Angeles',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Louisville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Louisville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Lower_Princes',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Lower_Princes',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Maceio',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Maceio',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Managua',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Managua',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Manaus',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Manaus',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Marigot',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Marigot',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Martinique',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Martinique',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Matamoros',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Matamoros',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Mazatlan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Mazatlan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Mendoza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Mendoza',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Menominee',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Menominee',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Merida',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Merida',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Metlakatla',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Metlakatla',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Mexico_City',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Mexico_City',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Miquelon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Miquelon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Moncton',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Moncton',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Monterrey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Monterrey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Montevideo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Montevideo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Montreal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Montreal',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Montserrat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Montserrat',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nassau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nassau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\New_York',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\New_York',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nipigon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nipigon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Noronha',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Noronha',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\North_Dakota\\Beulah',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\North_Dakota\\Beulah',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\North_Dakota\\Center',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\North_Dakota\\Center',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\North_Dakota\\New_Salem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\North_Dakota\\New_Salem',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Nuuk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Nuuk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Ojinaga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Ojinaga',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Panama',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Panama',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Pangnirtung',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Pangnirtung',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Paramaribo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Paramaribo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Phoenix',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Phoenix',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Port-au-Prince',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Port-au-Prince',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Port_of_Spain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Port_of_Spain',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Porto_Acre',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Porto_Acre',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Porto_Velho',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Porto_Velho',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Puerto_Rico',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Puerto_Rico',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Punta_Arenas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Punta_Arenas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rainy_River',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rainy_River',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rankin_Inlet',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rankin_Inlet',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Recife',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Recife',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Regina',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Regina',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Resolute',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Resolute',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rio_Branco',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rio_Branco',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Rosario',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Rosario',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santa_Isabel',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santa_Isabel',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santarem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santarem',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santiago',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santiago',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Santo_Domingo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Santo_Domingo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Sao_Paulo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Sao_Paulo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Scoresbysund',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Scoresbysund',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Shiprock',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Shiprock',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Sitka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Sitka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Barthelemy',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Barthelemy',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Johns',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Johns',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Kitts',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Kitts',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Lucia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Lucia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Thomas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Thomas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\St_Vincent',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\St_Vincent',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Swift_Current',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Swift_Current',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Tegucigalpa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Tegucigalpa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Thule',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Thule',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Thunder_Bay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Thunder_Bay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Tijuana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Tijuana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Toronto',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Toronto',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Tortola',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Tortola',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Vancouver',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Vancouver',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Virgin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Virgin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Whitehorse',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Whitehorse',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Winnipeg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Winnipeg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Yakutat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Yakutat',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\America\\Yellowknife',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\America\\Yellowknife',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Casey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Casey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Davis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Davis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\DumontDUrville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\DumontDUrville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Macquarie',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Macquarie',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Mawson',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Mawson',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\McMurdo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\McMurdo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Palmer',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Palmer',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Rothera',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Rothera',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\South_Pole',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\South_Pole',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Syowa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Syowa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Troll',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Troll',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Antarctica\\Vostok',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Antarctica\\Vostok',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Arctic\\Longyearbyen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Arctic\\Longyearbyen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Aden',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Aden',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Almaty',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Almaty',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Amman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Amman',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Anadyr',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Anadyr',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Aqtau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Aqtau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Aqtobe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Aqtobe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ashgabat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ashgabat',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ashkhabad',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ashkhabad',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Atyrau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Atyrau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Baghdad',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Baghdad',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Bahrain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Bahrain',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Baku',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Baku',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Bangkok',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Bangkok',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Barnaul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Barnaul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Beirut',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Beirut',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Bishkek',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Bishkek',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Brunei',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Brunei',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Calcutta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Calcutta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Chita',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Chita',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Choibalsan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Choibalsan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Chongqing',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Chongqing',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Chungking',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Chungking',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Colombo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Colombo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dacca',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dacca',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Damascus',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Damascus',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dhaka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dhaka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dili',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dili',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dubai',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dubai',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Dushanbe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Dushanbe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Famagusta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Famagusta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Gaza',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Gaza',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Harbin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Harbin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Hebron',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Hebron',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ho_Chi_Minh',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ho_Chi_Minh',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Hong_Kong',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Hong_Kong',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Hovd',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Hovd',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Irkutsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Irkutsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Istanbul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Istanbul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Jakarta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Jakarta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Jayapura',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Jayapura',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Jerusalem',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Jerusalem',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kabul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kabul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kamchatka',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kamchatka',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Karachi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Karachi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kashgar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kashgar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kathmandu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kathmandu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Katmandu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Katmandu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Khandyga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Khandyga',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kolkata',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kolkata',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Krasnoyarsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Krasnoyarsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kuala_Lumpur',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kuala_Lumpur',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kuching',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kuching',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Kuwait',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Kuwait',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Macao',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Macao',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Macau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Macau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Magadan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Magadan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Makassar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Makassar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Manila',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Manila',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Muscat',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Muscat',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Nicosia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Nicosia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Novokuznetsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Novokuznetsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Novosibirsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Novosibirsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Omsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Omsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Oral',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Oral',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Phnom_Penh',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Phnom_Penh',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Pontianak',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Pontianak',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Pyongyang',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Pyongyang',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Qatar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Qatar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Qostanay',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Qostanay',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Qyzylorda',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Qyzylorda',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Rangoon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Rangoon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Riyadh',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Riyadh',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Saigon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Saigon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Sakhalin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Sakhalin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Samarkand',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Samarkand',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Seoul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Seoul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Shanghai',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Shanghai',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Singapore',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Singapore',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Srednekolymsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Srednekolymsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Taipei',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Taipei',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tashkent',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tashkent',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tbilisi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tbilisi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tehran',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tehran',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tel_Aviv',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tel_Aviv',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Thimbu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Thimbu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Thimphu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Thimphu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tokyo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tokyo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Tomsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Tomsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ujung_Pandang',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ujung_Pandang',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ulaanbaatar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ulaanbaatar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ulan_Bator',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ulan_Bator',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Urumqi',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Urumqi',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Ust-Nera',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Ust-Nera',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Vientiane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Vientiane',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Vladivostok',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Vladivostok',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yakutsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yakutsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yangon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yangon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yekaterinburg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yekaterinburg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Asia\\Yerevan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Asia\\Yerevan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Azores',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Azores',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Bermuda',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Bermuda',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Canary',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Canary',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Cape_Verde',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Cape_Verde',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Faeroe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Faeroe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Faroe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Faroe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Jan_Mayen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Jan_Mayen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Madeira',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Madeira',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Reykjavik',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Reykjavik',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\South_Georgia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\South_Georgia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\St_Helena',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\St_Helena',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Atlantic\\Stanley',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Atlantic\\Stanley',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\ACT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\ACT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Adelaide',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Adelaide',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Brisbane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Brisbane',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Broken_Hill',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Broken_Hill',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Canberra',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Canberra',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Currie',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Currie',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Darwin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Darwin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Eucla',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Eucla',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Hobart',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Hobart',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\LHI',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\LHI',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Lindeman',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Lindeman',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Lord_Howe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Lord_Howe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Melbourne',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Melbourne',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\NSW',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\NSW',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\North',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\North',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Perth',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Perth',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Queensland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Queensland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\South',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\South',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Sydney',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Sydney',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Tasmania',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Tasmania',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Victoria',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Victoria',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\West',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\West',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Australia\\Yancowinna',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Australia\\Yancowinna',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\Acre',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\Acre',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\DeNoronha',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\DeNoronha',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\East',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\East',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Brazil\\West',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Brazil\\West',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\CET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\CET', 'DATA'),
+ ('_tcl_data\\tzdata\\CST6CDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\CST6CDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Atlantic',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Atlantic',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Central',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Central',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Eastern',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Eastern',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Mountain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Mountain',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Newfoundland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Newfoundland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Pacific',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Pacific',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Saskatchewan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Saskatchewan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Canada\\Yukon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Canada\\Yukon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Chile\\Continental',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Chile\\Continental',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Chile\\EasterIsland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Chile\\EasterIsland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Cuba',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Cuba',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\EET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\EET', 'DATA'),
+ ('_tcl_data\\tzdata\\EST', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\EST', 'DATA'),
+ ('_tcl_data\\tzdata\\EST5EDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\EST5EDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Egypt',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Egypt',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Eire',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Eire',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+1',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+1',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+10',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+10',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+11',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+11',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+12',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+12',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+2',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+2',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+3',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+3',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+4',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+4',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+5',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+5',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+6',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+6',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+7',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+7',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+8',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+8',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT+9',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT+9',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-1',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-1',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-10',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-10',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-11',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-11',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-12',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-12',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-13',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-13',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-14',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-14',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-2',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-2',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-3',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-3',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-4',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-4',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-5',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-5',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-6',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-6',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-7',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-7',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-8',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-8',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT-9',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT-9',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\GMT0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\GMT0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\Greenwich',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\Greenwich',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\UCT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\UCT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\UTC',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\UTC',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\Universal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\Universal',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Etc\\Zulu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Etc\\Zulu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Amsterdam',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Amsterdam',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Andorra',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Andorra',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Astrakhan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Astrakhan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Athens',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Athens',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Belfast',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Belfast',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Belgrade',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Belgrade',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Berlin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Berlin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Bratislava',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Bratislava',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Brussels',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Brussels',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Bucharest',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Bucharest',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Budapest',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Budapest',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Busingen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Busingen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Chisinau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Chisinau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Copenhagen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Copenhagen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Dublin',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Dublin',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Gibraltar',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Gibraltar',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Guernsey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Guernsey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Helsinki',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Helsinki',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Isle_of_Man',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Isle_of_Man',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Istanbul',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Istanbul',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Jersey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Jersey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kaliningrad',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kaliningrad',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kiev',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kiev',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kirov',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kirov',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Kyiv',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Kyiv',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Lisbon',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Lisbon',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Ljubljana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Ljubljana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\London',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\London',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Luxembourg',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Luxembourg',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Madrid',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Madrid',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Malta',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Malta',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Mariehamn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Mariehamn',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Minsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Minsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Monaco',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Monaco',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Moscow',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Moscow',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Nicosia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Nicosia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Oslo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Oslo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Paris',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Paris',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Podgorica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Podgorica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Prague',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Prague',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Riga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Riga',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Rome',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Rome',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Samara',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Samara',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\San_Marino',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\San_Marino',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Sarajevo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Sarajevo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Saratov',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Saratov',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Simferopol',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Simferopol',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Skopje',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Skopje',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Sofia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Sofia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Stockholm',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Stockholm',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Tallinn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Tallinn',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Tirane',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Tirane',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Tiraspol',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Tiraspol',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Ulyanovsk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Ulyanovsk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Uzhgorod',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Uzhgorod',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vaduz',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vaduz',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vatican',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vatican',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vienna',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vienna',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Vilnius',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Vilnius',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Volgograd',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Volgograd',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Warsaw',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Warsaw',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Zagreb',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Zagreb',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Zaporozhye',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Zaporozhye',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Europe\\Zurich',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Europe\\Zurich',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GB', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GB', 'DATA'),
+ ('_tcl_data\\tzdata\\GB-Eire',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GB-Eire',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GMT', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT', 'DATA'),
+ ('_tcl_data\\tzdata\\GMT+0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT+0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GMT-0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT-0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\GMT0',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\GMT0',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Greenwich',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Greenwich',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\HST', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\HST', 'DATA'),
+ ('_tcl_data\\tzdata\\Hongkong',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Hongkong',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Iceland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Iceland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Antananarivo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Antananarivo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Chagos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Chagos',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Christmas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Christmas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Cocos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Cocos',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Comoro',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Comoro',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Kerguelen',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Kerguelen',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Mahe',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Mahe',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Maldives',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Maldives',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Mauritius',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Mauritius',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Mayotte',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Mayotte',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Indian\\Reunion',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Indian\\Reunion',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Iran',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Iran',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Israel',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Israel',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Jamaica',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Jamaica',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Japan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Japan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Kwajalein',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Kwajalein',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Libya',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Libya',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\MET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\MET', 'DATA'),
+ ('_tcl_data\\tzdata\\MST', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\MST', 'DATA'),
+ ('_tcl_data\\tzdata\\MST7MDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\MST7MDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Mexico\\BajaNorte',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Mexico\\BajaNorte',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Mexico\\BajaSur',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Mexico\\BajaSur',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Mexico\\General',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Mexico\\General',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\NZ', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\NZ', 'DATA'),
+ ('_tcl_data\\tzdata\\NZ-CHAT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\NZ-CHAT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Navajo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Navajo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\PRC', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\PRC', 'DATA'),
+ ('_tcl_data\\tzdata\\PST8PDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\PST8PDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Apia',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Apia',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Auckland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Auckland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Bougainville',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Bougainville',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Chatham',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Chatham',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Chuuk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Chuuk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Easter',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Easter',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Efate',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Efate',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Enderbury',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Enderbury',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Fakaofo',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Fakaofo',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Fiji',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Fiji',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Funafuti',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Funafuti',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Galapagos',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Galapagos',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Gambier',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Gambier',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Guadalcanal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Guadalcanal',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Guam',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Guam',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Honolulu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Honolulu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Johnston',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Johnston',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kanton',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kanton',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kiritimati',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kiritimati',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kosrae',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kosrae',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Kwajalein',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Kwajalein',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Majuro',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Majuro',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Marquesas',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Marquesas',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Midway',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Midway',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Nauru',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Nauru',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Niue',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Niue',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Norfolk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Norfolk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Noumea',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Noumea',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Pago_Pago',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Pago_Pago',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Palau',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Palau',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Pitcairn',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Pitcairn',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Pohnpei',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Pohnpei',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Ponape',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Ponape',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Port_Moresby',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Port_Moresby',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Rarotonga',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Rarotonga',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Saipan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Saipan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Samoa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Samoa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Tahiti',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Tahiti',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Tarawa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Tarawa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Tongatapu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Tongatapu',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Truk',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Truk',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Wake',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Wake',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Wallis',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Wallis',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Pacific\\Yap',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Pacific\\Yap',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Poland',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Poland',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Portugal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Portugal',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\ROC', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\ROC', 'DATA'),
+ ('_tcl_data\\tzdata\\ROK', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\ROK', 'DATA'),
+ ('_tcl_data\\tzdata\\Singapore',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Singapore',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\AST4',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\AST4',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\AST4ADT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\AST4ADT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\CST6',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\CST6',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\CST6CDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\CST6CDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\EST5',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\EST5',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\EST5EDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\EST5EDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\HST10',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\HST10',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\MST7',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\MST7',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\MST7MDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\MST7MDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\PST8',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\PST8',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\PST8PDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\PST8PDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\YST9',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\YST9',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\SystemV\\YST9YDT',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\SystemV\\YST9YDT',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\Turkey',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Turkey',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\UCT', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\UCT', 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Alaska',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Alaska',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Aleutian',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Aleutian',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Arizona',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Arizona',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Central',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Central',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\East-Indiana',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\East-Indiana',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Eastern',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Eastern',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Hawaii',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Hawaii',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Indiana-Starke',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Indiana-Starke',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Michigan',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Michigan',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Mountain',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Mountain',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Pacific',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Pacific',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\US\\Samoa',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\US\\Samoa',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\UTC', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\UTC', 'DATA'),
+ ('_tcl_data\\tzdata\\Universal',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Universal',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\W-SU',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\W-SU',
+ 'DATA'),
+ ('_tcl_data\\tzdata\\WET', 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\WET', 'DATA'),
+ ('_tcl_data\\tzdata\\Zulu',
+ 'C:\\Python312\\tcl\\tcl8.6\\tzdata\\Zulu',
+ 'DATA'),
+ ('_tcl_data\\word.tcl', 'C:\\Python312\\tcl\\tcl8.6\\word.tcl', 'DATA'),
+ ('_tk_data\\bgerror.tcl', 'C:\\Python312\\tcl\\tk8.6\\bgerror.tcl', 'DATA'),
+ ('_tk_data\\button.tcl', 'C:\\Python312\\tcl\\tk8.6\\button.tcl', 'DATA'),
+ ('_tk_data\\choosedir.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\choosedir.tcl',
+ 'DATA'),
+ ('_tk_data\\clrpick.tcl', 'C:\\Python312\\tcl\\tk8.6\\clrpick.tcl', 'DATA'),
+ ('_tk_data\\comdlg.tcl', 'C:\\Python312\\tcl\\tk8.6\\comdlg.tcl', 'DATA'),
+ ('_tk_data\\console.tcl', 'C:\\Python312\\tcl\\tk8.6\\console.tcl', 'DATA'),
+ ('_tk_data\\dialog.tcl', 'C:\\Python312\\tcl\\tk8.6\\dialog.tcl', 'DATA'),
+ ('_tk_data\\entry.tcl', 'C:\\Python312\\tcl\\tk8.6\\entry.tcl', 'DATA'),
+ ('_tk_data\\focus.tcl', 'C:\\Python312\\tcl\\tk8.6\\focus.tcl', 'DATA'),
+ ('_tk_data\\fontchooser.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\fontchooser.tcl',
+ 'DATA'),
+ ('_tk_data\\iconlist.tcl', 'C:\\Python312\\tcl\\tk8.6\\iconlist.tcl', 'DATA'),
+ ('_tk_data\\icons.tcl', 'C:\\Python312\\tcl\\tk8.6\\icons.tcl', 'DATA'),
+ ('_tk_data\\images\\README',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\README',
+ 'DATA'),
+ ('_tk_data\\images\\logo.eps',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logo.eps',
+ 'DATA'),
+ ('_tk_data\\images\\logo100.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logo100.gif',
+ 'DATA'),
+ ('_tk_data\\images\\logo64.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logo64.gif',
+ 'DATA'),
+ ('_tk_data\\images\\logoLarge.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logoLarge.gif',
+ 'DATA'),
+ ('_tk_data\\images\\logoMed.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\logoMed.gif',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo.eps',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo.eps',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo100.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo100.gif',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo150.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo150.gif',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo175.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo175.gif',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo200.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo200.gif',
+ 'DATA'),
+ ('_tk_data\\images\\pwrdLogo75.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\pwrdLogo75.gif',
+ 'DATA'),
+ ('_tk_data\\images\\tai-ku.gif',
+ 'C:\\Python312\\tcl\\tk8.6\\images\\tai-ku.gif',
+ 'DATA'),
+ ('_tk_data\\license.terms',
+ 'C:\\Python312\\tcl\\tk8.6\\license.terms',
+ 'DATA'),
+ ('_tk_data\\listbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\listbox.tcl', 'DATA'),
+ ('_tk_data\\megawidget.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\megawidget.tcl',
+ 'DATA'),
+ ('_tk_data\\menu.tcl', 'C:\\Python312\\tcl\\tk8.6\\menu.tcl', 'DATA'),
+ ('_tk_data\\mkpsenc.tcl', 'C:\\Python312\\tcl\\tk8.6\\mkpsenc.tcl', 'DATA'),
+ ('_tk_data\\msgbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\msgbox.tcl', 'DATA'),
+ ('_tk_data\\msgs\\cs.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\cs.msg', 'DATA'),
+ ('_tk_data\\msgs\\da.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\da.msg', 'DATA'),
+ ('_tk_data\\msgs\\de.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\de.msg', 'DATA'),
+ ('_tk_data\\msgs\\el.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\el.msg', 'DATA'),
+ ('_tk_data\\msgs\\en.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\en.msg', 'DATA'),
+ ('_tk_data\\msgs\\en_gb.msg',
+ 'C:\\Python312\\tcl\\tk8.6\\msgs\\en_gb.msg',
+ 'DATA'),
+ ('_tk_data\\msgs\\eo.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\eo.msg', 'DATA'),
+ ('_tk_data\\msgs\\es.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\es.msg', 'DATA'),
+ ('_tk_data\\msgs\\fi.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\fi.msg', 'DATA'),
+ ('_tk_data\\msgs\\fr.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\fr.msg', 'DATA'),
+ ('_tk_data\\msgs\\hu.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\hu.msg', 'DATA'),
+ ('_tk_data\\msgs\\it.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\it.msg', 'DATA'),
+ ('_tk_data\\msgs\\nl.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\nl.msg', 'DATA'),
+ ('_tk_data\\msgs\\pl.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\pl.msg', 'DATA'),
+ ('_tk_data\\msgs\\pt.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\pt.msg', 'DATA'),
+ ('_tk_data\\msgs\\ru.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\ru.msg', 'DATA'),
+ ('_tk_data\\msgs\\sv.msg', 'C:\\Python312\\tcl\\tk8.6\\msgs\\sv.msg', 'DATA'),
+ ('_tk_data\\msgs\\zh_cn.msg',
+ 'C:\\Python312\\tcl\\tk8.6\\msgs\\zh_cn.msg',
+ 'DATA'),
+ ('_tk_data\\obsolete.tcl', 'C:\\Python312\\tcl\\tk8.6\\obsolete.tcl', 'DATA'),
+ ('_tk_data\\optMenu.tcl', 'C:\\Python312\\tcl\\tk8.6\\optMenu.tcl', 'DATA'),
+ ('_tk_data\\palette.tcl', 'C:\\Python312\\tcl\\tk8.6\\palette.tcl', 'DATA'),
+ ('_tk_data\\panedwindow.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\panedwindow.tcl',
+ 'DATA'),
+ ('_tk_data\\pkgIndex.tcl', 'C:\\Python312\\tcl\\tk8.6\\pkgIndex.tcl', 'DATA'),
+ ('_tk_data\\safetk.tcl', 'C:\\Python312\\tcl\\tk8.6\\safetk.tcl', 'DATA'),
+ ('_tk_data\\scale.tcl', 'C:\\Python312\\tcl\\tk8.6\\scale.tcl', 'DATA'),
+ ('_tk_data\\scrlbar.tcl', 'C:\\Python312\\tcl\\tk8.6\\scrlbar.tcl', 'DATA'),
+ ('_tk_data\\spinbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\spinbox.tcl', 'DATA'),
+ ('_tk_data\\tclIndex', 'C:\\Python312\\tcl\\tk8.6\\tclIndex', 'DATA'),
+ ('_tk_data\\tearoff.tcl', 'C:\\Python312\\tcl\\tk8.6\\tearoff.tcl', 'DATA'),
+ ('_tk_data\\text.tcl', 'C:\\Python312\\tcl\\tk8.6\\text.tcl', 'DATA'),
+ ('_tk_data\\tk.tcl', 'C:\\Python312\\tcl\\tk8.6\\tk.tcl', 'DATA'),
+ ('_tk_data\\tkfbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\tkfbox.tcl', 'DATA'),
+ ('_tk_data\\ttk\\altTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\altTheme.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\aquaTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\aquaTheme.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\button.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\button.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\clamTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\clamTheme.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\classicTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\classicTheme.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\combobox.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\combobox.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\cursors.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\cursors.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\defaults.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\defaults.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\entry.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\entry.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\fonts.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\fonts.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\menubutton.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\menubutton.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\notebook.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\notebook.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\panedwindow.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\panedwindow.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\progress.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\progress.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\scale.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\scale.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\scrollbar.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\scrollbar.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\sizegrip.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\sizegrip.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\spinbox.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\spinbox.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\treeview.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\treeview.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\ttk.tcl', 'C:\\Python312\\tcl\\tk8.6\\ttk\\ttk.tcl', 'DATA'),
+ ('_tk_data\\ttk\\utils.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\utils.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\vistaTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\vistaTheme.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\winTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\winTheme.tcl',
+ 'DATA'),
+ ('_tk_data\\ttk\\xpTheme.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\ttk\\xpTheme.tcl',
+ 'DATA'),
+ ('_tk_data\\unsupported.tcl',
+ 'C:\\Python312\\tcl\\tk8.6\\unsupported.tcl',
+ 'DATA'),
+ ('_tk_data\\xmfbox.tcl', 'C:\\Python312\\tcl\\tk8.6\\xmfbox.tcl', 'DATA'),
+ ('_tkinter.pyd', 'C:\\Python312\\DLLs\\_tkinter.pyd', 'EXTENSION'),
+ ('_wmi.pyd', 'C:\\Python312\\DLLs\\_wmi.pyd', 'EXTENSION'),
+ ('api-ms-win-core-console-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-console-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-datetime-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-datetime-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-debug-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-debug-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-errorhandling-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-errorhandling-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-fibers-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-fibers-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-file-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-file-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-file-l1-2-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-file-l1-2-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-file-l2-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-file-l2-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-handle-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-handle-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-heap-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-heap-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-interlocked-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-interlocked-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-libraryloader-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-libraryloader-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-localization-l1-2-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-localization-l1-2-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-memory-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-memory-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-namedpipe-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-namedpipe-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-processenvironment-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-processenvironment-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-processthreads-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-processthreads-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-processthreads-l1-1-1.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-processthreads-l1-1-1.dll',
+ 'BINARY'),
+ ('api-ms-win-core-profile-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-profile-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-rtlsupport-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-rtlsupport-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-string-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-string-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-synch-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-synch-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-synch-l1-2-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-synch-l1-2-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-sysinfo-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-sysinfo-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-timezone-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-timezone-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-core-util-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-core-util-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-conio-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-conio-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-convert-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-convert-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-environment-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-environment-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-filesystem-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-filesystem-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-heap-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-heap-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-locale-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-locale-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-math-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-math-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-private-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-private-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-process-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-process-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-runtime-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-runtime-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-stdio-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-stdio-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-string-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-string-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-time-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-time-l1-1-0.dll',
+ 'BINARY'),
+ ('api-ms-win-crt-utility-l1-1-0.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\api-ms-win-crt-utility-l1-1-0.dll',
+ 'BINARY'),
+ ('base_library.zip',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\base_library.zip',
+ 'DATA'),
+ ('cv2\\Error\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\Error\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\LICENSE-3RD-PARTY.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\LICENSE-3RD-PARTY.txt',
+ 'DATA'),
+ ('cv2\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\LICENSE.txt',
+ 'DATA'),
+ ('cv2\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\__init__.py',
+ 'DATA'),
+ ('cv2\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\aruco\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\aruco\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\barcode\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\barcode\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ccm\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ccm\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\config-3.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config-3.py',
+ 'DATA'),
+ ('cv2\\config.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\config.py',
+ 'DATA'),
+ ('cv2\\cuda\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\cuda\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\cv2.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\cv2.pyd',
+ 'EXTENSION'),
+ ('cv2\\data\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\data\\__init__.py',
+ 'DATA'),
+ ('cv2\\detail\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\detail\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\dnn\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\dnn\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\fisheye\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\fisheye\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\flann\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\flann\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\instr\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\instr\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ipp\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ipp\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\load_config_py2.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py2.py',
+ 'DATA'),
+ ('cv2\\load_config_py3.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\load_config_py3.py',
+ 'DATA'),
+ ('cv2\\mat_wrapper\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\mat_wrapper\\__init__.py',
+ 'DATA'),
+ ('cv2\\mcc\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\mcc\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\misc\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\__init__.py',
+ 'DATA'),
+ ('cv2\\misc\\version.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\misc\\version.py',
+ 'DATA'),
+ ('cv2\\ocl\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ocl\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\ogl\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\ogl\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\opencv_videoio_ffmpeg500_64.dll',
+ 'BINARY'),
+ ('cv2\\parallel\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\parallel\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\py.typed',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\py.typed',
+ 'DATA'),
+ ('cv2\\samples\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\samples\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\segmentation\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\segmentation\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\typing\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\typing\\__init__.py',
+ 'DATA'),
+ ('cv2\\utils\\__init__.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\__init__.py',
+ 'DATA'),
+ ('cv2\\utils\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\fs\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\fs\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\logging\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\logging\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\utils\\nested\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\utils\\nested\\__init__.pyi',
+ 'DATA'),
+ ('cv2\\version.py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\version.py',
+ 'DATA'),
+ ('cv2\\videoio_registry\\__init__.pyi',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\cv2\\videoio_registry\\__init__.pyi',
+ 'DATA'),
+ ('libcrypto-3.dll', 'C:\\Python312\\DLLs\\libcrypto-3.dll', 'BINARY'),
+ ('libffi-8.dll', 'C:\\Python312\\DLLs\\libffi-8.dll', 'BINARY'),
+ ('libssl-3.dll', 'C:\\Python312\\DLLs\\libssl-3.dll', 'BINARY'),
+ ('numpy-2.5.2.dist-info\\DELVEWHEEL',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\DELVEWHEEL',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\INSTALLER',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\INSTALLER',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\METADATA',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\METADATA',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\RECORD',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\RECORD',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\REQUESTED',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\REQUESTED',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\WHEEL',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\WHEEL',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\entry_points.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\entry_points.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\include\\numpy\\libdivide\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\include\\numpy\\libdivide\\LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\common\\pythoncapi-compat\\COPYING',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\common\\pythoncapi-compat\\COPYING',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\highway\\LICENSE',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\highway\\LICENSE',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\multiarray\\dragon4_LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\multiarray\\dragon4_LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\npysort\\x86-simd-sort\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\npysort\\x86-simd-sort\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\umath\\svml\\LICENSE',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\_core\\src\\umath\\svml\\LICENSE',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\fft\\pocketfft\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\fft\\pocketfft\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\linalg\\lapack_lite\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\linalg\\lapack_lite\\LICENSE.txt',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\ma\\LICENSE',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\ma\\LICENSE',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\distributions\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\distributions\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\mt19937\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\mt19937\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\pcg64\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\pcg64\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\philox\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\philox\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\sfc64\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\sfc64\\LICENSE.md',
+ 'DATA'),
+ ('numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\splitmix64\\LICENSE.md',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy-2.5.2.dist-info\\licenses\\numpy\\random\\src\\splitmix64\\LICENSE.md',
+ 'DATA'),
+ ('numpy.libs\\libscipy_openblas64_-327b2e0bcffce2882e0dc04cdeb4eaa6.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy.libs\\libscipy_openblas64_-327b2e0bcffce2882e0dc04cdeb4eaa6.dll',
+ 'BINARY'),
+ ('numpy.libs\\msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy.libs\\msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll',
+ 'BINARY'),
+ ('numpy\\_core\\_multiarray_tests.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_multiarray_tests.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\_core\\_multiarray_umath.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_multiarray_umath.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\fft\\_pocketfft_umath.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\_pocketfft_umath.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\linalg\\_umath_linalg.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\linalg\\_umath_linalg.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_bounded_integers.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_bounded_integers.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_common.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_common.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_generator.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_generator.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_mt19937.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_mt19937.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_pcg64.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_pcg64.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_philox.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_philox.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\_sfc64.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_sfc64.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\bit_generator.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\bit_generator.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('numpy\\random\\mtrand.cp312-win_amd64.pyd',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\mtrand.cp312-win_amd64.pyd',
+ 'EXTENSION'),
+ ('opencv_python-5.0.0.93.dist-info\\INSTALLER',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\INSTALLER',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\LICENSE-3RD-PARTY.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\LICENSE-3RD-PARTY.txt',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\LICENSE.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\LICENSE.txt',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\METADATA',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\METADATA',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\RECORD',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\RECORD',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\REQUESTED',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\REQUESTED',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\WHEEL',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\WHEEL',
+ 'DATA'),
+ ('opencv_python-5.0.0.93.dist-info\\top_level.txt',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\opencv_python-5.0.0.93.dist-info\\top_level.txt',
+ 'DATA'),
+ ('pyexpat.pyd', 'C:\\Python312\\DLLs\\pyexpat.pyd', 'EXTENSION'),
+ ('python3.dll', 'C:\\Python312\\python3.dll', 'BINARY'),
+ ('python312.dll', 'C:\\Python312\\python312.dll', 'BINARY'),
+ ('select.pyd', 'C:\\Python312\\DLLs\\select.pyd', 'EXTENSION'),
+ ('tcl86t.dll', 'C:\\Python312\\DLLs\\tcl86t.dll', 'BINARY'),
+ ('tcl8\\8.4\\platform-1.0.19.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.4\\platform-1.0.19.tm',
+ 'DATA'),
+ ('tcl8\\8.4\\platform\\shell-1.1.4.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.4\\platform\\shell-1.1.4.tm',
+ 'DATA'),
+ ('tcl8\\8.5\\msgcat-1.6.1.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.5\\msgcat-1.6.1.tm',
+ 'DATA'),
+ ('tcl8\\8.5\\tcltest-2.5.5.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.5\\tcltest-2.5.5.tm',
+ 'DATA'),
+ ('tcl8\\8.6\\http-2.9.8.tm',
+ 'C:\\Python312\\tcl\\tcl8\\8.6\\http-2.9.8.tm',
+ 'DATA'),
+ ('tk86t.dll', 'C:\\Python312\\DLLs\\tk86t.dll', 'BINARY'),
+ ('ucrtbase.dll',
+ 'C:\\Program Files (x86)\\Windows Kits\\10\\Windows Performance '
+ 'Toolkit\\ucrtbase.dll',
+ 'BINARY'),
+ ('unicodedata.pyd', 'C:\\Python312\\DLLs\\unicodedata.pyd', 'EXTENSION'),
+ ('zlib1.dll', 'C:\\Python312\\DLLs\\zlib1.dll', 'BINARY')],)
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/EXE-00.toc b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/EXE-00.toc
new file mode 100644
index 000000000..fa97f9ab4
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/EXE-00.toc
@@ -0,0 +1,77 @@
+('C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\MaskReviewer.exe',
+ False,
+ False,
+ True,
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\bootloader\\images\\icon-windowed.ico',
+ None,
+ False,
+ False,
+ b'\n\n \n \n \n \n \n \n \n '
+ b'\n <'
+ b'application>\n \n \n '
+ b' \n \n \n \n <'
+ b'/compatibility>\n '
+ b'\n \n true\n \n \n \n \n \n \n \n',
+ True,
+ False,
+ None,
+ None,
+ None,
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\MaskReviewer.pkg',
+ [('pyi-contents-directory _internal', '', 'OPTION'),
+ ('PYZ-00.pyz',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\PYZ-00.pyz',
+ 'PYZ'),
+ ('struct',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\struct.pyc',
+ 'PYMODULE'),
+ ('pyimod01_archive',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod01_archive.pyc',
+ 'PYMODULE'),
+ ('pyimod02_importers',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod02_importers.pyc',
+ 'PYMODULE'),
+ ('pyimod03_ctypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod03_ctypes.pyc',
+ 'PYMODULE'),
+ ('pyimod04_pywin32',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod04_pywin32.pyc',
+ 'PYMODULE'),
+ ('pyiboot01_bootstrap',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
+ 'PYSOURCE'),
+ ('pyi_rth__tkinter',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth__tkinter.py',
+ 'PYSOURCE'),
+ ('pyi_rth_inspect',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
+ 'PYSOURCE'),
+ ('pyi_rth_pkgutil',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
+ 'PYSOURCE'),
+ ('pyi_rth_multiprocessing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
+ 'PYSOURCE'),
+ ('MaskReviewer',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\MaskReviewer.py',
+ 'PYSOURCE')],
+ [],
+ False,
+ False,
+ 1787944409,
+ [('runw.exe',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\bootloader\\Windows-64bit-intel\\runw.exe',
+ 'EXECUTABLE')],
+ 'C:\\Python312\\python312.dll')
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/MaskReviewer.exe b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/MaskReviewer.exe
new file mode 100644
index 000000000..be22ca00d
Binary files /dev/null and b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/MaskReviewer.exe differ
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/MaskReviewer.pkg b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/MaskReviewer.pkg
new file mode 100644
index 000000000..e78bb5969
Binary files /dev/null and b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/MaskReviewer.pkg differ
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PKG-00.toc b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PKG-00.toc
new file mode 100644
index 000000000..4ae6328d5
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PKG-00.toc
@@ -0,0 +1,55 @@
+('C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\MaskReviewer.pkg',
+ {'BINARY': True,
+ 'DATA': True,
+ 'EXECUTABLE': True,
+ 'EXTENSION': True,
+ 'PYMODULE': True,
+ 'PYSOURCE': True,
+ 'PYZ': False,
+ 'SPLASH': True,
+ 'SYMLINK': False},
+ [('pyi-contents-directory _internal', '', 'OPTION'),
+ ('PYZ-00.pyz',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\PYZ-00.pyz',
+ 'PYZ'),
+ ('struct',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\struct.pyc',
+ 'PYMODULE'),
+ ('pyimod01_archive',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod01_archive.pyc',
+ 'PYMODULE'),
+ ('pyimod02_importers',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod02_importers.pyc',
+ 'PYMODULE'),
+ ('pyimod03_ctypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod03_ctypes.pyc',
+ 'PYMODULE'),
+ ('pyimod04_pywin32',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\localpycs\\pyimod04_pywin32.pyc',
+ 'PYMODULE'),
+ ('pyiboot01_bootstrap',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
+ 'PYSOURCE'),
+ ('pyi_rth__tkinter',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth__tkinter.py',
+ 'PYSOURCE'),
+ ('pyi_rth_inspect',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
+ 'PYSOURCE'),
+ ('pyi_rth_pkgutil',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
+ 'PYSOURCE'),
+ ('pyi_rth_multiprocessing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
+ 'PYSOURCE'),
+ ('MaskReviewer',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\MaskReviewer.py',
+ 'PYSOURCE')],
+ 'python312.dll',
+ True,
+ False,
+ False,
+ [],
+ None,
+ None,
+ None)
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PYZ-00.pyz b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PYZ-00.pyz
new file mode 100644
index 000000000..9e91e7584
Binary files /dev/null and b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PYZ-00.pyz differ
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PYZ-00.toc b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PYZ-00.toc
new file mode 100644
index 000000000..26ac02e3a
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/PYZ-00.toc
@@ -0,0 +1,815 @@
+('C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\build\\MaskReviewer\\PYZ-00.pyz',
+ [('__future__', 'C:\\Python312\\Lib\\__future__.py', 'PYMODULE'),
+ ('_aix_support', 'C:\\Python312\\Lib\\_aix_support.py', 'PYMODULE'),
+ ('_compat_pickle', 'C:\\Python312\\Lib\\_compat_pickle.py', 'PYMODULE'),
+ ('_compression', 'C:\\Python312\\Lib\\_compression.py', 'PYMODULE'),
+ ('_py_abc', 'C:\\Python312\\Lib\\_py_abc.py', 'PYMODULE'),
+ ('_pydatetime', 'C:\\Python312\\Lib\\_pydatetime.py', 'PYMODULE'),
+ ('_pydecimal', 'C:\\Python312\\Lib\\_pydecimal.py', 'PYMODULE'),
+ ('_strptime', 'C:\\Python312\\Lib\\_strptime.py', 'PYMODULE'),
+ ('_threading_local', 'C:\\Python312\\Lib\\_threading_local.py', 'PYMODULE'),
+ ('argparse', 'C:\\Python312\\Lib\\argparse.py', 'PYMODULE'),
+ ('ast', 'C:\\Python312\\Lib\\ast.py', 'PYMODULE'),
+ ('asyncio', 'C:\\Python312\\Lib\\asyncio\\__init__.py', 'PYMODULE'),
+ ('asyncio.base_events',
+ 'C:\\Python312\\Lib\\asyncio\\base_events.py',
+ 'PYMODULE'),
+ ('asyncio.base_futures',
+ 'C:\\Python312\\Lib\\asyncio\\base_futures.py',
+ 'PYMODULE'),
+ ('asyncio.base_subprocess',
+ 'C:\\Python312\\Lib\\asyncio\\base_subprocess.py',
+ 'PYMODULE'),
+ ('asyncio.base_tasks',
+ 'C:\\Python312\\Lib\\asyncio\\base_tasks.py',
+ 'PYMODULE'),
+ ('asyncio.constants',
+ 'C:\\Python312\\Lib\\asyncio\\constants.py',
+ 'PYMODULE'),
+ ('asyncio.coroutines',
+ 'C:\\Python312\\Lib\\asyncio\\coroutines.py',
+ 'PYMODULE'),
+ ('asyncio.events', 'C:\\Python312\\Lib\\asyncio\\events.py', 'PYMODULE'),
+ ('asyncio.exceptions',
+ 'C:\\Python312\\Lib\\asyncio\\exceptions.py',
+ 'PYMODULE'),
+ ('asyncio.format_helpers',
+ 'C:\\Python312\\Lib\\asyncio\\format_helpers.py',
+ 'PYMODULE'),
+ ('asyncio.futures', 'C:\\Python312\\Lib\\asyncio\\futures.py', 'PYMODULE'),
+ ('asyncio.locks', 'C:\\Python312\\Lib\\asyncio\\locks.py', 'PYMODULE'),
+ ('asyncio.log', 'C:\\Python312\\Lib\\asyncio\\log.py', 'PYMODULE'),
+ ('asyncio.mixins', 'C:\\Python312\\Lib\\asyncio\\mixins.py', 'PYMODULE'),
+ ('asyncio.proactor_events',
+ 'C:\\Python312\\Lib\\asyncio\\proactor_events.py',
+ 'PYMODULE'),
+ ('asyncio.protocols',
+ 'C:\\Python312\\Lib\\asyncio\\protocols.py',
+ 'PYMODULE'),
+ ('asyncio.queues', 'C:\\Python312\\Lib\\asyncio\\queues.py', 'PYMODULE'),
+ ('asyncio.runners', 'C:\\Python312\\Lib\\asyncio\\runners.py', 'PYMODULE'),
+ ('asyncio.selector_events',
+ 'C:\\Python312\\Lib\\asyncio\\selector_events.py',
+ 'PYMODULE'),
+ ('asyncio.sslproto', 'C:\\Python312\\Lib\\asyncio\\sslproto.py', 'PYMODULE'),
+ ('asyncio.staggered',
+ 'C:\\Python312\\Lib\\asyncio\\staggered.py',
+ 'PYMODULE'),
+ ('asyncio.streams', 'C:\\Python312\\Lib\\asyncio\\streams.py', 'PYMODULE'),
+ ('asyncio.subprocess',
+ 'C:\\Python312\\Lib\\asyncio\\subprocess.py',
+ 'PYMODULE'),
+ ('asyncio.taskgroups',
+ 'C:\\Python312\\Lib\\asyncio\\taskgroups.py',
+ 'PYMODULE'),
+ ('asyncio.tasks', 'C:\\Python312\\Lib\\asyncio\\tasks.py', 'PYMODULE'),
+ ('asyncio.threads', 'C:\\Python312\\Lib\\asyncio\\threads.py', 'PYMODULE'),
+ ('asyncio.timeouts', 'C:\\Python312\\Lib\\asyncio\\timeouts.py', 'PYMODULE'),
+ ('asyncio.transports',
+ 'C:\\Python312\\Lib\\asyncio\\transports.py',
+ 'PYMODULE'),
+ ('asyncio.trsock', 'C:\\Python312\\Lib\\asyncio\\trsock.py', 'PYMODULE'),
+ ('asyncio.unix_events',
+ 'C:\\Python312\\Lib\\asyncio\\unix_events.py',
+ 'PYMODULE'),
+ ('asyncio.windows_events',
+ 'C:\\Python312\\Lib\\asyncio\\windows_events.py',
+ 'PYMODULE'),
+ ('asyncio.windows_utils',
+ 'C:\\Python312\\Lib\\asyncio\\windows_utils.py',
+ 'PYMODULE'),
+ ('base64', 'C:\\Python312\\Lib\\base64.py', 'PYMODULE'),
+ ('bdb', 'C:\\Python312\\Lib\\bdb.py', 'PYMODULE'),
+ ('bisect', 'C:\\Python312\\Lib\\bisect.py', 'PYMODULE'),
+ ('bz2', 'C:\\Python312\\Lib\\bz2.py', 'PYMODULE'),
+ ('calendar', 'C:\\Python312\\Lib\\calendar.py', 'PYMODULE'),
+ ('cmd', 'C:\\Python312\\Lib\\cmd.py', 'PYMODULE'),
+ ('code', 'C:\\Python312\\Lib\\code.py', 'PYMODULE'),
+ ('codeop', 'C:\\Python312\\Lib\\codeop.py', 'PYMODULE'),
+ ('concurrent', 'C:\\Python312\\Lib\\concurrent\\__init__.py', 'PYMODULE'),
+ ('concurrent.futures',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\__init__.py',
+ 'PYMODULE'),
+ ('concurrent.futures._base',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\_base.py',
+ 'PYMODULE'),
+ ('concurrent.futures.process',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\process.py',
+ 'PYMODULE'),
+ ('concurrent.futures.thread',
+ 'C:\\Python312\\Lib\\concurrent\\futures\\thread.py',
+ 'PYMODULE'),
+ ('contextlib', 'C:\\Python312\\Lib\\contextlib.py', 'PYMODULE'),
+ ('contextvars', 'C:\\Python312\\Lib\\contextvars.py', 'PYMODULE'),
+ ('copy', 'C:\\Python312\\Lib\\copy.py', 'PYMODULE'),
+ ('csv', 'C:\\Python312\\Lib\\csv.py', 'PYMODULE'),
+ ('ctypes', 'C:\\Python312\\Lib\\ctypes\\__init__.py', 'PYMODULE'),
+ ('ctypes._endian', 'C:\\Python312\\Lib\\ctypes\\_endian.py', 'PYMODULE'),
+ ('dataclasses', 'C:\\Python312\\Lib\\dataclasses.py', 'PYMODULE'),
+ ('datetime', 'C:\\Python312\\Lib\\datetime.py', 'PYMODULE'),
+ ('decimal', 'C:\\Python312\\Lib\\decimal.py', 'PYMODULE'),
+ ('difflib', 'C:\\Python312\\Lib\\difflib.py', 'PYMODULE'),
+ ('dis', 'C:\\Python312\\Lib\\dis.py', 'PYMODULE'),
+ ('doctest', 'C:\\Python312\\Lib\\doctest.py', 'PYMODULE'),
+ ('email', 'C:\\Python312\\Lib\\email\\__init__.py', 'PYMODULE'),
+ ('email._encoded_words',
+ 'C:\\Python312\\Lib\\email\\_encoded_words.py',
+ 'PYMODULE'),
+ ('email._header_value_parser',
+ 'C:\\Python312\\Lib\\email\\_header_value_parser.py',
+ 'PYMODULE'),
+ ('email._parseaddr', 'C:\\Python312\\Lib\\email\\_parseaddr.py', 'PYMODULE'),
+ ('email._policybase',
+ 'C:\\Python312\\Lib\\email\\_policybase.py',
+ 'PYMODULE'),
+ ('email.base64mime', 'C:\\Python312\\Lib\\email\\base64mime.py', 'PYMODULE'),
+ ('email.charset', 'C:\\Python312\\Lib\\email\\charset.py', 'PYMODULE'),
+ ('email.contentmanager',
+ 'C:\\Python312\\Lib\\email\\contentmanager.py',
+ 'PYMODULE'),
+ ('email.encoders', 'C:\\Python312\\Lib\\email\\encoders.py', 'PYMODULE'),
+ ('email.errors', 'C:\\Python312\\Lib\\email\\errors.py', 'PYMODULE'),
+ ('email.feedparser', 'C:\\Python312\\Lib\\email\\feedparser.py', 'PYMODULE'),
+ ('email.generator', 'C:\\Python312\\Lib\\email\\generator.py', 'PYMODULE'),
+ ('email.header', 'C:\\Python312\\Lib\\email\\header.py', 'PYMODULE'),
+ ('email.headerregistry',
+ 'C:\\Python312\\Lib\\email\\headerregistry.py',
+ 'PYMODULE'),
+ ('email.iterators', 'C:\\Python312\\Lib\\email\\iterators.py', 'PYMODULE'),
+ ('email.message', 'C:\\Python312\\Lib\\email\\message.py', 'PYMODULE'),
+ ('email.parser', 'C:\\Python312\\Lib\\email\\parser.py', 'PYMODULE'),
+ ('email.policy', 'C:\\Python312\\Lib\\email\\policy.py', 'PYMODULE'),
+ ('email.quoprimime', 'C:\\Python312\\Lib\\email\\quoprimime.py', 'PYMODULE'),
+ ('email.utils', 'C:\\Python312\\Lib\\email\\utils.py', 'PYMODULE'),
+ ('fileinput', 'C:\\Python312\\Lib\\fileinput.py', 'PYMODULE'),
+ ('fnmatch', 'C:\\Python312\\Lib\\fnmatch.py', 'PYMODULE'),
+ ('fractions', 'C:\\Python312\\Lib\\fractions.py', 'PYMODULE'),
+ ('ftplib', 'C:\\Python312\\Lib\\ftplib.py', 'PYMODULE'),
+ ('getopt', 'C:\\Python312\\Lib\\getopt.py', 'PYMODULE'),
+ ('getpass', 'C:\\Python312\\Lib\\getpass.py', 'PYMODULE'),
+ ('gettext', 'C:\\Python312\\Lib\\gettext.py', 'PYMODULE'),
+ ('glob', 'C:\\Python312\\Lib\\glob.py', 'PYMODULE'),
+ ('gzip', 'C:\\Python312\\Lib\\gzip.py', 'PYMODULE'),
+ ('hashlib', 'C:\\Python312\\Lib\\hashlib.py', 'PYMODULE'),
+ ('hmac', 'C:\\Python312\\Lib\\hmac.py', 'PYMODULE'),
+ ('html', 'C:\\Python312\\Lib\\html\\__init__.py', 'PYMODULE'),
+ ('html.entities', 'C:\\Python312\\Lib\\html\\entities.py', 'PYMODULE'),
+ ('http', 'C:\\Python312\\Lib\\http\\__init__.py', 'PYMODULE'),
+ ('http.client', 'C:\\Python312\\Lib\\http\\client.py', 'PYMODULE'),
+ ('http.cookiejar', 'C:\\Python312\\Lib\\http\\cookiejar.py', 'PYMODULE'),
+ ('http.server', 'C:\\Python312\\Lib\\http\\server.py', 'PYMODULE'),
+ ('importlib', 'C:\\Python312\\Lib\\importlib\\__init__.py', 'PYMODULE'),
+ ('importlib._abc', 'C:\\Python312\\Lib\\importlib\\_abc.py', 'PYMODULE'),
+ ('importlib._bootstrap',
+ 'C:\\Python312\\Lib\\importlib\\_bootstrap.py',
+ 'PYMODULE'),
+ ('importlib._bootstrap_external',
+ 'C:\\Python312\\Lib\\importlib\\_bootstrap_external.py',
+ 'PYMODULE'),
+ ('importlib.abc', 'C:\\Python312\\Lib\\importlib\\abc.py', 'PYMODULE'),
+ ('importlib.machinery',
+ 'C:\\Python312\\Lib\\importlib\\machinery.py',
+ 'PYMODULE'),
+ ('importlib.metadata',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\__init__.py',
+ 'PYMODULE'),
+ ('importlib.metadata._adapters',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_adapters.py',
+ 'PYMODULE'),
+ ('importlib.metadata._collections',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_collections.py',
+ 'PYMODULE'),
+ ('importlib.metadata._functools',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_functools.py',
+ 'PYMODULE'),
+ ('importlib.metadata._itertools',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_itertools.py',
+ 'PYMODULE'),
+ ('importlib.metadata._meta',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_meta.py',
+ 'PYMODULE'),
+ ('importlib.metadata._text',
+ 'C:\\Python312\\Lib\\importlib\\metadata\\_text.py',
+ 'PYMODULE'),
+ ('importlib.readers',
+ 'C:\\Python312\\Lib\\importlib\\readers.py',
+ 'PYMODULE'),
+ ('importlib.resources',
+ 'C:\\Python312\\Lib\\importlib\\resources\\__init__.py',
+ 'PYMODULE'),
+ ('importlib.resources._adapters',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_adapters.py',
+ 'PYMODULE'),
+ ('importlib.resources._common',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_common.py',
+ 'PYMODULE'),
+ ('importlib.resources._itertools',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_itertools.py',
+ 'PYMODULE'),
+ ('importlib.resources._legacy',
+ 'C:\\Python312\\Lib\\importlib\\resources\\_legacy.py',
+ 'PYMODULE'),
+ ('importlib.resources.abc',
+ 'C:\\Python312\\Lib\\importlib\\resources\\abc.py',
+ 'PYMODULE'),
+ ('importlib.resources.readers',
+ 'C:\\Python312\\Lib\\importlib\\resources\\readers.py',
+ 'PYMODULE'),
+ ('importlib.util', 'C:\\Python312\\Lib\\importlib\\util.py', 'PYMODULE'),
+ ('inspect', 'C:\\Python312\\Lib\\inspect.py', 'PYMODULE'),
+ ('ipaddress', 'C:\\Python312\\Lib\\ipaddress.py', 'PYMODULE'),
+ ('json', 'C:\\Python312\\Lib\\json\\__init__.py', 'PYMODULE'),
+ ('json.decoder', 'C:\\Python312\\Lib\\json\\decoder.py', 'PYMODULE'),
+ ('json.encoder', 'C:\\Python312\\Lib\\json\\encoder.py', 'PYMODULE'),
+ ('json.scanner', 'C:\\Python312\\Lib\\json\\scanner.py', 'PYMODULE'),
+ ('logging', 'C:\\Python312\\Lib\\logging\\__init__.py', 'PYMODULE'),
+ ('lzma', 'C:\\Python312\\Lib\\lzma.py', 'PYMODULE'),
+ ('mimetypes', 'C:\\Python312\\Lib\\mimetypes.py', 'PYMODULE'),
+ ('multiprocessing',
+ 'C:\\Python312\\Lib\\multiprocessing\\__init__.py',
+ 'PYMODULE'),
+ ('multiprocessing.connection',
+ 'C:\\Python312\\Lib\\multiprocessing\\connection.py',
+ 'PYMODULE'),
+ ('multiprocessing.context',
+ 'C:\\Python312\\Lib\\multiprocessing\\context.py',
+ 'PYMODULE'),
+ ('multiprocessing.dummy',
+ 'C:\\Python312\\Lib\\multiprocessing\\dummy\\__init__.py',
+ 'PYMODULE'),
+ ('multiprocessing.dummy.connection',
+ 'C:\\Python312\\Lib\\multiprocessing\\dummy\\connection.py',
+ 'PYMODULE'),
+ ('multiprocessing.forkserver',
+ 'C:\\Python312\\Lib\\multiprocessing\\forkserver.py',
+ 'PYMODULE'),
+ ('multiprocessing.heap',
+ 'C:\\Python312\\Lib\\multiprocessing\\heap.py',
+ 'PYMODULE'),
+ ('multiprocessing.managers',
+ 'C:\\Python312\\Lib\\multiprocessing\\managers.py',
+ 'PYMODULE'),
+ ('multiprocessing.pool',
+ 'C:\\Python312\\Lib\\multiprocessing\\pool.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_fork',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_fork.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_forkserver',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_forkserver.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_spawn_posix',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_spawn_posix.py',
+ 'PYMODULE'),
+ ('multiprocessing.popen_spawn_win32',
+ 'C:\\Python312\\Lib\\multiprocessing\\popen_spawn_win32.py',
+ 'PYMODULE'),
+ ('multiprocessing.process',
+ 'C:\\Python312\\Lib\\multiprocessing\\process.py',
+ 'PYMODULE'),
+ ('multiprocessing.queues',
+ 'C:\\Python312\\Lib\\multiprocessing\\queues.py',
+ 'PYMODULE'),
+ ('multiprocessing.reduction',
+ 'C:\\Python312\\Lib\\multiprocessing\\reduction.py',
+ 'PYMODULE'),
+ ('multiprocessing.resource_sharer',
+ 'C:\\Python312\\Lib\\multiprocessing\\resource_sharer.py',
+ 'PYMODULE'),
+ ('multiprocessing.resource_tracker',
+ 'C:\\Python312\\Lib\\multiprocessing\\resource_tracker.py',
+ 'PYMODULE'),
+ ('multiprocessing.shared_memory',
+ 'C:\\Python312\\Lib\\multiprocessing\\shared_memory.py',
+ 'PYMODULE'),
+ ('multiprocessing.sharedctypes',
+ 'C:\\Python312\\Lib\\multiprocessing\\sharedctypes.py',
+ 'PYMODULE'),
+ ('multiprocessing.spawn',
+ 'C:\\Python312\\Lib\\multiprocessing\\spawn.py',
+ 'PYMODULE'),
+ ('multiprocessing.synchronize',
+ 'C:\\Python312\\Lib\\multiprocessing\\synchronize.py',
+ 'PYMODULE'),
+ ('multiprocessing.util',
+ 'C:\\Python312\\Lib\\multiprocessing\\util.py',
+ 'PYMODULE'),
+ ('netrc', 'C:\\Python312\\Lib\\netrc.py', 'PYMODULE'),
+ ('nturl2path', 'C:\\Python312\\Lib\\nturl2path.py', 'PYMODULE'),
+ ('numbers', 'C:\\Python312\\Lib\\numbers.py', 'PYMODULE'),
+ ('numpy',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.__config__',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\__config__.py',
+ 'PYMODULE'),
+ ('numpy._array_api_info',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_array_api_info.py',
+ 'PYMODULE'),
+ ('numpy._core',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._core._add_newdocs',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_add_newdocs.py',
+ 'PYMODULE'),
+ ('numpy._core._add_newdocs_scalars',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_add_newdocs_scalars.py',
+ 'PYMODULE'),
+ ('numpy._core._asarray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_asarray.py',
+ 'PYMODULE'),
+ ('numpy._core._dtype',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_dtype.py',
+ 'PYMODULE'),
+ ('numpy._core._dtype_ctypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_dtype_ctypes.py',
+ 'PYMODULE'),
+ ('numpy._core._exceptions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_exceptions.py',
+ 'PYMODULE'),
+ ('numpy._core._internal',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_internal.py',
+ 'PYMODULE'),
+ ('numpy._core._methods',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_methods.py',
+ 'PYMODULE'),
+ ('numpy._core._string_helpers',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_string_helpers.py',
+ 'PYMODULE'),
+ ('numpy._core._type_aliases',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_type_aliases.py',
+ 'PYMODULE'),
+ ('numpy._core._ufunc_config',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\_ufunc_config.py',
+ 'PYMODULE'),
+ ('numpy._core.arrayprint',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\arrayprint.py',
+ 'PYMODULE'),
+ ('numpy._core.defchararray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\defchararray.py',
+ 'PYMODULE'),
+ ('numpy._core.einsumfunc',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\einsumfunc.py',
+ 'PYMODULE'),
+ ('numpy._core.fromnumeric',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\fromnumeric.py',
+ 'PYMODULE'),
+ ('numpy._core.function_base',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\function_base.py',
+ 'PYMODULE'),
+ ('numpy._core.getlimits',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\getlimits.py',
+ 'PYMODULE'),
+ ('numpy._core.memmap',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\memmap.py',
+ 'PYMODULE'),
+ ('numpy._core.multiarray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\multiarray.py',
+ 'PYMODULE'),
+ ('numpy._core.numeric',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\numeric.py',
+ 'PYMODULE'),
+ ('numpy._core.numerictypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\numerictypes.py',
+ 'PYMODULE'),
+ ('numpy._core.overrides',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\overrides.py',
+ 'PYMODULE'),
+ ('numpy._core.printoptions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\printoptions.py',
+ 'PYMODULE'),
+ ('numpy._core.records',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\records.py',
+ 'PYMODULE'),
+ ('numpy._core.shape_base',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\shape_base.py',
+ 'PYMODULE'),
+ ('numpy._core.strings',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\strings.py',
+ 'PYMODULE'),
+ ('numpy._core.umath',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_core\\umath.py',
+ 'PYMODULE'),
+ ('numpy._distributor_init',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_distributor_init.py',
+ 'PYMODULE'),
+ ('numpy._expired_attrs_2_0',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_expired_attrs_2_0.py',
+ 'PYMODULE'),
+ ('numpy._globals',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_globals.py',
+ 'PYMODULE'),
+ ('numpy._pytesttester',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_pytesttester.py',
+ 'PYMODULE'),
+ ('numpy._typing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._typing._add_docstring',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_add_docstring.py',
+ 'PYMODULE'),
+ ('numpy._typing._array_like',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_array_like.py',
+ 'PYMODULE'),
+ ('numpy._typing._char_codes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_char_codes.py',
+ 'PYMODULE'),
+ ('numpy._typing._dtype_like',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_dtype_like.py',
+ 'PYMODULE'),
+ ('numpy._typing._nbit',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_nbit.py',
+ 'PYMODULE'),
+ ('numpy._typing._nbit_base',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_nbit_base.py',
+ 'PYMODULE'),
+ ('numpy._typing._nested_sequence',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_nested_sequence.py',
+ 'PYMODULE'),
+ ('numpy._typing._scalars',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_scalars.py',
+ 'PYMODULE'),
+ ('numpy._typing._shape',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_shape.py',
+ 'PYMODULE'),
+ ('numpy._typing._ufunc',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_typing\\_ufunc.py',
+ 'PYMODULE'),
+ ('numpy._utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_utils\\__init__.py',
+ 'PYMODULE'),
+ ('numpy._utils._conversions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_utils\\_conversions.py',
+ 'PYMODULE'),
+ ('numpy._utils._inspect',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\_utils\\_inspect.py',
+ 'PYMODULE'),
+ ('numpy.char',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\char\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.core',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\core\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.core._utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\core\\_utils.py',
+ 'PYMODULE'),
+ ('numpy.core.multiarray',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\core\\multiarray.py',
+ 'PYMODULE'),
+ ('numpy.ctypeslib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ctypeslib\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.ctypeslib._ctypeslib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ctypeslib\\_ctypeslib.py',
+ 'PYMODULE'),
+ ('numpy.dtypes',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\dtypes.py',
+ 'PYMODULE'),
+ ('numpy.exceptions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\exceptions.py',
+ 'PYMODULE'),
+ ('numpy.f2py',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.f2py.__version__',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\__version__.py',
+ 'PYMODULE'),
+ ('numpy.f2py._backends',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_backends\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.f2py._backends._backend',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_backends\\_backend.py',
+ 'PYMODULE'),
+ ('numpy.f2py._backends._meson',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_backends\\_meson.py',
+ 'PYMODULE'),
+ ('numpy.f2py._isocbind',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\_isocbind.py',
+ 'PYMODULE'),
+ ('numpy.f2py.auxfuncs',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\auxfuncs.py',
+ 'PYMODULE'),
+ ('numpy.f2py.capi_maps',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\capi_maps.py',
+ 'PYMODULE'),
+ ('numpy.f2py.cb_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\cb_rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.cfuncs',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\cfuncs.py',
+ 'PYMODULE'),
+ ('numpy.f2py.common_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\common_rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.crackfortran',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\crackfortran.py',
+ 'PYMODULE'),
+ ('numpy.f2py.diagnose',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\diagnose.py',
+ 'PYMODULE'),
+ ('numpy.f2py.f2py2e',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\f2py2e.py',
+ 'PYMODULE'),
+ ('numpy.f2py.f90mod_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\f90mod_rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.func2subr',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\func2subr.py',
+ 'PYMODULE'),
+ ('numpy.f2py.rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\rules.py',
+ 'PYMODULE'),
+ ('numpy.f2py.symbolic',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\symbolic.py',
+ 'PYMODULE'),
+ ('numpy.f2py.use_rules',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\f2py\\use_rules.py',
+ 'PYMODULE'),
+ ('numpy.fft',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.fft._helper',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\_helper.py',
+ 'PYMODULE'),
+ ('numpy.fft._pocketfft',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\fft\\_pocketfft.py',
+ 'PYMODULE'),
+ ('numpy.lib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.lib._array_utils_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_array_utils_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._arraypad_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_arraypad_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._arraysetops_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_arraysetops_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._arrayterator_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_arrayterator_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._datasource',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_datasource.py',
+ 'PYMODULE'),
+ ('numpy.lib._format_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_format_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._function_base_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_function_base_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._histograms_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_histograms_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._index_tricks_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_index_tricks_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._iotools',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_iotools.py',
+ 'PYMODULE'),
+ ('numpy.lib._nanfunctions_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_nanfunctions_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._npyio_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_npyio_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._polynomial_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_polynomial_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._scimath_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_scimath_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._shape_base_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_shape_base_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._stride_tricks_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_stride_tricks_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._twodim_base_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_twodim_base_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._type_check_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_type_check_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._ufunclike_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_ufunclike_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._utils_impl',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_utils_impl.py',
+ 'PYMODULE'),
+ ('numpy.lib._version',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\_version.py',
+ 'PYMODULE'),
+ ('numpy.lib.array_utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\array_utils.py',
+ 'PYMODULE'),
+ ('numpy.lib.format',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\format.py',
+ 'PYMODULE'),
+ ('numpy.lib.introspect',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\introspect.py',
+ 'PYMODULE'),
+ ('numpy.lib.mixins',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\mixins.py',
+ 'PYMODULE'),
+ ('numpy.lib.npyio',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\npyio.py',
+ 'PYMODULE'),
+ ('numpy.lib.recfunctions',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\recfunctions.py',
+ 'PYMODULE'),
+ ('numpy.lib.scimath',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\scimath.py',
+ 'PYMODULE'),
+ ('numpy.lib.stride_tricks',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\lib\\stride_tricks.py',
+ 'PYMODULE'),
+ ('numpy.linalg',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\linalg\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.linalg._linalg',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\linalg\\_linalg.py',
+ 'PYMODULE'),
+ ('numpy.ma',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.ma.core',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\core.py',
+ 'PYMODULE'),
+ ('numpy.ma.extras',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\extras.py',
+ 'PYMODULE'),
+ ('numpy.ma.mrecords',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\ma\\mrecords.py',
+ 'PYMODULE'),
+ ('numpy.matlib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\matlib.py',
+ 'PYMODULE'),
+ ('numpy.matrixlib',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\matrixlib\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.matrixlib.defmatrix',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\matrixlib\\defmatrix.py',
+ 'PYMODULE'),
+ ('numpy.polynomial',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.polynomial._polybase',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\_polybase.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.chebyshev',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\chebyshev.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.hermite',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\hermite.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.hermite_e',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\hermite_e.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.laguerre',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\laguerre.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.legendre',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\legendre.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.polynomial',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\polynomial.py',
+ 'PYMODULE'),
+ ('numpy.polynomial.polyutils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\polynomial\\polyutils.py',
+ 'PYMODULE'),
+ ('numpy.random',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.random._pickle',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\random\\_pickle.py',
+ 'PYMODULE'),
+ ('numpy.rec',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\rec\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.strings',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\strings\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.testing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.testing._private',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\_private\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.testing._private.extbuild',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\_private\\extbuild.py',
+ 'PYMODULE'),
+ ('numpy.testing._private.utils',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\_private\\utils.py',
+ 'PYMODULE'),
+ ('numpy.testing.overrides',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\testing\\overrides.py',
+ 'PYMODULE'),
+ ('numpy.typing',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\typing\\__init__.py',
+ 'PYMODULE'),
+ ('numpy.version',
+ 'C:\\ZendionInc\\agrobot_base\\Python\\OAK\\datasets\\oak-fcc-3\\MaskReviewer_package\\.build_venv\\Lib\\site-packages\\numpy\\version.py',
+ 'PYMODULE'),
+ ('opcode', 'C:\\Python312\\Lib\\opcode.py', 'PYMODULE'),
+ ('pathlib', 'C:\\Python312\\Lib\\pathlib.py', 'PYMODULE'),
+ ('pdb', 'C:\\Python312\\Lib\\pdb.py', 'PYMODULE'),
+ ('pickle', 'C:\\Python312\\Lib\\pickle.py', 'PYMODULE'),
+ ('pkgutil', 'C:\\Python312\\Lib\\pkgutil.py', 'PYMODULE'),
+ ('platform', 'C:\\Python312\\Lib\\platform.py', 'PYMODULE'),
+ ('pprint', 'C:\\Python312\\Lib\\pprint.py', 'PYMODULE'),
+ ('py_compile', 'C:\\Python312\\Lib\\py_compile.py', 'PYMODULE'),
+ ('pydoc', 'C:\\Python312\\Lib\\pydoc.py', 'PYMODULE'),
+ ('pydoc_data', 'C:\\Python312\\Lib\\pydoc_data\\__init__.py', 'PYMODULE'),
+ ('pydoc_data.topics',
+ 'C:\\Python312\\Lib\\pydoc_data\\topics.py',
+ 'PYMODULE'),
+ ('queue', 'C:\\Python312\\Lib\\queue.py', 'PYMODULE'),
+ ('quopri', 'C:\\Python312\\Lib\\quopri.py', 'PYMODULE'),
+ ('random', 'C:\\Python312\\Lib\\random.py', 'PYMODULE'),
+ ('runpy', 'C:\\Python312\\Lib\\runpy.py', 'PYMODULE'),
+ ('secrets', 'C:\\Python312\\Lib\\secrets.py', 'PYMODULE'),
+ ('selectors', 'C:\\Python312\\Lib\\selectors.py', 'PYMODULE'),
+ ('shlex', 'C:\\Python312\\Lib\\shlex.py', 'PYMODULE'),
+ ('shutil', 'C:\\Python312\\Lib\\shutil.py', 'PYMODULE'),
+ ('signal', 'C:\\Python312\\Lib\\signal.py', 'PYMODULE'),
+ ('socket', 'C:\\Python312\\Lib\\socket.py', 'PYMODULE'),
+ ('socketserver', 'C:\\Python312\\Lib\\socketserver.py', 'PYMODULE'),
+ ('ssl', 'C:\\Python312\\Lib\\ssl.py', 'PYMODULE'),
+ ('statistics', 'C:\\Python312\\Lib\\statistics.py', 'PYMODULE'),
+ ('string', 'C:\\Python312\\Lib\\string.py', 'PYMODULE'),
+ ('stringprep', 'C:\\Python312\\Lib\\stringprep.py', 'PYMODULE'),
+ ('subprocess', 'C:\\Python312\\Lib\\subprocess.py', 'PYMODULE'),
+ ('sysconfig', 'C:\\Python312\\Lib\\sysconfig.py', 'PYMODULE'),
+ ('tarfile', 'C:\\Python312\\Lib\\tarfile.py', 'PYMODULE'),
+ ('tempfile', 'C:\\Python312\\Lib\\tempfile.py', 'PYMODULE'),
+ ('textwrap', 'C:\\Python312\\Lib\\textwrap.py', 'PYMODULE'),
+ ('threading', 'C:\\Python312\\Lib\\threading.py', 'PYMODULE'),
+ ('tkinter', 'C:\\Python312\\Lib\\tkinter\\__init__.py', 'PYMODULE'),
+ ('tkinter.commondialog',
+ 'C:\\Python312\\Lib\\tkinter\\commondialog.py',
+ 'PYMODULE'),
+ ('tkinter.constants',
+ 'C:\\Python312\\Lib\\tkinter\\constants.py',
+ 'PYMODULE'),
+ ('tkinter.dialog', 'C:\\Python312\\Lib\\tkinter\\dialog.py', 'PYMODULE'),
+ ('tkinter.filedialog',
+ 'C:\\Python312\\Lib\\tkinter\\filedialog.py',
+ 'PYMODULE'),
+ ('tkinter.messagebox',
+ 'C:\\Python312\\Lib\\tkinter\\messagebox.py',
+ 'PYMODULE'),
+ ('tkinter.simpledialog',
+ 'C:\\Python312\\Lib\\tkinter\\simpledialog.py',
+ 'PYMODULE'),
+ ('token', 'C:\\Python312\\Lib\\token.py', 'PYMODULE'),
+ ('tokenize', 'C:\\Python312\\Lib\\tokenize.py', 'PYMODULE'),
+ ('tracemalloc', 'C:\\Python312\\Lib\\tracemalloc.py', 'PYMODULE'),
+ ('tty', 'C:\\Python312\\Lib\\tty.py', 'PYMODULE'),
+ ('typing', 'C:\\Python312\\Lib\\typing.py', 'PYMODULE'),
+ ('unittest', 'C:\\Python312\\Lib\\unittest\\__init__.py', 'PYMODULE'),
+ ('unittest._log', 'C:\\Python312\\Lib\\unittest\\_log.py', 'PYMODULE'),
+ ('unittest.async_case',
+ 'C:\\Python312\\Lib\\unittest\\async_case.py',
+ 'PYMODULE'),
+ ('unittest.case', 'C:\\Python312\\Lib\\unittest\\case.py', 'PYMODULE'),
+ ('unittest.loader', 'C:\\Python312\\Lib\\unittest\\loader.py', 'PYMODULE'),
+ ('unittest.main', 'C:\\Python312\\Lib\\unittest\\main.py', 'PYMODULE'),
+ ('unittest.result', 'C:\\Python312\\Lib\\unittest\\result.py', 'PYMODULE'),
+ ('unittest.runner', 'C:\\Python312\\Lib\\unittest\\runner.py', 'PYMODULE'),
+ ('unittest.signals', 'C:\\Python312\\Lib\\unittest\\signals.py', 'PYMODULE'),
+ ('unittest.suite', 'C:\\Python312\\Lib\\unittest\\suite.py', 'PYMODULE'),
+ ('unittest.util', 'C:\\Python312\\Lib\\unittest\\util.py', 'PYMODULE'),
+ ('urllib', 'C:\\Python312\\Lib\\urllib\\__init__.py', 'PYMODULE'),
+ ('urllib.error', 'C:\\Python312\\Lib\\urllib\\error.py', 'PYMODULE'),
+ ('urllib.parse', 'C:\\Python312\\Lib\\urllib\\parse.py', 'PYMODULE'),
+ ('urllib.request', 'C:\\Python312\\Lib\\urllib\\request.py', 'PYMODULE'),
+ ('urllib.response', 'C:\\Python312\\Lib\\urllib\\response.py', 'PYMODULE'),
+ ('webbrowser', 'C:\\Python312\\Lib\\webbrowser.py', 'PYMODULE'),
+ ('xml', 'C:\\Python312\\Lib\\xml\\__init__.py', 'PYMODULE'),
+ ('xml.parsers', 'C:\\Python312\\Lib\\xml\\parsers\\__init__.py', 'PYMODULE'),
+ ('xml.parsers.expat',
+ 'C:\\Python312\\Lib\\xml\\parsers\\expat.py',
+ 'PYMODULE'),
+ ('xml.sax', 'C:\\Python312\\Lib\\xml\\sax\\__init__.py', 'PYMODULE'),
+ ('xml.sax._exceptions',
+ 'C:\\Python312\\Lib\\xml\\sax\\_exceptions.py',
+ 'PYMODULE'),
+ ('xml.sax.expatreader',
+ 'C:\\Python312\\Lib\\xml\\sax\\expatreader.py',
+ 'PYMODULE'),
+ ('xml.sax.handler', 'C:\\Python312\\Lib\\xml\\sax\\handler.py', 'PYMODULE'),
+ ('xml.sax.saxutils', 'C:\\Python312\\Lib\\xml\\sax\\saxutils.py', 'PYMODULE'),
+ ('xml.sax.xmlreader',
+ 'C:\\Python312\\Lib\\xml\\sax\\xmlreader.py',
+ 'PYMODULE'),
+ ('xmlrpc', 'C:\\Python312\\Lib\\xmlrpc\\__init__.py', 'PYMODULE'),
+ ('xmlrpc.client', 'C:\\Python312\\Lib\\xmlrpc\\client.py', 'PYMODULE'),
+ ('zipfile', 'C:\\Python312\\Lib\\zipfile\\__init__.py', 'PYMODULE'),
+ ('zipfile._path',
+ 'C:\\Python312\\Lib\\zipfile\\_path\\__init__.py',
+ 'PYMODULE'),
+ ('zipfile._path.glob',
+ 'C:\\Python312\\Lib\\zipfile\\_path\\glob.py',
+ 'PYMODULE'),
+ ('zipimport', 'C:\\Python312\\Lib\\zipimport.py', 'PYMODULE')])
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/base_library.zip b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/base_library.zip
new file mode 100644
index 000000000..791caf2e4
Binary files /dev/null and b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/base_library.zip differ
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/warn-MaskReviewer.txt b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/warn-MaskReviewer.txt
new file mode 100644
index 000000000..8ed17f335
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/warn-MaskReviewer.txt
@@ -0,0 +1,223 @@
+
+This file lists modules PyInstaller was not able to find. This does not
+necessarily mean these modules are required for running your program. Both
+Python's standard library and 3rd-party Python packages often conditionally
+import optional modules, some of which may be available only on certain
+platforms.
+
+Types of import:
+* top-level: imported at the top-level - look at these first
+* conditional: imported within an if-statement
+* delayed: imported within a function
+* optional: imported within a try-except-statement
+
+IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
+ tracking down the missing module yourself. Thanks!
+
+missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), http.server (delayed, optional), netrc (delayed, conditional), getpass (delayed)
+missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional)
+missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
+missing module named fcntl - imported by subprocess (optional)
+missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
+missing module named _scproxy - imported by urllib.request (conditional)
+missing module named termios - imported by tty (top-level), getpass (optional)
+missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
+missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
+missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
+missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
+missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
+missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
+missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional)
+missing module named resource - imported by posix (top-level)
+excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
+missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
+missing module named pyimod02_importers - imported by C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
+missing module named _dummy_thread - imported by numpy._core.arrayprint (optional)
+missing module named typing_extensions - imported by numpy._typing._nested_sequence (conditional), numpy.random.bit_generator (top-level)
+missing module named charset_normalizer - imported by numpy.f2py.crackfortran (optional)
+missing module named vms_lib - imported by platform (delayed, optional)
+missing module named 'java.lang' - imported by platform (delayed, optional)
+missing module named java - imported by platform (delayed)
+missing module named _winreg - imported by platform (delayed, optional)
+missing module named psutil - imported by numpy.testing._private.utils (delayed, optional)
+missing module named readline - imported by cmd (delayed, conditional, optional), code (delayed, conditional, optional), pdb (delayed, optional)
+missing module named win32pdh - imported by numpy.testing._private.utils (delayed, conditional)
+missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
+missing module named _typeshed - imported by numpy.random._common (top-level), numpy.random.bit_generator (top-level)
+missing module named threadpoolctl - imported by numpy.lib._utils_impl (delayed, optional)
+missing module named numpy._core.zeros - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.void - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.vecmat - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.vecdot - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.ushort - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.unsignedinteger - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.ulonglong - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.ulong - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.uintp - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.uintc - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.uint64 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.uint32 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.uint16 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.uint - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.ubyte - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.trunc - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.true_divide - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.transpose - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._function_base_impl (top-level), numpy (conditional)
+missing module named numpy._core.trace - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.timedelta64 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.tensordot - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.tanh - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.tan - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.swapaxes - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.sum - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.subtract - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.str_ - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.square - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.sqrt - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.fft._pocketfft (top-level)
+missing module named numpy._core.spacing - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.sort - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.sinh - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.single - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.signedinteger - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.signbit - imported by numpy._core (conditional), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.sign - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.short - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.rint - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.right_shift - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.remainder - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.reciprocal - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.fft._pocketfft (top-level)
+missing module named numpy._core.radians - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.rad2deg - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.prod - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.power - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.positive - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.pi - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.outer - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.ones - imported by numpy._core (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
+missing module named numpy._core.object_ - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.number - imported by numpy._core (conditional), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.not_equal - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.nextafter - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.newaxis - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.negative - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.ndarray - imported by numpy._core (top-level), numpy.lib._utils_impl (top-level), numpy (conditional), numpy.testing._private.utils (top-level)
+missing module named numpy._core.multiply - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.moveaxis - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.modf - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.mod - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.minimum - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.maximum - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.matvec - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.matrix_transpose - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.matmul - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.longlong - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.longdouble - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.long - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.logical_xor - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.logical_or - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.logical_not - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.logical_and - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.logaddexp2 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.logaddexp - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.log10 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.log2 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.log1p - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.log - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.linspace - imported by numpy._core (top-level), numpy.lib._index_tricks_impl (top-level), numpy (conditional)
+missing module named numpy._core.less_equal - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.less - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.left_shift - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.ldexp - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.lcm - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.isscalar - imported by numpy._core (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.isnan - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.isfinite - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.intp - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy._array_api_info (top-level), numpy.testing._private.utils (top-level)
+missing module named numpy._core.integer - imported by numpy._core (conditional), numpy (conditional), numpy.fft._helper (top-level)
+missing module named numpy._core.intc - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.int64 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.int32 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.int16 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.int8 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.inf - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.inexact - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.iinfo - imported by numpy._core (top-level), numpy.lib._twodim_base_impl (top-level), numpy (conditional)
+missing module named numpy._core.hypot - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.hstack - imported by numpy._core (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
+missing module named numpy._core.heaviside - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.half - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.greater_equal - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.greater - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.gcd - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.frompyfunc - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.frexp - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.fmod - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.fmin - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.fmax - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.floor_divide - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.floor - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.floating - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.float_power - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.float32 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level), numpy.testing._private.utils (top-level)
+missing module named numpy._core.float16 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.finfo - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
+missing module named numpy._core.fabs - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.expm1 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.exp2 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.exp - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.euler_gamma - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.errstate - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.equal - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.empty_like - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.fft._pocketfft (top-level)
+missing module named numpy._core.empty - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.fft._helper (top-level), numpy.testing._private.utils (top-level)
+missing module named numpy._core.e - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.double - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.dot - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
+missing module named numpy._core.divmod - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.divide - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.diagonal - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.degrees - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.deg2rad - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.datetime64 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.csingle - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.cross - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.count_nonzero - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.cosh - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.cos - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.copysign - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.conjugate - imported by numpy._core (conditional), numpy (conditional), numpy.fft._pocketfft (top-level)
+missing module named numpy._core.conj - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.complexfloating - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.complex64 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
+missing module named numpy._core.clongdouble - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.character - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.ceil - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.cdouble - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.cbrt - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.bytes_ - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.byte - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.bool_ - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.bitwise_xor - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.bitwise_or - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.bitwise_count - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.bitwise_and - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.atleast_3d - imported by numpy._core (top-level), numpy.lib._shape_base_impl (top-level), numpy (conditional)
+missing module named numpy._core.atleast_2d - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.atleast_1d - imported by numpy._core (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
+missing module named numpy._core.asarray - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._array_utils_impl (top-level), numpy (conditional), numpy.fft._helper (top-level), numpy.fft._pocketfft (top-level)
+missing module named numpy._core.asanyarray - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.array - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional), numpy.testing._private.utils (top-level)
+missing module named numpy._core.argsort - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.arctanh - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.arctan2 - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.arctan - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.arcsinh - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.arcsin - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.arccosh - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.arccos - imported by numpy._core (conditional), numpy (conditional)
+missing module named numpy._core.amin - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.amax - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named numpy._core.all - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.testing._private.utils (delayed)
+missing module named numpy._core.add - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
+missing module named yaml - imported by numpy.__config__ (delayed)
+missing module named numpy._distributor_init_local - imported by numpy (optional), numpy._distributor_init (optional)
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/xref-MaskReviewer.html b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/xref-MaskReviewer.html
new file mode 100644
index 000000000..b7507c528
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build/MaskReviewer/xref-MaskReviewer.html
@@ -0,0 +1,18920 @@
+
+
+
+
+ modulegraph cross reference for MaskReviewer.py, pyi_rth__tkinter.py, pyi_rth_inspect.py, pyi_rth_multiprocessing.py, pyi_rth_pkgutil.py
+
+
+
+ modulegraph cross reference for MaskReviewer.py, pyi_rth__tkinter.py, pyi_rth_inspect.py, pyi_rth_multiprocessing.py, pyi_rth_pkgutil.py
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
_abc (builtin module)
+
+
+
+
+
+
+
+
_ast (builtin module)
+
+
+
+
+
+
_asyncio C:\Python312\DLLs\_asyncio.pyd
+
+
+
+
+
+
_bisect (builtin module)
+
+
+
+
+
+
_blake2 (builtin module)
+
+
+
+
+
+
_bz2 C:\Python312\DLLs\_bz2.pyd
+
+
+
+
+
+
_codecs (builtin module)
+
+
+
+
+
+
_codecs_cn (builtin module)
+
+
+
+
+
+
_codecs_hk (builtin module)
+
+
+
+
+
+
_codecs_iso2022 (builtin module)
+
+
+
+
+
+
_codecs_jp (builtin module)
+
+
+
+
+
+
_codecs_kr (builtin module)
+
+
+
+
+
+
_codecs_tw (builtin module)
+
+
+
+
+
+
_collections (builtin module)
+
+
+
+
+
+
+
+
+
+
+
+
_contextvars (builtin module)
+
+
+
+
+
+
_csv (builtin module)
+
+
+
+
+
+
_ctypes C:\Python312\DLLs\_ctypes.pyd
+
+
+
+
+
+
_datetime (builtin module)
+
+
+
+
+
+
+
_decimal C:\Python312\DLLs\_decimal.pyd
+
+
+
+
+
+
+
+
+
+
+
+
_functools (builtin module)
+
+
+
+
+
+
_hashlib C:\Python312\DLLs\_hashlib.pyd
+
+
+
+
+
+
_heapq (builtin module)
+imported by:
+
heapq
+
+
+
+
+
+
+
+
_imp (builtin module)
+
+
+
+
+
+
_io (builtin module)
+
+
+
+
+
+
_json (builtin module)
+
+
+
+
+
+
+
_locale (builtin module)
+
+
+
+
+
+
_lzma C:\Python312\DLLs\_lzma.pyd
+
+
+
+
+
+
_md5 (builtin module)
+
+
+
+
+
+
_multibytecodec (builtin module)
+
+
+
+
+
+
_multiprocessing C:\Python312\DLLs\_multiprocessing.pyd
+
+
+
+
+
+
_opcode (builtin module)
+
+
+
+
+
+
_operator (builtin module)
+
+
+
+
+
+
_overlapped C:\Python312\DLLs\_overlapped.pyd
+
+
+
+
+
+
_pickle (builtin module)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
_queue C:\Python312\DLLs\_queue.pyd
+imported by:
+
queue
+
+
+
+
+
+
+
+
_random (builtin module)
+
+
+
+
+
+
+
+
_sha1 (builtin module)
+
+
+
+
+
+
_sha2 (builtin module)
+
+
+
+
+
+
_sha3 (builtin module)
+
+
+
+
+
+
_signal (builtin module)
+
+
+
+
+
+
_socket C:\Python312\DLLs\_socket.pyd
+
+
+
+
+
+
_sre (builtin module)
+
+
+
+
+
+
+
_ssl C:\Python312\DLLs\_ssl.pyd
+
+
+
+
+
+
+
_stat (builtin module)
+
+
+
+
+
+
_statistics (builtin module)
+
+
+
+
+
+
_string (builtin module)
+
+
+
+
+
+
+
+
_struct (builtin module)
+
+
+
+
+
+
_thread (builtin module)
+
+
+
+
+
+
+
+
_tkinter C:\Python312\DLLs\_tkinter.pyd
+
+
+
+
+
+
_tokenize (builtin module)
+
+
+
+
+
+
_tracemalloc (builtin module)
+
+
+
+
+
+
+
+
_typing (builtin module)
+
+
+
+
+
+
_warnings (builtin module)
+
+
+
+
+
+
_weakref (builtin module)
+
+
+
+
+
+
+
+
_winapi (builtin module)
+
+
+
+
+
+
+
+
_wmi C:\Python312\DLLs\_wmi.pyd
+
+
+
+
+
+
abc
+
SourceModule
+
+
+
+
+
+
+
+
+
array (builtin module)
+
+
+
+
+
+
ast
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
atexit (builtin module)
+
+
+
+
+
+
base64
+
SourceModule
+
+
+
+
+
+
+
bdb
+
SourceModule
+
+
+
+
+
+
+
binascii (builtin module)
+
+
+
+
+
+
bisect
+
SourceModule
+
+
+
+
+
+
+
builtins (builtin module)
+
+
+
+
+
+
bz2
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
cmd
+
SourceModule
+
+
+
+
+
+
+
code
+
SourceModule
+
+
+
+
+
+
+
codecs
+
SourceModule
+
+
+
+
+
+
+
codeop
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
copy
+
SourceModule
+
+
+
+
+
+
+
+
+
csv
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
cv2.cv2 C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\cv2\cv2.pyd
+
+
+
+
+
+
+
+
+
cv2.dnn
+
NamespacePackage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
dis
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
enum
+
SourceModule
+
+
+
+
+
+
+
errno (builtin module)
+
+
+
+
+
+
fcntl
+
MissingModule
+
+
+
+
+
+
+
+
+
+
+
+
ftplib
+
SourceModule
+
+
+
+
+
+
+
+
+
gc (builtin module)
+
+
+
+
+
+
+
+
+
getopt
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
glob
+
SourceModule
+
+
+
+
+
+
+
grp
+
MissingModule
+
+
+
+
+
+
gzip
+
SourceModule
+
+
+
+
+
+
+
+
+
heapq
+
SourceModule
+
+
+
+
+
+
+
hmac
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
io
+
SourceModule
+
+
+
+
+
+
+
+
+
itertools (builtin module)
+
+
+
+
+
+
java
+
MissingModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
locale
+
SourceModule
+
+
+
+
+
+
+
+
+
lzma
+
SourceModule
+
+
+
+
+
+
+
marshal (builtin module)
+
+
+
+
+
+
math (builtin module)
+
+
+
+
+
+
+
+
mmap (builtin module)
+
+
+
+
+
+
msvcrt (builtin module)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
netrc
+
SourceModule
+
+
+
+
+
+
+
nt (builtin module)
+
+
+
+
+
+
ntpath
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
numpy._core._multiarray_tests C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\_core\_multiarray_tests.cp312-win_amd64.pyd
+
+imported by:
+
numpy
+
+
+
+
+
+
+
+
numpy._core._multiarray_umath C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\_core\_multiarray_umath.cp312-win_amd64.pyd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
numpy.fft._pocketfft_umath C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\fft\_pocketfft_umath.cp312-win_amd64.pyd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
numpy.linalg._umath_linalg C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\linalg\_umath_linalg.cp312-win_amd64.pyd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
numpy.random._bounded_integers C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\_bounded_integers.cp312-win_amd64.pyd
+
+
+
+
+
+
+
numpy.random._common C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\_common.cp312-win_amd64.pyd
+
+
+
+
+
+
+
numpy.random._generator C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\_generator.cp312-win_amd64.pyd
+
+
+
+
+
+
+
numpy.random._mt19937 C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\_mt19937.cp312-win_amd64.pyd
+
+
+
+
+
+
+
numpy.random._pcg64 C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\_pcg64.cp312-win_amd64.pyd
+
+
+
+
+
+
+
numpy.random._philox C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\_philox.cp312-win_amd64.pyd
+
+
+
+
+
+
+
+
+
numpy.random._sfc64 C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\_sfc64.cp312-win_amd64.pyd
+
+
+
+
+
+
+
numpy.random.bit_generator C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\bit_generator.cp312-win_amd64.pyd
+
+
+
+
+
+
+
numpy.random.mtrand C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\MaskReviewer_package\.build_venv\Lib\site-packages\numpy\random\mtrand.cp312-win_amd64.pyd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
opcode
+
SourceModule
+
+
+
+
+
+
+
+
+
os
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
pdb
+
SourceModule
+
+
+
+
+
+
+
pickle
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
posix
+
MissingModule
+
+
+
+
+
+
+
+
+
pprint
+
SourceModule
+
+
+
+
+
+
+
+
+
pwd
+
MissingModule
+
+
+
+
+
+
+
+
pydoc
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
pyexpat C:\Python312\DLLs\pyexpat.pyd
+
+
+
+
+
+
+
+
queue
+
SourceModule
+
+
+
+
+
+
+
quopri
+
SourceModule
+
+
+
+
+
+
+
random
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
runpy
+
SourceModule
+
+
+
+
+
+
+
+
+
select C:\Python312\DLLs\select.pyd
+
+
+
+
+
+
+
+
shlex
+
SourceModule
+
+
+
+
+
+
+
shutil
+
SourceModule
+
+
+
+
+
+
+
signal
+
SourceModule
+
+
+
+
+
+
+
socket
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ssl
+
SourceModule
+
+
+
+
+
+
+
stat
+
SourceModule
+
+
+
+
+
+
+
+
+
string
+
SourceModule
+
+
+
+
+
+
+
+
+
struct
+
SourceModule
+
+
+
+
+
+
+
+
+
sys (builtin module)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
time (builtin module)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
token
+
SourceModule
+
+
+
+
+
+
+
+
+
+
+
+
tty
+
SourceModule
+
+imported by:
+
pydoc
+
+
+
+
+
+
+
+
types
+
SourceModule
+
+
+
+
+
+
+
typing
+
SourceModule
+
+
+
+
+
+
+
+
+
unicodedata C:\Python312\DLLs\unicodedata.pyd
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
winreg (builtin module)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
yaml
+
MissingModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
zlib (builtin module)
+
+
+
+
+
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build_windows.bat b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build_windows.bat
new file mode 100644
index 000000000..69112babf
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/build_windows.bat
@@ -0,0 +1,81 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+
+echo ============================================================
+echo BUILD MASKREVIEWER WINDOWS
+echo ============================================================
+echo.
+
+where py >nul 2>nul
+if %errorlevel%==0 (
+ set PY=py
+) else (
+ set PY=python
+)
+
+%PY% --version
+if errorlevel 1 (
+ echo.
+ echo [ERRO] Python nao encontrado.
+ echo Instale Python 3.11 ou 3.12 para Windows e tente novamente.
+ pause
+ exit /b 1
+)
+
+if not exist ".build_venv\Scripts\python.exe" (
+ echo [1/4] Criando ambiente de build...
+ %PY% -m venv .build_venv
+ if errorlevel 1 goto :erro
+)
+
+call ".build_venv\Scripts\activate.bat"
+
+echo [2/4] Instalando dependencias de build...
+python -m pip install --upgrade pip
+python -m pip install pyinstaller numpy opencv-python
+if errorlevel 1 goto :erro
+
+echo [3/4] Limpando builds antigos...
+if exist build rmdir /s /q build
+if exist dist rmdir /s /q dist
+if exist MaskReviewer.spec del /q MaskReviewer.spec
+
+echo [4/4] Gerando EXE...
+python -m PyInstaller ^
+ --noconfirm ^
+ --clean ^
+ --onedir ^
+ --windowed ^
+ --name MaskReviewer ^
+ --collect-all cv2 ^
+ MaskReviewer.py
+
+if errorlevel 1 goto :erro
+
+echo.
+echo ============================================================
+echo BUILD CONCLUIDO
+echo ============================================================
+echo Pasta gerada:
+echo %CD%\dist\MaskReviewer
+echo.
+echo Coloque ao lado do MaskReviewer.exe:
+echo labelmap.txt
+echo group\
+echo.
+echo Estrutura final recomendada:
+echo MaskReviewer\
+echo MaskReviewer.exe
+echo _internal\
+echo labelmap.txt
+echo group\
+echo.
+pause
+exit /b 0
+
+:erro
+echo.
+echo [ERRO] Falha no build.
+pause
+exit /b 1
diff --git a/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/executar_sem_exe.bat b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/executar_sem_exe.bat
new file mode 100644
index 000000000..1c2f6fc31
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/MaskReviewer_package/executar_sem_exe.bat
@@ -0,0 +1,19 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+
+where py >nul 2>nul
+if %errorlevel%==0 (
+ set PY=py
+) else (
+ set PY=python
+)
+
+%PY% -c "import cv2, numpy" >nul 2>nul
+if errorlevel 1 (
+ echo Instalando numpy e opencv-python...
+ %PY% -m pip install numpy opencv-python
+)
+
+%PY% MaskReviewer.py
+if errorlevel 1 pause
diff --git a/Python/OAK/datasets/oak-fcc-3/_10_export_onnx.py b/Python/OAK/datasets/oak-fcc-3/_10_export_onnx.py
index d668adce8..f0de57b22 100644
--- a/Python/OAK/datasets/oak-fcc-3/_10_export_onnx.py
+++ b/Python/OAK/datasets/oak-fcc-3/_10_export_onnx.py
@@ -8,7 +8,8 @@ Exporta o checkpoint PyTorch do SegFormer Multi-Head OAK-FCC-3 para ONNX.
Exemplo:
-python _10_export_onnx.py --config config.json --checkpoint backup/segformer_b1/2026_08_17/stacked_raw5_ndvi_ndre/best_score.pt --out backup/segformer_b1/2026_08_17/stacked_raw5_ndvi_ndre/best_score.onnx --include-norm --postprocess argmax_fullres
+python .\_10_export_onnx.py --config config.json --checkpoint backup/segformer_b1/2026_08_17/stacked_raw5_ndvi_ndre/best_score.pt --out backup/segformer_b1/2026_08_17/stacked_raw5_ndvi_ndre/best_score.onnx --include-norm --postprocess argmax_fullres
+python .\_10_export_onnx.py --config .\config.json --train-script .\_8_train_multihead_v2.py --opset 17 --device cuda --include-norm --postprocess argmax_fullres
"""
from __future__ import annotations
diff --git a/Python/OAK/datasets/oak-fcc-3/_11_validate_onnx.py b/Python/OAK/datasets/oak-fcc-3/_11_validate_onnx.py
index e376f6ae8..4ca57cd2d 100644
--- a/Python/OAK/datasets/oak-fcc-3/_11_validate_onnx.py
+++ b/Python/OAK/datasets/oak-fcc-3/_11_validate_onnx.py
@@ -13,6 +13,7 @@ para o SegFormer OAK-FCC-3 Multi-Head.
Exemplo:
python _11_validate_onnx.py --config config.json --max_samples 20 --device cuda --onnx_provider cuda --torch_no_amp
+python .\_11_validate_onnx.py --config .\config.json --train-script .\_8_train_multihead_v2.py --max_samples 50 --device cuda --onnx_provider cuda --torch_no_amp
Para validar o ONNX com saída já redimensionada:
diff --git a/Python/OAK/datasets/oak-fcc-3/_12_benchmark_onnx_v2.py b/Python/OAK/datasets/oak-fcc-3/_12_benchmark_onnx_v2.py
new file mode 100644
index 000000000..dabe02c15
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/_12_benchmark_onnx_v2.py
@@ -0,0 +1,1191 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+_12_benchmark_onnx.py
+
+Benchmark PyTorch vs ONNX Runtime para SegFormer OAK-FCC-3 Multi-Head.
+
+Mede:
+ - PyTorch FP32
+ - PyTorch AMP/FP16
+ - ONNX Runtime CUDA ou CPU
+
+Exemplos:
+
+Benchmark ONNX cru 160x256:
+
+python _12_benchmark_onnx_v2.py --config config.json --train-script .\_8_train_multihead_v2.py --max_samples 50 --warmup 10 --repeat 5 --device cuda --onnx_provider cuda
+
+Benchmark ONNX resized 640x1024:
+
+python _12_benchmark_onnx_v2.py --config config.json --train-script .\_8_train_multihead_v2.py --max_samples 50 --warmup 10 --repeat 5 --device cuda --onnx_provider cuda
+
+
+TensorRT
+python .\_12_benchmark_onnx_v2.py --config .\config.json --train-script .\_8_train_multihead_v2.py --max_samples 50 --warmup 10 --repeat 5 --device cuda --onnx_provider tensorrt --skip_torch_fp32 --skip_torch_amp
+
+"""
+
+from __future__ import annotations
+
+import gc
+import csv
+import json
+import time
+import argparse
+import importlib.util
+import inspect
+from pathlib import Path
+from typing import Optional, List, Dict, Sequence, Tuple
+
+import cv2
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+
+SOURCE_CHANNEL_ORDER = ["R", "G", "B", "RE", "NIR"]
+DERIVED_CHANNEL_ORDER = ["NDVI", "NDRE"]
+SUPPORTED_INPUT_CHANNELS = SOURCE_CHANNEL_ORDER + DERIVED_CHANNEL_ORDER
+
+
+# ============================================================
+# Utils
+# ============================================================
+
+def load_json(path: str | Path) -> dict:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def save_json(path: str | Path, data: dict):
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+
+def save_csv(path: str | Path, rows: List[dict]):
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ if not rows:
+ return
+
+ keys = list(rows[0].keys())
+
+ with path.open("w", newline="", encoding="utf-8") as f:
+ w = csv.DictWriter(f, fieldnames=keys)
+ w.writeheader()
+ w.writerows(rows)
+
+
+def resolve_path(path_like: Optional[str], base: Optional[Path] = None) -> Optional[Path]:
+ if path_like is None:
+ return None
+
+ p = Path(path_like)
+ if p.is_absolute():
+ return p
+
+ if base is None:
+ base = Path.cwd()
+
+ return (base / p).resolve()
+
+
+def import_train_module(train_script_path: str | Path):
+ train_script_path = Path(train_script_path)
+
+ if not train_script_path.exists():
+ raise FileNotFoundError(f"Script de treino não encontrado: {train_script_path}")
+
+ spec = importlib.util.spec_from_file_location(
+ "train_multihead_module",
+ str(train_script_path.resolve())
+ )
+
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Não consegui importar o script: {train_script_path}")
+
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def synchronize_if_cuda(device: torch.device):
+ if device.type == "cuda":
+ torch.cuda.synchronize()
+
+
+def clear_cuda():
+ gc.collect()
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
+ torch.cuda.synchronize()
+
+
+def percentile(values: List[float], p: float) -> float:
+ if not values:
+ return 0.0
+ return float(np.percentile(np.asarray(values, dtype=np.float64), p))
+
+
+def summarize_times(times_ms: List[float]) -> dict:
+ arr = np.asarray(times_ms, dtype=np.float64)
+
+ if arr.size == 0:
+ return {
+ "n": 0,
+ "mean_ms": 0.0,
+ "median_ms": 0.0,
+ "min_ms": 0.0,
+ "max_ms": 0.0,
+ "p95_ms": 0.0,
+ "p99_ms": 0.0,
+ "fps_mean": 0.0,
+ "fps_p95_latency": 0.0,
+ }
+
+ mean_ms = float(arr.mean())
+ p95_ms = float(np.percentile(arr, 95))
+ p99_ms = float(np.percentile(arr, 99))
+
+ return {
+ "n": int(arr.size),
+ "mean_ms": mean_ms,
+ "median_ms": float(np.median(arr)),
+ "min_ms": float(arr.min()),
+ "max_ms": float(arr.max()),
+ "p95_ms": p95_ms,
+ "p99_ms": p99_ms,
+ "fps_mean": float(1000.0 / mean_ms) if mean_ms > 0 else 0.0,
+ "fps_p95_latency": float(1000.0 / p95_ms) if p95_ms > 0 else 0.0,
+ }
+
+
+def experiment_tag(config: dict, channels: int) -> str:
+ explicit = str(config.get("stats_source_tag", "")).strip()
+ if explicit:
+ return explicit
+ return f"{config.get('fusion_mode', 'stacked')}_raw{channels}"
+
+
+def load_export_metadata(onnx_path: Path) -> Tuple[dict, Optional[Path]]:
+ meta_path = onnx_path.with_suffix(".export_meta.json")
+ if meta_path.is_file():
+ return load_json(meta_path), meta_path
+ return {}, None
+
+
+def resolve_model_artifact_paths(
+ args,
+ config: dict,
+ config_dir: Path,
+ channels: int,
+ require_onnx: bool = True,
+) -> Tuple[Path, Path, str]:
+ """
+ Resolve checkpoint e ONNX.
+
+ Se --checkpoint ou --onnx forem informados, usa os caminhos informados.
+ Se ficarem vazios, monta a partir do config:
+
+ backup/{modelo}/{model_name}/{stats_source_tag}/{ckpt_name}.pt
+ backup/{modelo}/{model_name}/{stats_source_tag}/{ckpt_name}.onnx
+
+ ckpt_name vem de:
+ config["ckpt_test"] ou "best_score"
+ """
+ model = config.get("modelo", "segformer_b1")
+ model_name = config.get("model_name", "target_teached")
+ ckpt_name = config.get("ckpt_test", "best_score")
+ base_dir = config_dir / "backup" / model / model_name / experiment_tag(config, channels)
+
+ ckpt_filename = str(ckpt_name)
+ if not ckpt_filename.lower().endswith(".pt"):
+ ckpt_filename += ".pt"
+ onnx_filename = f"{Path(str(ckpt_name)).stem}.onnx"
+
+ if args.checkpoint:
+ checkpoint_path = resolve_path(args.checkpoint, config_dir)
+ else:
+ checkpoint_path = base_dir / ckpt_filename
+
+ if args.onnx:
+ onnx_path = resolve_path(args.onnx, config_dir)
+ else:
+ onnx_path = base_dir / onnx_filename
+
+ if checkpoint_path is None or not checkpoint_path.is_file():
+ raise FileNotFoundError(
+ f"Checkpoint não encontrado: {checkpoint_path}\n"
+ f"Dica: informe --checkpoint ou ajuste config['ckpt_test']."
+ )
+
+ if require_onnx and (onnx_path is None or not onnx_path.is_file()):
+ raise FileNotFoundError(
+ f"ONNX não encontrado: {onnx_path}\n"
+ f"Dica: exporte o checkpoint, informe --onnx, ou use --skip_onnx para benchmark só PyTorch."
+ )
+
+ # Mesmo em --skip_onnx retornamos o caminho esperado, ainda que o arquivo não exista.
+ # Isso mantém nomes de relatório e diretório do experimento previsíveis.
+ return checkpoint_path.resolve(), onnx_path.resolve(), str(ckpt_name)
+
+
+# ============================================================
+# Dataset / normalização
+# ============================================================
+
+def collect_tensor_samples(root: Path, max_samples: int = 50, start_idx: int = 0) -> List[Path]:
+ tensor_paths: List[Path] = []
+
+ direct = root / "tensors"
+ if direct.is_dir():
+ tensor_paths.extend(sorted(direct.glob("*.npy")))
+
+ group_root = root / "group"
+ if group_root.is_dir():
+ for gdir in sorted(group_root.iterdir()):
+ tdir = gdir / "tensors"
+ if tdir.is_dir():
+ tensor_paths.extend(sorted(tdir.glob("*.npy")))
+
+ if not tensor_paths:
+ tensor_paths.extend(sorted(root.glob("**/tensors/*.npy")))
+
+ if not tensor_paths:
+ raise RuntimeError(f"Nenhum tensor .npy encontrado em: {root}")
+
+ start_idx = max(0, int(start_idx))
+ selected = tensor_paths[start_idx:]
+
+ if max_samples > 0:
+ selected = selected[:int(max_samples)]
+
+ return selected
+
+
+def load_tensor(
+ path: Path,
+ requested_channels: Sequence[str],
+ saved_channel_names: Optional[Sequence[str]] = None,
+) -> np.ndarray:
+ arr = np.load(str(path)).astype(np.float32)
+
+ if arr.ndim != 3:
+ raise RuntimeError(f"Tensor inválido {path}: shape={arr.shape}, esperado 3D")
+
+ if 1 <= arr.shape[0] <= len(SUPPORTED_INPUT_CHANNELS) and arr.shape[1] > 8 and arr.shape[2] > 8:
+ chw = arr
+ elif 1 <= arr.shape[-1] <= len(SUPPORTED_INPUT_CHANNELS) and arr.shape[0] > 8 and arr.shape[1] > 8:
+ chw = np.transpose(arr, (2, 0, 1))
+ else:
+ raise RuntimeError(f"Tensor com layout inesperado: {path} shape={arr.shape}")
+
+ requested = [str(x).strip().upper() for x in requested_channels]
+ saved = [str(x).strip().upper() for x in (saved_channel_names or [])]
+
+ if saved:
+ if len(saved) != chw.shape[0] or len(set(saved)) != len(saved):
+ raise RuntimeError(
+ f"Meta/tensor incompatíveis em {path}: channels={saved}, shape={chw.shape}"
+ )
+ missing = [name for name in requested if name not in saved]
+ if missing:
+ raise RuntimeError(f"Tensor {path} não contém {missing}. Disponíveis={saved}")
+ indices = [saved.index(name) for name in requested]
+ elif chw.shape[0] == len(requested):
+ indices = list(range(len(requested)))
+ print(f"[WARN] {path.name}: sem meta.channels; assumindo ordem {requested}")
+ elif chw.shape[0] == len(SOURCE_CHANNEL_ORDER) and all(x in SOURCE_CHANNEL_ORDER for x in requested):
+ indices = [SOURCE_CHANNEL_ORDER.index(name) for name in requested]
+ print(f"[WARN] {path.name}: Raw5 legado; usando ordem {SOURCE_CHANNEL_ORDER}")
+ else:
+ raise RuntimeError(
+ f"Não é seguro inferir canais de {path}: shape={chw.shape}, pedidos={requested}"
+ )
+
+ chw = chw[indices, :, :]
+
+ finite = np.isfinite(chw)
+ if finite.any():
+ mx = float(np.nanmax(chw[finite]))
+ if mx > 2.0 and mx <= 255.0:
+ chw = chw / 255.0
+ elif mx > 255.0:
+ chw = chw / 65535.0
+
+ chw = np.nan_to_num(chw, nan=0.0, posinf=1.0, neginf=-1.0)
+ return np.ascontiguousarray(chw, dtype=np.float32)
+
+
+def load_tensor_channel_names(tensor_path: Path) -> List[str]:
+ meta_path = tensor_path.parent.parent / "metas" / f"{tensor_path.stem}.json"
+ if not meta_path.is_file():
+ return []
+ meta = load_json(meta_path)
+ return list(meta.get("channels") or meta.get("input_channels") or [])
+
+
+def find_norm_stats(config: dict, config_dir: Path, save_dir: Path, explicit: Optional[str]) -> Optional[Path]:
+ if explicit:
+ return resolve_path(explicit, config_dir)
+
+ W, H = config.get("resolucao", [1024, 640])
+ dataset_path = config_dir / "dataset"
+
+ candidates = [
+ save_dir / "norm_stats.json",
+ dataset_path / f"{int(W)}x{int(H)}" / "group" / "norm_stats.json",
+ config_dir / "backup" / config.get("modelo", "segformer_b1") / config.get("model_name", "test") / config.get("stats_source_tag", "stacked_raw5") / "norm_stats.json",
+ ]
+
+ for p in candidates:
+ if p.is_file():
+ return p
+
+ return candidates[0]
+
+
+def load_norm_stats(
+ path: Optional[Path],
+ channel_names: Sequence[str],
+) -> Tuple[Optional[List[float]], Optional[List[float]], Optional[str]]:
+ if path is None or not path.is_file():
+ raise FileNotFoundError(
+ f"norm_stats não encontrado: {path}. Benchmark exige os stats do treinamento."
+ )
+
+ js = load_json(path)
+ mean = js.get("mean")
+ std = js.get("std")
+ names = [str(x).strip().upper() for x in js.get("channels", [])]
+ requested = [str(x).strip().upper() for x in channel_names]
+
+ if mean is None or std is None:
+ raise RuntimeError(f"norm_stats inválido, faltando mean/std: {path}")
+
+ if len(mean) != len(std):
+ raise RuntimeError(f"norm_stats inválido: mean={len(mean)} std={len(std)}")
+
+ if names:
+ if len(names) != len(mean) or len(set(names)) != len(names):
+ raise RuntimeError(f"norm_stats channels inválido: {names}")
+ missing = [name for name in requested if name not in names]
+ if missing:
+ raise RuntimeError(
+ f"norm_stats não contém {missing}. Disponíveis={names}, pedidos={requested}"
+ )
+ indices = [names.index(name) for name in requested]
+ elif len(mean) == len(requested):
+ indices = list(range(len(requested)))
+ print(f"[NORM][WARN] stats sem nomes; assumindo ordem {requested}")
+ elif len(mean) == len(SOURCE_CHANNEL_ORDER) and all(x in SOURCE_CHANNEL_ORDER for x in requested):
+ indices = [SOURCE_CHANNEL_ORDER.index(name) for name in requested]
+ print(f"[NORM][WARN] stats Raw5 legados; usando ordem {SOURCE_CHANNEL_ORDER}")
+ else:
+ raise RuntimeError(
+ f"Não é seguro mapear norm_stats sem nomes: mean={len(mean)}, pedidos={requested}"
+ )
+
+ mean_sel = [float(mean[i]) for i in indices]
+ std_sel = [float(std[i]) for i in indices]
+ if not np.all(np.isfinite(mean_sel)) or not np.all(np.isfinite(std_sel)):
+ raise RuntimeError(f"norm_stats possui NaN/Inf: mean={mean_sel} std={std_sel}")
+ if any(x <= 0.0 for x in std_sel):
+ raise RuntimeError(f"norm_stats possui std inválido: {std_sel}")
+
+ print(f"[NORM] usando {path}")
+ print(f"[NORM] channels={requested}")
+ print(f"[NORM] mean={mean_sel}")
+ print(f"[NORM] std ={std_sel}")
+
+ return mean_sel, std_sel, str(path)
+
+
+def normalize_numpy_chw(chw: np.ndarray, mean: Optional[List[float]], std: Optional[List[float]]) -> np.ndarray:
+ if mean is None or std is None:
+ return chw.astype(np.float32)
+
+ mean_np = np.asarray(mean, dtype=np.float32).reshape(-1, 1, 1)
+ std_np = np.asarray(std, dtype=np.float32).reshape(-1, 1, 1)
+ std_np = np.clip(std_np, 1e-6, None)
+
+ return ((chw.astype(np.float32) - mean_np) / std_np).astype(np.float32)
+
+
+def load_inputs_as_numpy(
+ samples: List[Path],
+ input_channel_names: Sequence[str],
+ mean: Optional[List[float]],
+ std: Optional[List[float]],
+ target_hw: Tuple[int, int],
+ normalize_input: bool = True,
+) -> List[np.ndarray]:
+ H, W = target_hw
+ xs = []
+
+ for p in samples:
+ chw01 = load_tensor(
+ p,
+ requested_channels=input_channel_names,
+ saved_channel_names=load_tensor_channel_names(p),
+ )
+
+ if chw01.shape[-2:] != (H, W):
+ hwc = np.transpose(chw01, (1, 2, 0))
+ hwc = cv2.resize(hwc, (W, H), interpolation=cv2.INTER_LINEAR)
+ chw01 = np.transpose(hwc, (2, 0, 1)).astype(np.float32)
+
+ if normalize_input:
+ chw = normalize_numpy_chw(chw01, mean=mean, std=std)
+ else:
+ chw = chw01.astype(np.float32, copy=False)
+
+ x = np.expand_dims(chw, axis=0).astype(np.float32)
+ xs.append(x)
+
+ return xs
+
+
+# ============================================================
+# PyTorch
+# ============================================================
+
+class TorchTupleWrapper(nn.Module):
+ def __init__(
+ self,
+ model: nn.Module,
+ output_heads: List[str],
+ postprocess: str = "none",
+ include_norm: bool = False,
+ norm_mean: Optional[Sequence[float]] = None,
+ norm_std: Optional[Sequence[float]] = None,
+ ):
+ super().__init__()
+ self.model = model
+ self.output_heads = list(output_heads)
+ self.postprocess = str(postprocess).lower()
+ self.include_norm = bool(include_norm)
+ if self.postprocess not in ("none", "resize_logits", "argmax_lowres", "argmax_fullres"):
+ raise RuntimeError(f"postprocess inválido: {self.postprocess}")
+ if self.include_norm:
+ if norm_mean is None or norm_std is None:
+ raise RuntimeError("include_norm=True requer norm_mean/norm_std")
+ self.register_buffer(
+ "norm_mean",
+ torch.tensor(norm_mean, dtype=torch.float32).view(1, -1, 1, 1),
+ )
+ self.register_buffer(
+ "norm_std",
+ torch.tensor(norm_std, dtype=torch.float32).view(1, -1, 1, 1),
+ )
+ else:
+ self.register_buffer("norm_mean", torch.empty(0))
+ self.register_buffer("norm_std", torch.empty(0))
+
+ def forward(self, pixel_values: torch.Tensor):
+ x = pixel_values
+ if self.include_norm:
+ x = (x - self.norm_mean) / torch.clamp(self.norm_std, min=1e-6)
+ outputs = self.model(pixel_values=x)
+ input_hw = pixel_values.shape[-2:]
+ result = []
+
+ for head in self.output_heads:
+ value = outputs[head]
+ if self.postprocess in ("resize_logits", "argmax_fullres"):
+ value = F.interpolate(
+ value,
+ size=input_hw,
+ mode="bilinear",
+ align_corners=False,
+ )
+ if self.postprocess in ("argmax_lowres", "argmax_fullres"):
+ value = torch.argmax(value, dim=1).to(torch.uint8)
+ result.append(value)
+
+ return tuple(result)
+
+
+@torch.inference_mode()
+def benchmark_torch(
+ model: nn.Module,
+ inputs_np: List[np.ndarray],
+ device: torch.device,
+ warmup: int,
+ repeat: int,
+ amp: bool,
+ label: str,
+) -> Tuple[dict, List[dict]]:
+ model.eval()
+
+ times = []
+ rows = []
+
+ # Precarrega tensors na GPU para medir só inferência do modelo.
+ inputs_t = [
+ torch.from_numpy(x).to(device, non_blocking=True)
+ for x in inputs_np
+ ]
+
+ if device.type == "cuda":
+ torch.cuda.synchronize()
+
+ print(f"\n[BENCH] {label} | warmup={warmup} repeat={repeat}")
+
+ # Warmup
+ for i in range(max(0, warmup)):
+ x = inputs_t[i % len(inputs_t)]
+ with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=amp and device.type == "cuda"):
+ _ = model(x)
+
+ synchronize_if_cuda(device)
+
+ # Medição
+ total_iter = len(inputs_t) * max(1, repeat)
+ idx = 0
+
+ for r in range(max(1, repeat)):
+ for sample_idx, x in enumerate(inputs_t):
+ synchronize_if_cuda(device)
+ t0 = time.perf_counter()
+
+ with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=amp and device.type == "cuda"):
+ _ = model(x)
+
+ synchronize_if_cuda(device)
+ dt_ms = (time.perf_counter() - t0) * 1000.0
+
+ times.append(dt_ms)
+ rows.append({
+ "engine": label,
+ "repeat": r,
+ "sample_idx": sample_idx,
+ "iter_idx": idx,
+ "latency_ms": dt_ms,
+ })
+
+ idx += 1
+
+ if idx % 25 == 0 or idx == total_iter:
+ print(f" {idx:04d}/{total_iter:04d} | last={dt_ms:.2f}ms")
+
+ summary = summarize_times(times)
+ summary["engine"] = label
+ summary["timing_scope"] = "model_forward_and_export_postprocess_device_resident"
+
+ return summary, rows
+
+
+# ============================================================
+# ONNX Runtime
+# ============================================================
+
+def create_onnx_session(
+ onnx_path: Path,
+ provider: str,
+ trt_home: Optional[str] = None,
+ trt_fp16: bool = True,
+):
+ import os
+
+ try:
+ import onnxruntime as ort
+ except ImportError:
+ raise ImportError(
+ "onnxruntime não está instalado. Instale com:\n"
+ " pip install onnxruntime-gpu\n"
+ "ou CPU:\n"
+ " pip install onnxruntime"
+ )
+
+ available = ort.get_available_providers()
+ print(f"[ONNX] providers disponíveis: {available}")
+
+ provider = provider.lower()
+
+ if provider == "tensorrt":
+ trt_home = trt_home or os.environ.get("TRT_HOME", r"C:\dev\TensorRT-10.10.0.31")
+ dll_dirs = [
+ os.path.join(trt_home, "lib"),
+ os.path.join(trt_home, "bin"),
+ ]
+ cuda_home = os.environ.get("CUDA_PATH")
+ if cuda_home:
+ dll_dirs.append(os.path.join(cuda_home, "bin"))
+ dll_dirs.append(r"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.4\bin")
+
+ for dll_dir in dll_dirs:
+ if os.path.isdir(dll_dir):
+ try:
+ os.add_dll_directory(dll_dir)
+ print(f"[DLL] add_dll_directory: {dll_dir}")
+ except Exception as e:
+ print(f"[DLL][WARN] falha em {dll_dir}: {e}")
+
+ sess_options = ort.SessionOptions()
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
+
+ if provider == "cuda":
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
+
+ elif provider == "cpu":
+ providers = ["CPUExecutionProvider"]
+
+ elif provider == "tensorrt":
+ cache_dir = onnx_path.parent / "trt_cache"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+
+ trt_options = {
+ "device_id": 0,
+
+ # FP16: o ponto principal do nosso teste.
+ "trt_fp16_enable": bool(trt_fp16),
+
+ # Cache: evita rebuild do engine a cada execução.
+ "trt_engine_cache_enable": True,
+ "trt_engine_cache_path": str(cache_dir),
+
+ # Timing cache ajuda a acelerar builds futuros.
+ "trt_timing_cache_enable": True,
+ "trt_timing_cache_path": str(cache_dir),
+
+ # Workspace. 4GB é razoável para RTX 3070, ajuste se faltar VRAM.
+ "trt_max_workspace_size": 4 * 1024 * 1024 * 1024,
+ }
+
+ providers = [
+ ("TensorrtExecutionProvider", trt_options),
+ "CUDAExecutionProvider",
+ "CPUExecutionProvider",
+ ]
+
+ else:
+ providers = [provider]
+
+ # Checagem de disponibilidade, lidando com provider tuple.
+ requested_names = [
+ p[0] if isinstance(p, tuple) else p
+ for p in providers
+ ]
+
+ providers_ok = [
+ p for p in providers
+ if (p[0] if isinstance(p, tuple) else p) in available
+ ]
+
+ if not providers_ok:
+ raise RuntimeError(
+ f"Nenhum provider solicitado está disponível. "
+ f"Solicitado={requested_names}, disponível={available}"
+ )
+
+ session = ort.InferenceSession(
+ str(onnx_path),
+ sess_options=sess_options,
+ providers=providers_ok,
+ )
+
+ print(f"[ONNX] usando providers: {session.get_providers()}")
+
+ active_providers = session.get_providers()
+
+ if provider == "tensorrt" and "TensorrtExecutionProvider" not in active_providers:
+ raise RuntimeError(
+ "TensorRTExecutionProvider foi solicitado, mas não ficou ativo. "
+ f"Providers ativos: {active_providers}. "
+ "Provável causa: TensorRT não instalado, DLLs fora do PATH, "
+ "ou versão incompatível com onnxruntime-gpu."
+ )
+
+ if provider == "cuda" and "CUDAExecutionProvider" not in active_providers:
+ raise RuntimeError(
+ "CUDAExecutionProvider foi solicitado, mas não ficou ativo. "
+ f"Providers ativos: {active_providers}."
+ )
+
+ return session
+
+
+def benchmark_onnx(
+ session,
+ inputs_np: List[np.ndarray],
+ warmup: int,
+ repeat: int,
+ label: str,
+) -> Tuple[dict, List[dict]]:
+ input_name = session.get_inputs()[0].name
+
+ times = []
+ rows = []
+
+ print(f"\n[BENCH] {label} | warmup={warmup} repeat={repeat}")
+
+ # Warmup
+ for i in range(max(0, warmup)):
+ x = inputs_np[i % len(inputs_np)]
+ _ = session.run(None, {input_name: x})
+
+ # Medição
+ total_iter = len(inputs_np) * max(1, repeat)
+ idx = 0
+
+ for r in range(max(1, repeat)):
+ for sample_idx, x in enumerate(inputs_np):
+ t0 = time.perf_counter()
+ _ = session.run(None, {input_name: x})
+ dt_ms = (time.perf_counter() - t0) * 1000.0
+
+ times.append(dt_ms)
+ rows.append({
+ "engine": label,
+ "repeat": r,
+ "sample_idx": sample_idx,
+ "iter_idx": idx,
+ "latency_ms": dt_ms,
+ })
+
+ idx += 1
+
+ if idx % 25 == 0 or idx == total_iter:
+ print(f" {idx:04d}/{total_iter:04d} | last={dt_ms:.2f}ms")
+
+ summary = summarize_times(times)
+ summary["engine"] = label
+ summary["timing_scope"] = "onnx_session_run_host_input_and_outputs"
+
+ return summary, rows
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser()
+
+ parser.add_argument("--config", default="config.json")
+ parser.add_argument("--checkpoint", default="")
+ parser.add_argument("--onnx", default="")
+ parser.add_argument("--train-script", default="_8_train_multihead_v2.py")
+ parser.add_argument("--labelmap", default="dataset/labelmap.txt")
+
+ parser.add_argument("--split_folder", default="val", choices=["train", "val", "test"])
+ parser.add_argument("--root_override", default=None)
+ parser.add_argument("--norm_stats", default=None)
+
+ parser.add_argument("--max_samples", type=int, default=50)
+ parser.add_argument("--start_idx", type=int, default=0)
+ parser.add_argument("--warmup", type=int, default=10)
+ parser.add_argument("--repeat", type=int, default=5)
+
+ parser.add_argument("--device", default="cuda", choices=["cuda", "cpu"])
+ parser.add_argument("--onnx_provider", default="cuda", choices=["cuda", "cpu", "tensorrt"])
+ parser.add_argument("--trt_home", default=None)
+ parser.add_argument("--trt_no_fp16", action="store_true")
+
+ parser.add_argument("--skip_torch_fp32", action="store_true")
+ parser.add_argument("--skip_torch_amp", action="store_true")
+ parser.add_argument("--skip_onnx", action="store_true")
+
+ parser.add_argument(
+ "--onnx_has_norm",
+ action="store_true",
+ help="Força ONNX com normalização interna; normalmente detectado pelo export_meta.",
+ )
+ parser.add_argument(
+ "--onnx_no_norm",
+ action="store_true",
+ help="Força ONNX sem normalização interna; normalmente detectado pelo export_meta.",
+ )
+
+ parser.add_argument("--out_dir", default=None)
+
+ args = parser.parse_args()
+
+ if args.skip_torch_fp32 and args.skip_torch_amp and args.skip_onnx:
+ raise RuntimeError("Todos os engines foram desativados; não há o que medir.")
+
+ config_path = resolve_path(args.config, Path.cwd())
+
+ if config_path is None or not config_path.is_file():
+ raise FileNotFoundError(f"Config não encontrado: {config_path}")
+ config_dir = config_path.parent
+ config = load_json(config_path)
+
+ train_script_path = resolve_path(args.train_script, config_dir)
+ labelmap_path = resolve_path(args.labelmap, config_dir)
+ if train_script_path is None or not train_script_path.is_file():
+ raise FileNotFoundError(f"Train script não encontrado: {train_script_path}")
+ if labelmap_path is None or not labelmap_path.is_file():
+ raise FileNotFoundError(f"Labelmap não encontrado: {labelmap_path}")
+
+ train_mod = import_train_module(train_script_path)
+ trainer_version = str(getattr(train_mod, "TRAINER_VERSION", "legacy/unknown"))
+ print(f"[TRAIN MODULE] {train_script_path.name} | trainer_version={trainer_version}")
+
+ W, H = config.get("resolucao", [1024, 640])
+ W = int(W)
+ H = int(H)
+
+ backbone = config.get("backbone", "nvidia/mit-b1")
+ input_channel_names = train_mod.get_input_channel_names(config)
+ input_channel_indices = train_mod.get_input_channel_indices(config)
+ channels = len(input_channel_names)
+
+ checkpoint_path, onnx_path, ckpt_name = resolve_model_artifact_paths(
+ args=args,
+ config=config,
+ config_dir=config_dir,
+ channels=channels,
+ require_onnx=not args.skip_onnx,
+ )
+
+ export_meta, export_meta_path = load_export_metadata(onnx_path) if onnx_path.is_file() else ({}, None)
+ exported_names = [
+ str(x).strip().upper()
+ for x in export_meta.get("input_channel_names", [])
+ ]
+ if exported_names and exported_names != input_channel_names:
+ raise RuntimeError(
+ f"ONNX incompatível: canais exportados={exported_names}, "
+ f"config atual={input_channel_names}."
+ )
+ if args.onnx_has_norm and args.onnx_no_norm:
+ raise RuntimeError("Use apenas uma opção: --onnx_has_norm ou --onnx_no_norm.")
+ input_contract = export_meta.get("input_contract", {}) or {}
+ detected_onnx_has_norm = bool(
+ input_contract.get("normalization_embedded", export_meta.get("include_norm", False))
+ )
+ onnx_has_norm = (
+ True if args.onnx_has_norm
+ else False if args.onnx_no_norm
+ else detected_onnx_has_norm
+ )
+ exported_postprocess = str(export_meta.get("postprocess", "none")).lower()
+ if exported_postprocess == "none" and bool(export_meta.get("resize_to_input", False)):
+ exported_postprocess = "resize_logits"
+ if exported_postprocess not in ("none", "resize_logits", "argmax_lowres", "argmax_fullres"):
+ raise RuntimeError(f"postprocess inválido no export_meta: {exported_postprocess}")
+
+ semantic_id2label, semantic_label2id, ignore_from_labelmap = train_mod.load_labelmap(
+ str(labelmap_path)
+ )
+
+ heads_config = train_mod.build_heads_config(
+ config,
+ ignore_index=int(ignore_from_labelmap)
+ )
+
+ heads_config["semantic"]["num_classes"] = int(len(semantic_id2label))
+ heads_config["semantic"]["ignore_index"] = int(ignore_from_labelmap)
+
+ output_heads = list(heads_config.keys())
+ exported_heads = list(export_meta.get("heads", []) or [])
+ if exported_heads and exported_heads != output_heads:
+ raise RuntimeError(
+ f"Heads do ONNX={exported_heads} diferem do config/modelo={output_heads}."
+ )
+
+ save_dir = (
+ config_dir
+ / "backup"
+ / config.get("modelo", "segformer_b1")
+ / config.get("model_name", "test")
+ / experiment_tag(config, channels)
+ )
+
+ norm_stats_path = find_norm_stats(
+ config=config,
+ config_dir=config_dir,
+ save_dir=save_dir,
+ explicit=args.norm_stats,
+ )
+
+ mean, std, norm_stats_used = load_norm_stats(
+ norm_stats_path,
+ channel_names=input_channel_names,
+ )
+
+ if args.root_override:
+ root = resolve_path(args.root_override, Path.cwd())
+ else:
+ root = (config_dir / "dataset" / "split" / args.split_folder).resolve()
+
+ if root is None or not root.is_dir():
+ raise FileNotFoundError(f"Root de dados não encontrado: {root}")
+
+ samples = collect_tensor_samples(
+ root=root,
+ max_samples=args.max_samples,
+ start_idx=args.start_idx,
+ )
+
+ if args.out_dir:
+ out_dir = resolve_path(args.out_dir, config_dir)
+ else:
+ out_dir = onnx_path.parent / "benchmarks"
+
+ assert out_dir is not None
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ use_cuda = args.device == "cuda" and torch.cuda.is_available()
+ device = torch.device("cuda" if use_cuda else "cpu")
+
+ if args.device == "cuda" and not torch.cuda.is_available():
+ print("[WARN] CUDA indisponível. Usando CPU no PyTorch.")
+
+ print("==========================================")
+ print("Benchmark PyTorch vs ONNX")
+ print(f"Config : {config_path}")
+ print(f"Checkpoint : {checkpoint_path}")
+ print(f"ONNX : {onnx_path}")
+ print(f"Export meta : {export_meta_path or 'ausente (modo legado)'}")
+ print(f"Root : {root}")
+ print(f"Samples : {len(samples)}")
+ print(f"Warmup : {args.warmup}")
+ print(f"Repeat : {args.repeat}")
+ print(f"Backbone : {backbone}")
+ print(f"Input shape : [1, {channels}, {H}, {W}]")
+ print(f"Channels : {input_channel_names} idx={input_channel_indices}")
+ print(f"Heads : {output_heads}")
+ print(f"Device : {device}")
+ print(f"ONNX provider: {args.onnx_provider}")
+ print(f"ONNX has norm: {onnx_has_norm}")
+ print(f"Postprocess : {exported_postprocess}")
+ print(f"Out dir : {out_dir}")
+ print("==========================================")
+ print(
+ "[TIMING] PyTorch mede forward com entrada residente no device; "
+ "ONNX session.run inclui cópia da entrada/saídas do ORT. "
+ "Pré-processamento e leitura de disco ficam fora das duas medições."
+ )
+
+ run_torch_fp32 = not args.skip_torch_fp32
+ run_torch_amp = not args.skip_torch_amp and device.type == "cuda"
+ if not args.skip_torch_amp and device.type != "cuda":
+ print("[WARN] PyTorch AMP/FP16 exige CUDA; medição AMP será pulada.")
+ need_torch = run_torch_fp32 or run_torch_amp
+ if not need_torch and args.skip_onnx:
+ raise RuntimeError("Nenhum engine executável restou para o benchmark.")
+ need_normalized_inputs = (
+ (need_torch or not args.skip_onnx) and not onnx_has_norm
+ )
+
+ inputs_torch_np: List[np.ndarray] = []
+ if need_normalized_inputs:
+ print("\n[DATA] Carregando inputs normalizados...")
+ inputs_torch_np = load_inputs_as_numpy(
+ samples=samples,
+ input_channel_names=input_channel_names,
+ mean=mean,
+ std=std,
+ target_hw=(H, W),
+ normalize_input=True,
+ )
+
+ if (need_torch or not args.skip_onnx) and onnx_has_norm:
+ print("[DATA] Carregando inputs crus para ONNX com normalização embutida...")
+ inputs_onnx_np = load_inputs_as_numpy(
+ samples=samples,
+ input_channel_names=input_channel_names,
+ mean=mean,
+ std=std,
+ target_hw=(H, W),
+ normalize_input=False,
+ )
+ else:
+ inputs_onnx_np = inputs_torch_np
+ if need_torch and onnx_has_norm:
+ inputs_torch_np = inputs_onnx_np
+ print(f"[DATA] Amostras preparadas: {len(samples)}")
+
+ summaries = []
+ all_rows = []
+
+ # ========================================================
+ # PyTorch
+ # ========================================================
+ if need_torch:
+ print("\n[MODEL] Montando PyTorch...")
+ build_kwargs = dict(
+ backbone=backbone,
+ input_channel_names=input_channel_names,
+ heads_config=heads_config,
+ semantic_id2label=semantic_id2label,
+ semantic_label2id=semantic_label2id,
+ )
+ try:
+ if "config" in inspect.signature(train_mod.build_model).parameters:
+ build_kwargs["config"] = config
+ except (TypeError, ValueError):
+ pass
+ model = train_mod.build_model(**build_kwargs)
+
+ ckpt = train_mod.load_checkpoint(
+ str(checkpoint_path),
+ model,
+ map_location="cpu",
+ )
+
+ saved_contract = ckpt.get("model_contract", {}) or {}
+ print(
+ "[MODEL CONTRACT] "
+ f"decoder={saved_contract.get('decoder_mode', 'legacy')} | "
+ f"spectral_init={saved_contract.get('spectral_input_init', 'legacy')} | "
+ f"channels={saved_contract.get('input_channel_names', [])}"
+ )
+ saved_names = [str(x).strip().upper() for x in saved_contract.get("input_channel_names", [])]
+ legacy_names = SOURCE_CHANNEL_ORDER[:channels]
+ if not saved_names and input_channel_names != legacy_names:
+ raise RuntimeError(
+ "Checkpoint sem contrato nominal não pode ser usado com "
+ f"input_channels={input_channel_names}."
+ )
+ model.to(device)
+ model.eval()
+
+ torch_model = TorchTupleWrapper(
+ model=model,
+ output_heads=output_heads,
+ postprocess=exported_postprocess,
+ include_norm=onnx_has_norm,
+ norm_mean=mean,
+ norm_std=std,
+ ).to(device)
+ torch_model.eval()
+
+ clear_cuda()
+
+ if run_torch_fp32:
+ summary, rows = benchmark_torch(
+ model=torch_model,
+ inputs_np=inputs_torch_np,
+ device=device,
+ warmup=args.warmup,
+ repeat=args.repeat,
+ amp=False,
+ label="torch_fp32",
+ )
+ summaries.append(summary)
+ all_rows.extend(rows)
+
+ clear_cuda()
+
+ if run_torch_amp:
+ summary, rows = benchmark_torch(
+ model=torch_model,
+ inputs_np=inputs_torch_np,
+ device=device,
+ warmup=args.warmup,
+ repeat=args.repeat,
+ amp=True,
+ label="torch_amp_fp16",
+ )
+ summaries.append(summary)
+ all_rows.extend(rows)
+
+ del torch_model
+ del model
+ clear_cuda()
+
+ # ========================================================
+ # ONNX
+ # ========================================================
+ if not args.skip_onnx:
+ print("\n[ONNX] Carregando sessão...")
+ session = create_onnx_session(
+ onnx_path=onnx_path,
+ provider=args.onnx_provider,
+ trt_home=args.trt_home,
+ trt_fp16=not args.trt_no_fp16,
+ )
+
+ onnx_input = session.get_inputs()[0]
+ onnx_input_shape = onnx_input.shape
+ if len(onnx_input_shape) != 4:
+ raise RuntimeError(
+ f"Entrada ONNX inesperada: {onnx_input.name} shape={onnx_input_shape}"
+ )
+ onnx_channels = onnx_input_shape[1]
+ if isinstance(onnx_channels, int) and onnx_channels != channels:
+ raise RuntimeError(
+ f"ONNX espera {onnx_channels} canais, config possui {channels}: "
+ f"{input_channel_names}"
+ )
+ print(f"[ONNX] input={onnx_input.name} shape={onnx_input_shape}")
+
+ summary, rows = benchmark_onnx(
+ session=session,
+ inputs_np=inputs_onnx_np,
+ warmup=args.warmup,
+ repeat=args.repeat,
+ label=f"onnx_{args.onnx_provider}",
+ )
+ summaries.append(summary)
+ all_rows.extend(rows)
+
+ # ========================================================
+ # Relatório
+ # ========================================================
+ print("\n========== RESUMO ==========")
+
+ for s in summaries:
+ print(
+ f"{s['engine']:<16} "
+ f"n={s['n']:<4} "
+ f"mean={s['mean_ms']:.3f}ms "
+ f"median={s['median_ms']:.3f}ms "
+ f"p95={s['p95_ms']:.3f}ms "
+ f"p99={s['p99_ms']:.3f}ms "
+ f"fps_mean={s['fps_mean']:.2f} "
+ f"fps_p95={s['fps_p95_latency']:.2f}"
+ )
+
+ base_name = f"{onnx_path.stem}_{args.onnx_provider}"
+ report_json = out_dir / f"{base_name}_benchmark_report.json"
+ report_csv = out_dir / f"{base_name}_benchmark_rows.csv"
+
+ report = {
+ "config": str(config_path),
+ "train_script": str(train_script_path),
+ "trainer_version": trainer_version,
+ "checkpoint": str(checkpoint_path),
+ "ckpt_name": ckpt_name,
+ "onnx": str(onnx_path),
+ "export_meta": str(export_meta_path) if export_meta_path is not None else None,
+ "onnx_has_norm": bool(onnx_has_norm),
+ "onnx_postprocess": exported_postprocess,
+ "root": str(root),
+ "samples": len(samples),
+ "warmup": int(args.warmup),
+ "repeat": int(args.repeat),
+ "input_shape": [1, channels, H, W],
+ "input_channel_names": input_channel_names,
+ "input_channel_indices": input_channel_indices,
+ "heads": output_heads,
+ "norm_stats_used": norm_stats_used,
+ "onnx_provider": args.onnx_provider,
+ "trt_home": args.trt_home,
+ "trt_fp16": bool(not args.trt_no_fp16),
+ "device": str(device),
+ "dataset_preprocessing_in_timing": False,
+ "normalization_in_timing": bool(onnx_has_norm),
+ "summaries": summaries,
+ }
+
+ save_json(report_json, report)
+ save_csv(report_csv, all_rows)
+
+ print(f"\n[OK] JSON salvo em: {report_json}")
+ print(f"[OK] CSV salvo em : {report_csv}")
+ print("\nBenchmark finalizado.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/_3_select_training_samples.py b/Python/OAK/datasets/oak-fcc-3/_3_select_training_samples.py
index 0045791b1..0bd9bb9d5 100644
--- a/Python/OAK/datasets/oak-fcc-3/_3_select_training_samples.py
+++ b/Python/OAK/datasets/oak-fcc-3/_3_select_training_samples.py
@@ -2,675 +2,780 @@
# -*- coding: utf-8 -*-
"""
-_3_select_training_samples.py
+Seleção agrícola inteligente antes do normalize.
-Seleciona amostras prontas em dataset/brutas/group e copia para
-dataset/original/group com balanceamento por classe.
+Princípios:
+- máscara é a fonte da verdade para as classes presentes;
+- pasta de origem é apenas uma indicação manual;
+- remove duplicatas/quase-duplicatas;
+- quando há limite, seleciona coreset por diversidade + relevância agrícola;
+- preserva preview/mask/meta/3 bins;
+- gera manifest, audit e summary.
-Estrutura de entrada:
+Classes padrão: 0=chao, 1=cana, 2=erva, 255=ignore.
-dataset/brutas/group/
- /
- previews/
- masks/
- metas/
- bins/
+python .\_3_select_training_samples.py --src-root .\dataset\brutas\group --dst-root .\dataset\original\group --copy-all chao_cana chao_erva chao_cana_erva cana erva cana_erva --group-limit chao=500 --selection-mode diverse --presence-min-pixels 100 --seed 42 --clear-dst
-Estrutura de saída:
-
-dataset/original/group/
- /
- previews/
- masks/
- metas/
- bins/
-
-Cada amostra válida precisa ter:
- previews/.png
- masks/.png
- metas/.json
- bins/_CAM_A.bin
- bins/_CAM_B.bin
- bins/_CAM_C.bin
-
-Também aceita formato antigo:
- bins/_cam0.bin
- bins/_cam1.bin
- bins/_cam2.bin
-
-Exemplo:
-
-python _4_select_training_samples.py ^
- --src-root dataset/brutas/group ^
- --dst-root dataset/original/group ^
- --copy-all chao_cana chao_erva chao_cana_erva ^
- --max-ratio 2.0 ^
- --seed 42
"""
from __future__ import annotations
import argparse
import csv
+import hashlib
import json
+import math
import random
import re
import shutil
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
from statistics import median
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, Sequence, Tuple
+import cv2
+import numpy as np
+
+from helpers import (
+ carregar_labelmap_completo,
+ converter_mask_rgb_para_ids,
+ _infer_ignore_id,
+)
PREVIEW_EXTS = (".png", ".jpg", ".jpeg")
-
-BIN_RE_OAK = re.compile(
- r"^(?P.+)_(?PCAM_[ABC])(?P.*)\.bin$",
- re.IGNORECASE,
-)
-
-BIN_RE_OLD = re.compile(
- r"^(?P.+)_cam(?P\d+)(?P.*)\.bin$",
- re.IGNORECASE,
-)
-
-CAM_ORDER = {
- "CAM_A": 0,
- "CAM_B": 1,
- "CAM_C": 2,
- "cam0": 0,
- "cam1": 1,
- "cam2": 2,
+CLASS_IDS = (0, 1, 2)
+GROUP_BY_SET = {
+ frozenset({0}): "chao",
+ frozenset({1}): "cana",
+ frozenset({2}): "erva",
+ frozenset({0, 1}): "chao_cana",
+ frozenset({0, 2}): "chao_erva",
+ frozenset({1, 2}): "cana_erva",
+ frozenset({0, 1, 2}): "chao_cana_erva",
}
+BIN_RE_OAK = re.compile(r"^(?P.+)_(?PCAM_[ABC])(?P.*)\.bin$", re.I)
+BIN_RE_OLD = re.compile(r"^(?P.+)_cam(?P\d+)(?P.*)\.bin$", re.I)
+CAM_ORDER = {"CAM_A": 0, "CAM_B": 1, "CAM_C": 2, "cam0": 0, "cam1": 1, "cam2": 2}
+
@dataclass
-class SampleBundle:
- group: str
+class Sample:
+ source_group: str
base: str
preview_path: Path
mask_path: Path
meta_path: Path
bin_paths: List[Path]
+ actual_group: str = ""
+ present_ids: Tuple[int, ...] = field(default_factory=tuple)
+ frac_chao: float = 0.0
+ frac_cana: float = 0.0
+ frac_erva: float = 0.0
+ comp_cana: int = 0
+ comp_erva: int = 0
+ largest_cana: float = 0.0
+ largest_erva: float = 0.0
+ weed_near_cane: float = 0.0
+ weed_touch_cane: float = 0.0
+ brightness: float = 0.0
+ contrast: float = 0.0
+ saturation: float = 0.0
+ dhash: int = 0
+ content_hash: str = ""
+ feature: Optional[np.ndarray] = None
+ agri_score: float = 0.0
+ diversity_score: float = 0.0
+ duplicate_of: str = ""
+ selected: bool = False
+ reason: str = ""
-def ensure_dir(path: Path) -> None:
- path.mkdir(parents=True, exist_ok=True)
+
+def ensure_dir(p: Path):
+ p.mkdir(parents=True, exist_ok=True)
def parse_bin_name(path: Path):
- name = path.name
-
- m = BIN_RE_OAK.match(name)
+ m = BIN_RE_OAK.match(path.name)
if m:
cam = m.group("cam").upper()
-
- # Exemplo:
- # 20_PosProcessamento_CAM_A (2).bin
- # prefixo = 20_PosProcessamento
- # sufixo = " (2)"
- # base = 20_PosProcessamento (2)
- base = m.group("prefixo") + m.group("sufixo")
-
- return base, cam, CAM_ORDER.get(cam, 99)
-
- m = BIN_RE_OLD.match(name)
+ return m.group("prefixo") + m.group("sufixo"), cam, CAM_ORDER.get(cam, 99)
+ m = BIN_RE_OLD.match(path.name)
if m:
cam = f"cam{int(m.group('cam'))}"
- base = m.group("prefixo") + m.group("sufixo")
-
- return base, cam, CAM_ORDER.get(cam, 99)
-
+ return m.group("prefixo") + m.group("sufixo"), cam, CAM_ORDER.get(cam, 99)
return None
-def find_preview_files(previews_dir: Path) -> List[Path]:
- files = []
-
- if not previews_dir.is_dir():
- return files
-
- for p in sorted(previews_dir.iterdir()):
- if p.is_file() and p.suffix.lower() in PREVIEW_EXTS:
- files.append(p)
-
- return files
-
-
-def find_bins_for_base(bins_dir: Path, base: str) -> List[Path]:
+def find_bins(bins_dir: Path, base: str) -> List[Path]:
found = []
-
if not bins_dir.is_dir():
- return found
-
+ return []
for p in bins_dir.glob("*.bin"):
parsed = parse_bin_name(p)
- if parsed is None:
- continue
-
- bin_base, _cam, order = parsed
-
- if bin_base != base:
- continue
-
- found.append((order, p))
-
- found.sort(key=lambda x: x[0])
- return [p for _, p in found]
+ if parsed and parsed[0] == base:
+ found.append((parsed[2], p))
+ return [p for _, p in sorted(found, key=lambda x: x[0])]
-def has_required_cameras(
- bin_paths: List[Path],
- required_cameras: List[str],
-) -> bool:
- if not required_cameras:
- return len(bin_paths) > 0
-
- counts = {cam.upper(): 0 for cam in required_cameras}
-
+def has_required_cameras(bin_paths: Sequence[Path], required: Sequence[str]) -> bool:
+ if not required:
+ return bool(bin_paths)
+ seen = {x.upper(): 0 for x in required}
for p in bin_paths:
parsed = parse_bin_name(p)
- if parsed is None:
+ if not parsed:
continue
-
- _base, cam, _order = parsed
-
- if cam.lower() == "cam0":
- normalizada = "CAM_A"
- elif cam.lower() == "cam1":
- normalizada = "CAM_B"
- elif cam.lower() == "cam2":
- normalizada = "CAM_C"
- else:
- normalizada = cam.upper()
-
- if normalizada in counts:
- counts[normalizada] += 1
-
- return all(quantidade == 1 for quantidade in counts.values())
+ cam = parsed[1]
+ cam = {"cam0": "CAM_A", "cam1": "CAM_B", "cam2": "CAM_C"}.get(cam.lower(), cam.upper())
+ if cam in seen:
+ seen[cam] += 1
+ return all(v == 1 for v in seen.values())
-def collect_valid_samples_from_group(
- group_dir: Path,
- required_cameras: List[str],
-) -> List[SampleBundle]:
- group = group_dir.name
-
- previews_dir = group_dir / "previews"
- masks_dir = group_dir / "masks"
- metas_dir = group_dir / "metas"
- bins_dir = group_dir / "bins"
-
- samples = []
-
- if not previews_dir.is_dir():
- return samples
-
- for preview_path in find_preview_files(previews_dir):
- base = preview_path.stem
-
- mask_path = masks_dir / f"{base}.png"
- meta_path = metas_dir / f"{base}.json"
- bin_paths = find_bins_for_base(bins_dir, base)
-
- if not mask_path.exists():
+def collect_samples(src_root: Path, required: Sequence[str]) -> List[Sample]:
+ out = []
+ for gdir in sorted(src_root.iterdir()):
+ if not gdir.is_dir():
continue
-
- if not meta_path.exists():
+ pdir, mdir, jdir, bdir = gdir / "previews", gdir / "masks", gdir / "metas", gdir / "bins"
+ if not pdir.is_dir():
continue
-
- if not bin_paths:
- continue
-
- if not has_required_cameras(bin_paths, required_cameras):
- continue
-
- samples.append(
- SampleBundle(
- group=group,
- base=base,
- preview_path=preview_path,
- mask_path=mask_path,
- meta_path=meta_path,
- bin_paths=bin_paths,
- )
- )
-
- return samples
+ for preview in sorted(pdir.iterdir()):
+ if not preview.is_file() or preview.suffix.lower() not in PREVIEW_EXTS:
+ continue
+ base = preview.stem
+ mask = mdir / f"{base}.png"
+ meta = jdir / f"{base}.json"
+ bins = find_bins(bdir, base)
+ if not mask.is_file() or not meta.is_file() or not bins:
+ continue
+ if not has_required_cameras(bins, required):
+ continue
+ out.append(Sample(gdir.name, base, preview, mask, meta, bins))
+ return out
-def collect_all_groups(
- src_root: Path,
- required_cameras: List[str],
-) -> Dict[str, List[SampleBundle]]:
- groups = {}
+def read_mask(
+ path: Path,
+ cor_para_id: dict,
+ ignore_id: int,
+) -> np.ndarray:
+ """
+ Lê a máscara bruta usando o MESMO contrato do normalize.
- for group_dir in sorted(src_root.iterdir()):
- if not group_dir.is_dir():
- continue
+ Aceita:
+ 1) id-map grayscale já em 0/1/2/ignore;
+ 2) máscara RGB colorida definida pelo dataset/labelmap.txt.
- valid = collect_valid_samples_from_group(group_dir, required_cameras)
- groups[group_dir.name] = valid
+ Nas máscaras coloridas:
+ OpenCV lê BGR -> convertemos para RGB -> helpers.converter_mask_rgb_para_ids.
+ """
+ raw = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
- return groups
+ if raw is None:
+ raise RuntimeError(f"falha ao ler máscara: {path}")
+ allowed = set(CLASS_IDS) | {int(ignore_id)}
-def compute_reference_count(
- all_samples: Dict[str, List[SampleBundle]],
- copy_all: List[str],
- fallback_mode: str,
-) -> int:
- counts = []
+ # Caso já seja uma máscara de IDs grayscale.
+ if raw.ndim == 2:
+ unique = set(int(v) for v in np.unique(raw).tolist())
+ if unique.issubset(allowed):
+ return np.asarray(raw)
- for group in copy_all:
- n = len(all_samples.get(group, []))
- if n > 0:
- counts.append(n)
+ # Algumas máscaras podem estar salvas em 3 canais, mas já conter IDs iguais
+ # nos três canais. Aceitamos esse caso também.
+ if raw.ndim == 3 and raw.shape[2] >= 3:
+ c0 = raw[..., 0]
+ c1 = raw[..., 1]
+ c2 = raw[..., 2]
- if counts:
- return int(round(median(counts)))
+ if np.array_equal(c0, c1) and np.array_equal(c0, c2):
+ unique = set(int(v) for v in np.unique(c0).tolist())
+ if unique.issubset(allowed):
+ return np.asarray(c0)
- # Fallback caso não tenha copy_all válido
- all_counts = [len(v) for v in all_samples.values() if len(v) > 0]
+ # Contrato normal do dataset bruto: máscara COLORIDA segundo labelmap.txt.
+ bgr = cv2.imread(str(path), cv2.IMREAD_COLOR)
- if not all_counts:
- return 0
+ if bgr is None:
+ raise RuntimeError(f"falha ao abrir máscara RGB: {path}")
- if fallback_mode == "min":
- return min(all_counts)
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
- if fallback_mode == "median":
- return int(round(median(all_counts)))
-
- if fallback_mode == "mean":
- return int(round(sum(all_counts) / len(all_counts)))
-
- return min(all_counts)
-
-
-def select_samples(
- all_samples: Dict[str, List[SampleBundle]],
- copy_all: List[str],
- max_ratio: float,
- target_per_other: Optional[int],
- max_per_class: Optional[int],
- seed: int,
- fallback_mode: str,
-) -> Dict[str, List[SampleBundle]]:
- rng = random.Random(seed)
-
- copy_all_set = set(copy_all)
-
- reference_count = compute_reference_count(
- all_samples=all_samples,
- copy_all=copy_all,
- fallback_mode=fallback_mode,
+ ids = converter_mask_rgb_para_ids(
+ rgb,
+ cor_para_id,
+ int(ignore_id),
)
- if target_per_other is not None:
- other_limit = int(target_per_other)
- else:
- other_limit = int(round(reference_count * float(max_ratio)))
+ ids = np.asarray(ids)
- if other_limit < 1:
- other_limit = 1
+ if ids.ndim != 2:
+ raise RuntimeError(
+ f"conversão RGB->IDs retornou shape inválido {ids.shape}: {path}"
+ )
- selected = {}
+ unique = set(int(v) for v in np.unique(ids).tolist())
+ unknown = sorted(unique - allowed)
- for group, samples in all_samples.items():
- samples_sorted = list(samples)
- rng.shuffle(samples_sorted)
+ if unknown:
+ raise RuntimeError(
+ f"IDs desconhecidos após converter labelmap: {unknown} em {path}"
+ )
- if group in copy_all_set:
- chosen = samples_sorted
- else:
- limit = other_limit
-
- if max_per_class is not None:
- limit = min(limit, int(max_per_class))
-
- chosen = samples_sorted[: min(len(samples_sorted), limit)]
-
- # Ordena por nome depois do sorteio, só para copiar organizado
- chosen.sort(key=lambda s: s.base)
- selected[group] = chosen
-
- return selected
+ return ids
-def dest_bin_name(new_base: str, src_bin: Path) -> str:
- parsed = parse_bin_name(src_bin)
+def infer_group(mask: np.ndarray, ignore_ids: Sequence[int], min_pixels: int, min_frac: float):
+ allowed = set(CLASS_IDS) | set(ignore_ids)
+ unknown = sorted(set(int(v) for v in np.unique(mask)) - allowed)
+ if unknown:
+ raise RuntimeError(f"IDs desconhecidos na máscara: {unknown}")
- if parsed is None:
- return src_bin.name
+ present = []
+ for cid in CLASS_IDS:
+ count = int(np.count_nonzero(mask == cid))
+ frac = count / max(mask.size, 1)
+ if count >= max(1, min_pixels) and frac >= min_frac:
+ present.append(cid)
- _old_base, cam, _order = parsed
-
- if cam.upper().startswith("CAM_"):
- return f"{new_base}_{cam.upper()}.bin"
-
- return f"{new_base}_{cam}.bin"
+ group = GROUP_BY_SET.get(frozenset(present))
+ if group is None:
+ raise RuntimeError(f"combinação de classes não mapeada: {present}")
+ return group, tuple(present)
-def unique_base_for_dest(dst_group_dir: Path, base: str, preview_ext: str) -> str:
- previews_dir = dst_group_dir / "previews"
- masks_dir = dst_group_dir / "masks"
- metas_dir = dst_group_dir / "metas"
- bins_dir = dst_group_dir / "bins"
+def comp_stats(binary: np.ndarray):
+ n, _, stats, _ = cv2.connectedComponentsWithStats(binary.astype(np.uint8), 8)
+ if n <= 1:
+ return 0, 0.0
+ areas = stats[1:, cv2.CC_STAT_AREA]
+ return int(len(areas)), float(np.max(areas) / max(binary.size, 1))
- def occupied(candidate: str) -> bool:
- if (previews_dir / f"{candidate}{preview_ext}").exists():
- return True
- if (masks_dir / f"{candidate}.png").exists():
- return True
- if (metas_dir / f"{candidate}.json").exists():
- return True
- for suffix in ("CAM_A", "CAM_B", "CAM_C", "cam0", "cam1", "cam2"):
- if (bins_dir / f"{candidate}_{suffix}.bin").exists():
- return True
+def weed_cane_relation(mask: np.ndarray, near_frac: float):
+ cane = mask == 1
+ weed = mask == 2
+ if not np.any(cane) or not np.any(weed):
+ return 0.0, 0.0
- return False
+ radius = max(1, int(round(min(mask.shape[:2]) * near_frac)))
+ dist = cv2.distanceTransform((~cane).astype(np.uint8), cv2.DIST_L2, 3)
+ near = float(np.mean(dist[weed] <= radius))
- if not occupied(base):
- return base
+ dil = cv2.dilate(cane.astype(np.uint8), np.ones((3, 3), np.uint8), iterations=1) > 0
+ touch = float(np.mean(dil[weed])) if np.any(weed) else 0.0
+ return near, touch
+
+def grid_fraction(binary: np.ndarray, rows=4, cols=6):
+ h, w = binary.shape[:2]
+ vals = []
+ for gy in range(rows):
+ y0, y1 = int(gy*h/rows), int((gy+1)*h/rows)
+ for gx in range(cols):
+ x0, x1 = int(gx*w/cols), int((gx+1)*w/cols)
+ tile = binary[y0:y1, x0:x1]
+ vals.append(float(np.mean(tile)) if tile.size else 0.0)
+ return np.asarray(vals, np.float32)
+
+
+def dhash64(img: np.ndarray) -> int:
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+ small = cv2.resize(gray, (9, 8), interpolation=cv2.INTER_AREA)
+ diff = small[:, 1:] > small[:, :-1]
+ v = 0
+ for bit in diff.flatten():
+ v = (v << 1) | int(bool(bit))
+ return v
+
+
+def hist_features(img: np.ndarray):
+ hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
+ parts = []
+ for ch, bins, hi in ((0, 12, 180), (1, 8, 256), (2, 8, 256)):
+ h = cv2.calcHist([hsv], [ch], None, [bins], [0, hi]).flatten().astype(np.float32)
+ if h.sum() > 0:
+ h /= h.sum()
+ parts.append(h)
+ return np.concatenate(parts)
+
+
+def agricultural_score(s: Sample) -> float:
+ has_chao, has_cana, has_erva = 0 in s.present_ids, 1 in s.present_ids, 2 in s.present_ids
+ score = 0.0
+ if has_erva: score += 0.20
+ if has_cana: score += 0.12
+ if has_chao and (has_cana or has_erva): score += 0.08
+ if has_cana and has_erva: score += 0.28
+ score += 0.18 * s.weed_near_cane
+ score += 0.10 * s.weed_touch_cane
+ return float(min(1.0, score))
+
+
+def analyze(
+ s: Sample,
+ trust_folder: bool,
+ ignore_ids: Sequence[int],
+ min_pixels: int,
+ min_frac: float,
+ near_frac: float,
+ cor_para_id: dict,
+ ignore_id: int,
+):
+ mask = read_mask(
+ s.mask_path,
+ cor_para_id=cor_para_id,
+ ignore_id=ignore_id,
+ )
+ inferred, present = infer_group(mask, ignore_ids, min_pixels, min_frac)
+ s.actual_group = s.source_group if trust_folder else inferred
+ s.present_ids = present
+ s.frac_chao = float(np.mean(mask == 0))
+ s.frac_cana = float(np.mean(mask == 1))
+ s.frac_erva = float(np.mean(mask == 2))
+ s.comp_cana, s.largest_cana = comp_stats(mask == 1)
+ s.comp_erva, s.largest_erva = comp_stats(mask == 2)
+ s.weed_near_cane, s.weed_touch_cane = weed_cane_relation(mask, near_frac)
+
+ img = cv2.imread(str(s.preview_path), cv2.IMREAD_COLOR)
+ if img is None:
+ raise RuntimeError(f"falha ao ler preview: {s.preview_path}")
+ hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
+ s.brightness = float(np.mean(hsv[..., 2]) / 255.0)
+ s.saturation = float(np.mean(hsv[..., 1]) / 255.0)
+ s.contrast = float(np.std(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) / 255.0)
+ s.dhash = dhash64(img)
+
+ img_small = cv2.resize(img, (64, 40), interpolation=cv2.INTER_AREA)
+ mask_small = cv2.resize(mask.astype(np.uint8), (64, 40), interpolation=cv2.INTER_NEAREST)
+ h = hashlib.sha1(); h.update(img_small.tobytes()); h.update(mask_small.tobytes())
+ s.content_hash = h.hexdigest()
+
+ low = cv2.resize(img, (8, 5), interpolation=cv2.INTER_AREA).astype(np.float32).flatten() / 255.0
+ s.feature = np.concatenate([
+ np.asarray([
+ s.frac_chao, s.frac_cana, s.frac_erva,
+ math.log1p(s.comp_cana)/6.0, math.log1p(s.comp_erva)/6.0,
+ s.largest_cana, s.largest_erva,
+ s.weed_near_cane, s.weed_touch_cane,
+ s.brightness, s.contrast, s.saturation,
+ ], np.float32),
+ grid_fraction(mask == 1),
+ grid_fraction(mask == 2),
+ hist_features(img),
+ low,
+ ]).astype(np.float32)
+ s.agri_score = agricultural_score(s)
+
+
+def hamming(a: int, b: int) -> int:
+ return (a ^ b).bit_count()
+
+
+def deduplicate(samples: Sequence[Sample], max_hamming: int, max_mask_l1: float):
+ ordered = sorted(samples, key=lambda s: (-s.agri_score, s.base))
+ kept, dups = [], []
+ exact = {}
+ for s in ordered:
+ key = (s.actual_group, s.content_hash)
+ if key in exact:
+ s.duplicate_of = exact[key].base; s.reason = "duplicate_exact"; dups.append(s); continue
+ sf = np.asarray([s.frac_chao, s.frac_cana, s.frac_erva])
+ rep = None
+ for k in kept:
+ if k.actual_group != s.actual_group or hamming(k.dhash, s.dhash) > max_hamming:
+ continue
+ kf = np.asarray([k.frac_chao, k.frac_cana, k.frac_erva])
+ if float(np.sum(np.abs(kf - sf))) <= max_mask_l1:
+ rep = k; break
+ if rep is not None:
+ s.duplicate_of = rep.base; s.reason = "duplicate_near"; dups.append(s); continue
+ kept.append(s); exact[key] = s
+ return kept, dups
+
+
+def robust_z(x: np.ndarray):
+ med = np.median(x, axis=0)
+ q25, q75 = np.percentile(x, 25, axis=0), np.percentile(x, 75, axis=0)
+ scale = q75 - q25
+ std = np.std(x, axis=0)
+ scale = np.where(scale > 1e-6, scale, std)
+ scale = np.where(scale > 1e-6, scale, 1.0)
+ return np.clip((x - med) / scale, -8, 8).astype(np.float32)
+
+
+def select_diverse(samples: Sequence[Sample], k: int, seed: int, agri_weight: float):
+ items = list(samples)
+ if k >= len(items):
+ for s in items: s.reason = s.reason or "all"; s.diversity_score = 1.0
+ return items
+
+ x = robust_z(np.stack([s.feature for s in items]))
+ agri = np.asarray([s.agri_score for s in items], np.float32)
+ rng = np.random.default_rng(seed)
+ jitter = rng.uniform(0, 1e-6, len(items)).astype(np.float32)
+
+ first = int(np.argmax(agri + jitter))
+ chosen = [first]
+ used = np.zeros(len(items), bool); used[first] = True
+ d = np.sum((x - x[first])**2, axis=1)
+
+ while len(chosen) < k:
+ dist = np.sqrt(np.maximum(d, 0))
+ priority = dist * (1.0 + agri_weight * agri)
+ priority[used] = -1
+ idx = int(np.argmax(priority + jitter))
+ chosen.append(idx); used[idx] = True
+ d = np.minimum(d, np.sum((x - x[idx])**2, axis=1))
+
+ out = []
+ for idx in chosen:
+ s = items[idx]; s.reason = "diverse_coreset"; s.diversity_score = 1.0; out.append(s)
+ return out
+
+
+def parse_group_limits(values: Sequence[str]) -> Dict[str, int]:
+ out = {}
+ for v in values:
+ if "=" not in v:
+ raise ValueError(f"--group-limit inválido: {v}; use grupo=N")
+ g, n = v.split("=", 1); out[g.strip()] = int(n)
+ return out
+
+
+def group_by_actual(samples: Sequence[Sample]):
+ out: Dict[str, List[Sample]] = {}
+ for s in samples: out.setdefault(s.actual_group, []).append(s)
+ return out
+
+
+def reference_count(by_group, copy_all, fallback):
+ vals = [len(by_group[g]) for g in copy_all if g in by_group and by_group[g]]
+ if vals: return int(round(median(vals)))
+ vals = [len(v) for v in by_group.values() if v]
+ if not vals: return 0
+ if fallback == "median": return int(round(median(vals)))
+ if fallback == "mean": return int(round(sum(vals)/len(vals)))
+ return min(vals)
+
+
+def resolve_limit(group, n, copy_all, group_limits, ref, max_ratio, target_other, max_per_class):
+ if group in group_limits: return min(n, max(0, group_limits[group]))
+ if group in set(copy_all): return n
+ lim = target_other if target_other is not None else int(round(ref * max_ratio))
+ if max_per_class is not None: lim = min(lim, max_per_class)
+ return min(n, max(0, lim))
+
+
+def dest_bin_name(base: str, src: Path):
+ parsed = parse_bin_name(src)
+ if not parsed: return src.name
+ cam = parsed[1]
+ return f"{base}_{cam.upper() if cam.upper().startswith('CAM_') else cam}.bin"
+
+
+def unique_base(dst_group: Path, base: str, ext: str):
+ def occupied(c):
+ if (dst_group/"previews"/f"{c}{ext}").exists() or (dst_group/"masks"/f"{c}.png").exists() or (dst_group/"metas"/f"{c}.json").exists(): return True
+ return any((dst_group/"bins"/f"{c}_{s}.bin").exists() for s in ("CAM_A","CAM_B","CAM_C","cam0","cam1","cam2"))
+ if not occupied(base): return base
i = 1
- while True:
- candidate = f"{base}_{i:03d}"
- if not occupied(candidate):
- return candidate
- i += 1
+ while occupied(f"{base}_{i:03d}"): i += 1
+ return f"{base}_{i:03d}"
-def copy_sample(
- sample: SampleBundle,
- dst_root: Path,
- overwrite: bool,
-) -> dict:
- dst_group_dir = dst_root / sample.group
-
- dst_previews = dst_group_dir / "previews"
- dst_masks = dst_group_dir / "masks"
- dst_metas = dst_group_dir / "metas"
- dst_bins = dst_group_dir / "bins"
-
- for d in (dst_previews, dst_masks, dst_metas, dst_bins):
- ensure_dir(d)
-
- preview_ext = sample.preview_path.suffix.lower()
-
- if overwrite:
- new_base = sample.base
- else:
- new_base = unique_base_for_dest(dst_group_dir, sample.base, preview_ext)
-
- dst_preview = dst_previews / f"{new_base}{preview_ext}"
- dst_mask = dst_masks / f"{new_base}.png"
- dst_meta = dst_metas / f"{new_base}.json"
-
- shutil.copy2(sample.preview_path, dst_preview)
- shutil.copy2(sample.mask_path, dst_mask)
- shutil.copy2(sample.meta_path, dst_meta)
-
- dst_bins_paths = []
-
- for src_bin in sample.bin_paths:
- dst_bin = dst_bins / dest_bin_name(new_base, src_bin)
- shutil.copy2(src_bin, dst_bin)
- dst_bins_paths.append(dst_bin)
-
+def copy_sample(s: Sample, dst_root: Path, overwrite: bool):
+ g = dst_root / s.actual_group
+ for d in (g/"previews", g/"masks", g/"metas", g/"bins"): ensure_dir(d)
+ ext = s.preview_path.suffix.lower()
+ base = s.base if overwrite else unique_base(g, s.base, ext)
+ dp, dm, dj = g/"previews"/f"{base}{ext}", g/"masks"/f"{base}.png", g/"metas"/f"{base}.json"
+ shutil.copy2(s.preview_path, dp); shutil.copy2(s.mask_path, dm); shutil.copy2(s.meta_path, dj)
+ dbins = []
+ for b in s.bin_paths:
+ d = g/"bins"/dest_bin_name(base, b); shutil.copy2(b, d); dbins.append(d)
return {
- "group": sample.group,
- "src_base": sample.base,
- "dst_base": new_base,
- "src_preview": str(sample.preview_path),
- "src_mask": str(sample.mask_path),
- "src_meta": str(sample.meta_path),
- "src_bins": json.dumps([str(p) for p in sample.bin_paths], ensure_ascii=False),
- "dst_preview": str(dst_preview),
- "dst_mask": str(dst_mask),
- "dst_meta": str(dst_meta),
- "dst_bins": json.dumps([str(p) for p in dst_bins_paths], ensure_ascii=False),
- "bin_count": len(dst_bins_paths),
+ "source_group": s.source_group, "actual_group": s.actual_group,
+ "src_base": s.base, "dst_base": base,
+ "src_preview": str(s.preview_path), "src_mask": str(s.mask_path), "src_meta": str(s.meta_path),
+ "src_bins": json.dumps([str(x) for x in s.bin_paths], ensure_ascii=False),
+ "dst_preview": str(dp), "dst_mask": str(dm), "dst_meta": str(dj),
+ "dst_bins": json.dumps([str(x) for x in dbins], ensure_ascii=False),
+ "frac_chao": s.frac_chao, "frac_cana": s.frac_cana, "frac_erva": s.frac_erva,
+ "agricultural_score": s.agri_score, "selection_reason": s.reason,
}
-def write_manifest(path: Path, rows: List[dict]) -> None:
+def write_csv(path: Path, rows: List[dict]):
+ if not rows: return
ensure_dir(path.parent)
-
- fieldnames = [
- "group",
- "src_base",
- "dst_base",
- "src_preview",
- "src_mask",
- "src_meta",
- "src_bins",
- "dst_preview",
- "dst_mask",
- "dst_meta",
- "dst_bins",
- "bin_count",
- ]
-
with path.open("w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=fieldnames)
- w.writeheader()
- w.writerows(rows)
+ w = csv.DictWriter(f, fieldnames=list(rows[0].keys()), extrasaction="ignore"); w.writeheader(); w.writerows(rows)
-def write_summary(path: Path, summary: dict) -> None:
- ensure_dir(path.parent)
-
- with path.open("w", encoding="utf-8") as f:
- json.dump(summary, f, ensure_ascii=False, indent=2)
-
-
-def maybe_clear_dest(dst_root: Path) -> None:
- if dst_root.exists():
- shutil.rmtree(dst_root)
- ensure_dir(dst_root)
+def audit_row(s: Sample):
+ return {
+ "source_group": s.source_group, "actual_group": s.actual_group,
+ "group_mismatch": s.source_group != s.actual_group,
+ "base": s.base, "selected": s.selected, "reason": s.reason,
+ "duplicate_of": s.duplicate_of,
+ "present_ids": ",".join(map(str, s.present_ids)),
+ "frac_chao": s.frac_chao, "frac_cana": s.frac_cana, "frac_erva": s.frac_erva,
+ "comp_cana": s.comp_cana, "comp_erva": s.comp_erva,
+ "largest_cana": s.largest_cana, "largest_erva": s.largest_erva,
+ "weed_near_cane": s.weed_near_cane, "weed_touch_cane": s.weed_touch_cane,
+ "brightness": s.brightness, "contrast": s.contrast, "saturation": s.saturation,
+ "agri_score": s.agri_score, "dhash": f"{s.dhash:016x}", "content_hash": s.content_hash,
+ "preview": str(s.preview_path), "mask": str(s.mask_path), "meta": str(s.meta_path),
+ }
def main():
- parser = argparse.ArgumentParser(
- description="Seleciona amostras prontas de dataset/brutas/group para dataset/original/group com balanceamento."
- )
-
- parser.add_argument(
- "--src-root",
- default="dataset/brutas/group",
- help="Raiz dos grupos preparados.",
- )
-
- parser.add_argument(
- "--dst-root",
- default="dataset/original/group",
- help="Raiz de saída dos grupos selecionados.",
- )
-
- parser.add_argument(
- "--copy-all",
- nargs="*",
- default=[],
- help="Classes/grupos que serão copiados integralmente.",
- )
-
- parser.add_argument(
- "--max-ratio",
- type=float,
- default=2.0,
- help="Para classes não listadas em --copy-all, copia no máximo reference_count * max_ratio.",
- )
-
- parser.add_argument(
- "--target-per-other",
- type=int,
- default=None,
- help="Se definido, ignora --max-ratio e usa este limite para classes fora de --copy-all.",
- )
-
- parser.add_argument(
- "--max-per-class",
- type=int,
- default=None,
- help="Limite absoluto por classe para classes fora de --copy-all.",
- )
-
- parser.add_argument(
- "--required-cameras",
- nargs="*",
- default=["CAM_A", "CAM_B", "CAM_C"],
- help="Câmeras obrigatórias nos bins. Use vazio para aceitar qualquer bin.",
- )
-
- parser.add_argument(
- "--seed",
- type=int,
- default=42,
- help="Seed para seleção aleatória reprodutível.",
- )
-
- parser.add_argument(
- "--fallback-mode",
- choices=["min", "median", "mean"],
- default="min",
- help="Referência caso nenhum grupo de --copy-all tenha amostras.",
- )
-
- parser.add_argument(
- "--manifest",
+ ap = argparse.ArgumentParser(description="Seleção agrícola inteligente por máscara + diversidade + relevância operacional")
+ ap.add_argument("--src-root", default="dataset/brutas/group")
+ ap.add_argument("--dst-root", default="dataset/original/group")
+ ap.add_argument(
+ "--labelmap",
default="",
- help="CSV de manifesto. Default: /selection_manifest.csv",
+ help=(
+ "Caminho do labelmap.txt. "
+ "Default: tenta /labelmap.txt a partir de --src-root."
+ ),
)
+ ap.add_argument("--copy-all", nargs="*", default=[])
+ ap.add_argument("--group-limit", nargs="*", default=[], metavar="GRUPO=N")
+ ap.add_argument("--selection-mode", choices=["diverse", "random"], default="diverse")
+ ap.add_argument("--agricultural-weight", type=float, default=0.75)
+ ap.add_argument("--max-ratio", type=float, default=2.0)
+ ap.add_argument("--target-per-other", type=int, default=None)
+ ap.add_argument("--max-per-class", type=int, default=None)
+ ap.add_argument("--required-cameras", nargs="*", default=["CAM_A","CAM_B","CAM_C"])
+ ap.add_argument("--seed", type=int, default=42)
+ ap.add_argument("--fallback-mode", choices=["min","median","mean"], default="min")
+ ap.add_argument("--trust-folder-group", action="store_true")
+ ap.add_argument("--presence-min-pixels", type=int, default=1)
+ ap.add_argument("--presence-min-fraction", type=float, default=0.0)
+ ap.add_argument("--ignore-ids", nargs="*", type=int, default=[255])
+ ap.add_argument("--near-radius-frac", type=float, default=0.02)
+ ap.add_argument("--no-dedup", action="store_true")
+ ap.add_argument("--dedup-hamming", type=int, default=3)
+ ap.add_argument("--dedup-mask-l1", type=float, default=0.03)
+ ap.add_argument("--manifest", default="")
+ ap.add_argument("--audit", default="")
+ ap.add_argument("--summary", default="")
+ ap.add_argument("--dry-run", action="store_true")
+ ap.add_argument("--clear-dst", action="store_true")
+ ap.add_argument("--overwrite", action="store_true")
+ args = ap.parse_args()
- parser.add_argument(
- "--summary",
- default="",
- help="JSON de resumo. Default: /selection_summary.json",
- )
-
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="Só mostra o plano, não copia nada.",
- )
-
- parser.add_argument(
- "--clear-dst",
- action="store_true",
- help="Apaga dst-root antes de copiar.",
- )
-
- parser.add_argument(
- "--overwrite",
- action="store_true",
- help="Sobrescreve nomes existentes no destino. Sem isso, deduplica com _001, _002...",
- )
-
- args = parser.parse_args()
-
- src_root = Path(args.src_root)
- dst_root = Path(args.dst_root)
-
- if not src_root.is_dir():
- raise SystemExit(f"[ERRO] src-root não encontrado: {src_root}")
-
+ src, dst = Path(args.src_root), Path(args.dst_root)
+ if not src.is_dir(): raise SystemExit(f"[ERRO] src-root não encontrado: {src}")
if args.clear_dst and not args.dry_run:
- maybe_clear_dest(dst_root)
+ if dst.exists(): shutil.rmtree(dst)
+ ensure_dir(dst)
- all_samples = collect_all_groups(
- src_root=src_root,
- required_cameras=args.required_cameras,
- )
+ limits = parse_group_limits(args.group_limit)
- selected = select_samples(
- all_samples=all_samples,
- copy_all=args.copy_all,
- max_ratio=args.max_ratio,
- target_per_other=args.target_per_other,
- max_per_class=args.max_per_class,
- seed=args.seed,
- fallback_mode=args.fallback_mode,
- )
+ # --------------------------------------------------------
+ # Labelmap: mesmo contrato usado pelo normalize
+ # src típico: dataset/brutas/group -> dataset/labelmap.txt
+ # --------------------------------------------------------
+ if args.labelmap:
+ labelmap_path = Path(args.labelmap)
+ else:
+ # dataset/brutas/group -> parents[1] == dataset
+ try:
+ dataset_root = src.parents[1]
+ except Exception:
+ dataset_root = Path("dataset")
- reference_count = compute_reference_count(
- all_samples=all_samples,
- copy_all=args.copy_all,
- fallback_mode=args.fallback_mode,
+ labelmap_path = dataset_root / "labelmap.txt"
+
+ if not labelmap_path.is_file():
+ fallback = Path("dataset") / "labelmap.txt"
+ if fallback.is_file():
+ labelmap_path = fallback
+
+ if not labelmap_path.is_file():
+ raise SystemExit(
+ f"[ERRO] labelmap não encontrado: {labelmap_path}\n"
+ "Passe explicitamente --labelmap caminho/labelmap.txt"
+ )
+
+ try:
+ cor_para_id, _, classes_labelmap, ignore_rgb = carregar_labelmap_completo(
+ str(labelmap_path)
+ )
+ ignore_id = int(_infer_ignore_id(ignore_rgb, 255))
+ except Exception as exc:
+ raise SystemExit(
+ f"[ERRO] Falha ao carregar labelmap {labelmap_path}: {exc}"
+ )
+
+ # O ignore detectado no labelmap entra sempre na lista permitida.
+ effective_ignore_ids = sorted(
+ set(int(x) for x in args.ignore_ids) | {ignore_id}
)
print("==============================================")
- print("Seleção de amostras para treinamento")
- print(f"Origem : {src_root}")
- print(f"Destino : {dst_root}")
- print(f"Copy all : {args.copy_all}")
- print(f"Reference : {reference_count}")
- print(f"Max ratio : {args.max_ratio}")
- print(f"Target other : {args.target_per_other}")
- print(f"Seed : {args.seed}")
- print(f"Dry-run : {args.dry_run}")
+ print("SELETOR AGRÍCOLA INTELIGENTE")
+ print(f"Origem : {src}")
+ print(f"Destino : {dst}")
+ print(f"Modo : {args.selection_mode}")
+ print(f"Copy all : {args.copy_all}")
+ print(f"Group limits : {limits}")
+ print(f"Máscara é verdade : {not args.trust_folder_group}")
+ print(f"Dedup : {not args.no_dedup}")
+ print(f"Labelmap : {labelmap_path}")
+ print(f"Ignore ID : {ignore_id}")
+ print(f"Classes labelmap : {classes_labelmap}")
print("==============================================")
- print("\nPlano por grupo:")
- for group in sorted(all_samples.keys()):
- total_valid = len(all_samples[group])
- total_selected = len(selected.get(group, []))
- mode = "ALL" if group in set(args.copy_all) else "BALANCED"
- print(f" {group:24s} validos={total_valid:5d} selecionados={total_selected:5d} modo={mode}")
+ samples = collect_samples(src, args.required_cameras)
+ print(f"[1/4] bundles válidos: {len(samples)}")
+ analyzed, rejected = [], []
+ for i, s in enumerate(samples, 1):
+ try:
+ analyze(
+ s,
+ args.trust_folder_group,
+ effective_ignore_ids,
+ args.presence_min_pixels,
+ args.presence_min_fraction,
+ args.near_radius_frac,
+ cor_para_id,
+ ignore_id,
+ )
+ analyzed.append(s)
+
+ except Exception as e:
+ error_text = str(e)
+ rejected.append({
+ "source_group": s.source_group,
+ "base": s.base,
+ "mask": str(s.mask_path),
+ "error": error_text,
+ })
+
+ # Mostra cedo os primeiros erros para não esperar milhares de samples
+ # antes de perceber um problema sistêmico.
+ if len(rejected) <= 10:
+ print(
+ f"[REJECT {len(rejected):02d}] "
+ f"{s.source_group}/{s.base}: {error_text}"
+ )
+
+ if i % 250 == 0 or i == len(samples):
+ print(
+ f" auditados={i}/{len(samples)} "
+ f"ok={len(analyzed)} rejeitados={len(rejected)}"
+ )
+
+ mismatches = [s for s in analyzed if s.source_group != s.actual_group]
+ print(
+ f"[2/4] auditados={len(analyzed)} "
+ f"mismatch_pasta_mascara={len(mismatches)} "
+ f"rejeitados={len(rejected)}"
+ )
+
+ if rejected:
+ error_counts: Dict[str, int] = {}
+ for item in rejected:
+ # Agrupa pela mensagem completa; para nosso volume já é suficiente
+ # e deixa o erro sistêmico imediatamente visível.
+ key = str(item.get("error", "erro_desconhecido"))
+ error_counts[key] = error_counts.get(key, 0) + 1
+
+ print("\nPrincipais motivos de rejeição:")
+ for error, count in sorted(
+ error_counts.items(),
+ key=lambda kv: (-kv[1], kv[0]),
+ )[:10]:
+ print(f" {count:5d}x {error}")
+
+ if not analyzed:
+ raise SystemExit(
+ "[ERRO] Nenhuma amostra passou pela auditoria. "
+ "Veja os [REJECT] acima antes de continuar."
+ )
+
+ if args.no_dedup:
+ unique, dups = analyzed, []
+ else:
+ unique, dups = deduplicate(analyzed, args.dedup_hamming, args.dedup_mask_l1)
+ print(f"[3/4] únicos={len(unique)} duplicatas/quase={len(dups)}")
+
+ by_group = group_by_actual(unique)
+ ref = reference_count(by_group, args.copy_all, args.fallback_mode)
+ selected_by_group = {}
+
+ print("\nPlano por grupo REAL (máscara):")
+ for group in sorted(by_group):
+ items = by_group[group]
+ lim = resolve_limit(group, len(items), args.copy_all, limits, ref, args.max_ratio, args.target_per_other, args.max_per_class)
+ if lim >= len(items):
+ chosen = list(items)
+ for s in chosen: s.reason = s.reason or "all"; s.diversity_score = 1.0
+ elif args.selection_mode == "diverse":
+ chosen = select_diverse(items, lim, args.seed, args.agricultural_weight)
+ else:
+ rng = random.Random(args.seed); chosen = list(items); rng.shuffle(chosen); chosen = chosen[:lim]
+ for s in chosen: s.reason = "random"
+ for s in chosen: s.selected = True
+ selected_by_group[group] = chosen
+ print(f" {group:24s} únicos={len(items):5d} selecionados={len(chosen):5d} modo={'ALL' if lim >= len(items) else args.selection_mode.upper()}")
+
+ total_selected = sum(len(v) for v in selected_by_group.values())
manifest_rows = []
if not args.dry_run:
- for group in sorted(selected.keys()):
- for sample in selected[group]:
- row = copy_sample(
- sample=sample,
- dst_root=dst_root,
- overwrite=args.overwrite,
- )
- manifest_rows.append(row)
+ for group in sorted(selected_by_group):
+ for s in selected_by_group[group]:
+ manifest_rows.append(copy_sample(s, dst, args.overwrite))
- manifest_path = Path(args.manifest) if args.manifest else dst_root / "selection_manifest.csv"
- summary_path = Path(args.summary) if args.summary else dst_root / "selection_summary.json"
+ manifest = Path(args.manifest) if args.manifest else dst / "selection_manifest.csv"
+ audit = Path(args.audit) if args.audit else dst / "selection_audit.csv"
+ summary = Path(args.summary) if args.summary else dst / "selection_summary.json"
+ write_csv(manifest, manifest_rows)
+ write_csv(audit, [audit_row(s) for s in sorted(analyzed, key=lambda x:(x.actual_group,x.source_group,x.base))])
- write_manifest(manifest_path, manifest_rows)
-
- summary = {
- "src_root": str(src_root),
- "dst_root": str(dst_root),
- "copy_all": args.copy_all,
- "max_ratio": args.max_ratio,
- "target_per_other": args.target_per_other,
- "max_per_class": args.max_per_class,
- "required_cameras": args.required_cameras,
- "seed": args.seed,
- "reference_count": reference_count,
- "groups": {
- group: {
- "valid": len(all_samples[group]),
- "selected": len(selected.get(group, [])),
- "mode": "all" if group in set(args.copy_all) else "balanced",
- }
- for group in sorted(all_samples.keys())
- },
- "total_selected": sum(len(v) for v in selected.values()),
+ data = {
+ "src_root": str(src), "dst_root": str(dst), "selection_mode": args.selection_mode,
+ "copy_all": args.copy_all, "group_limits": limits, "seed": args.seed,
+ "labelmap": str(labelmap_path),
+ "ignore_id": ignore_id,
+ "effective_ignore_ids": effective_ignore_ids,
+ "reclassify_by_mask": not args.trust_folder_group,
+ "dedup_enabled": not args.no_dedup,
+ "total_collected": len(samples), "total_analyzed": len(analyzed),
+ "total_rejected": len(rejected), "total_group_mismatches": len(mismatches),
+ "total_unique_after_dedup": len(unique), "total_duplicates": len(dups),
+ "total_selected": total_selected,
+ "groups": {g:{"unique_available":len(by_group[g]),"selected":len(selected_by_group[g])} for g in sorted(by_group)},
+ "folder_mask_mismatches": [{"base":s.base,"source_group":s.source_group,"actual_group":s.actual_group,"frac_chao":s.frac_chao,"frac_cana":s.frac_cana,"frac_erva":s.frac_erva} for s in mismatches],
+ "rejected": rejected,
}
-
- write_summary(summary_path, summary)
-
- print("\n[OK] Cópia concluída.")
- print(f"Manifest : {manifest_path}")
- print(f"Summary : {summary_path}")
-
+ ensure_dir(summary.parent)
+ summary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(f"\n[OK] Manifest: {manifest}\n[OK] Audit : {audit}\n[OK] Summary : {summary}")
else:
print("\n[DRY-RUN] Nada foi copiado.")
- print("\nResumo final:")
- print(f"Total válido : {sum(len(v) for v in all_samples.values())}")
- print(f"Total selecionado: {sum(len(v) for v in selected.values())}")
+ print("\n==============================================")
+ print(f"Válidos coletados : {len(samples)}")
+ print(f"Auditados OK : {len(analyzed)}")
+ print(f"Rejeitados : {len(rejected)}")
+ print(f"Mismatch pasta x máscara : {len(mismatches)}")
+ print(f"Duplicatas/quase : {len(dups)}")
+ print(f"Únicos após dedup : {len(unique)}")
+ print(f"Selecionados : {total_selected}")
+ print("==============================================")
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/_7_split.py b/Python/OAK/datasets/oak-fcc-3/_7_split.py
index 648a2a930..efb328082 100644
--- a/Python/OAK/datasets/oak-fcc-3/_7_split.py
+++ b/Python/OAK/datasets/oak-fcc-3/_7_split.py
@@ -1,23 +1,32 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
+
"""
-_7_split_multi_source.py
+_7_split_agri_teacher_v2.py
-Split estratificado por grupo/família para OAK-FCC-3, com suporte a múltiplas
-raízes de dataset e proteção contra vazamento de dados sintéticos/augmentados.
+Split agrícola inteligente para OAK-FCC-3 / Teacher V2.
-Por que este script existe?
----------------------------
-No fluxo atual temos, por exemplo:
+Objetivos:
+- VAL/TEST 100% reais.
+- Sintéticos/augment/copy-paste somente no TRAIN.
+- Máscara semântica é a fonte da verdade para o grupo real.
+- Proteção por família para impedir leakage de augmentações.
+- Agrupamento conservador de famílias visualmente quase idênticas.
+- Estratificação por problema agrícola, não apenas pelo nome da pasta:
+ * grupo semântico real
+ * quantidade de erva
+ * proximidade/contato cana x erva
+- Seleção DIVERSA das famílias de VAL/TEST (coreset/farthest-point),
+ para que a "prova" cubra o espaço de situações em vez de ser um random puro.
+- Descritores usam máscara + preview +, opcionalmente, tensor multiespectral
+ já normalizado/preparado pelo pipeline.
+- Gera auditoria completa para sabermos exatamente por que cada família caiu
+ em TRAIN/VAL/TEST.
- dataset/1024x640/group -> dados reais normalizados
- dataset/copypaste/group -> dados sintéticos copy/paste pós-normalização
-
-A validação precisa continuar 100% real. Então este script:
- - usa dados reais para decidir o split por família
- - manda aug/copy-paste/sintéticos somente para TRAIN
- - opcionalmente só inclui sintéticos cuja família real caiu no TRAIN
- - copia tensores, máscaras, masks auxiliares, metas, previews e visuals
+A ideia pedagógica:
+ TRAIN = sala de aula ampla e diversa.
+ VAL = prova real, representativa e cobrindo os pontos difíceis.
+ TEST = prova final opcional, também 100% real.
Estrutura esperada:
//tensors/*.npy
@@ -28,34 +37,41 @@ Estrutura esperada:
//previews/*.png
//visuals/*.png
-Uso recomendado para real + copy/paste:
----------------------------------------
-python _7_split_multi_source.py ^
- --src-roots dataset/1024x640/group,dataset/copypaste/group ^
+
+Teste
+python .\_7_split.py --train 0.85 --val 0.15 --test 0 --strategy agri_diverse --merge-near-duplicate-families --dry-run
+python .\_7_split.py --train 0.85 --val 0.15 --test 0 --strategy agri_diverse --merge-near-duplicate-families --use-tensor-features --dry-run
+
+Exemplo recomendado, somente dados reais:
+python _7_split.py --train 0.85 --val 0.15 --test 0 --strategy agri_diverse --use-tensor-features --merge-near-duplicate-families --clear-dst
+
+Real + copy/paste:
+python _7_split.py ^
+ --src-roots dataset/1280x800/group,dataset/copypaste/group ^
--train-only-roots dataset/copypaste/group ^
- --train 0.7 --val 0.3 --test 0.0 ^
+ --train 0.85 --val 0.15 --test 0 ^
+ --strategy agri_diverse ^
--synthetic-train-only ^
--synthetic-respect-family-split ^
--clear-dst
-
-Com isso:
- - dados reais vão para train/val conforme split
- - copy/paste vai apenas para train
- - copy/paste derivado de família que caiu em val/test é ignorado por padrão
"""
from __future__ import annotations
-import os
-import re
+import argparse
import csv
import json
-import shutil
+import math
+import os
import random
-import argparse
-from dataclasses import dataclass
+import re
+import shutil
+from dataclasses import dataclass, field
from pathlib import Path
-from typing import Dict, List, Optional, Tuple, Any
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
+
+import cv2
+import numpy as np
# ============================================================
@@ -72,92 +88,88 @@ RESOLUCAO = tuple(CONFIG.get("resolucao", [1024, 640]))
MULTI_HEAD = bool(CONFIG.get("multi_head", False))
TENSOR_EXT = ".npy"
-MASK_NPY_SUFFIX = ".npy"
+MASK_SUFFIX = ".npy"
AUX_MASK_DIRS = [
"masks_vegetation",
"masks_cana",
]
-COPY_OPTIONAL_DIRS = [
- "metas",
- "previews",
- "visuals",
-]
+CLASS_ID_TO_NAME = {
+ 0: "chao",
+ 1: "cana",
+ 2: "erva",
+}
-IGNORE_FILENAMES = {
- "normalize_manifest.csv",
- "copy_paste_manifest.csv",
- "copy_paste_rejected.csv",
- "copy_paste_summary.json",
- "audit_summary.json",
- "audit_health.json",
+GROUP_BY_CLASS_SET = {
+ frozenset({0}): "chao",
+ frozenset({1}): "cana",
+ frozenset({2}): "erva",
+ frozenset({0, 1}): "chao_cana",
+ frozenset({0, 2}): "chao_erva",
+ frozenset({1, 2}): "cana_erva",
+ frozenset({0, 1, 2}): "chao_cana_erva",
}
# ============================================================
-# Regex de família/origem
+# Regex origem/família
# ============================================================
RE_ORIGINAL_PREFIX = re.compile(r"^original_(.+)$", re.IGNORECASE)
-# augmented_abc_augx_00 -> family abc
RE_AUGMENTED_FAMILY = re.compile(
r"^augmented_(.+?)(?:_aug[a-zA-Z0-9]*_\d+)?$",
re.IGNORECASE,
)
-RE_AUG_SUFFIX = re.compile(r"_aug[a-zA-Z0-9]*_\d+$", re.IGNORECASE)
-# copy-paste: abc_cp_00 -> family abc
-RE_COPYPASTE_SUFFIX = re.compile(r"(.+)_cp_\d+$", re.IGNORECASE)
+RE_AUG_SUFFIX = re.compile(
+ r"_aug[a-zA-Z0-9]*_\d+$",
+ re.IGNORECASE,
+)
-# grupos copy/paste: chao_cana_copypaste -> split_group chao_cana
-RE_GROUP_COPYPASTE_SUFFIX = re.compile(r"(.+)_copypaste$", re.IGNORECASE)
+RE_COPYPASTE_SUFFIX = re.compile(
+ r"(.+)_cp_\d+$",
+ re.IGNORECASE,
+)
+
+RE_GROUP_COPYPASTE_SUFFIX = re.compile(
+ r"(.+)_copypaste$",
+ re.IGNORECASE,
+)
# ============================================================
# Utilidades
# ============================================================
-def garantir(p: str | Path) -> None:
- os.makedirs(str(p), exist_ok=True)
+def garantir(path: str | Path) -> None:
+ os.makedirs(str(path), exist_ok=True)
-def limpar_dir(p: str | Path) -> None:
- if os.path.isdir(str(p)):
- shutil.rmtree(str(p))
- garantir(p)
+def limpar_dir(path: str | Path) -> None:
+ if os.path.isdir(str(path)):
+ shutil.rmtree(str(path))
+ garantir(path)
-def norm_path(p: str | Path) -> str:
- return os.path.normcase(os.path.abspath(str(p)))
+def norm_path(path: str | Path) -> str:
+ return os.path.normcase(os.path.abspath(str(path)))
-def parse_csv_list(s: Optional[str]) -> List[str]:
- if not s:
+def parse_csv_list(value: Optional[str]) -> List[str]:
+ if not value:
return []
- return [x.strip() for x in str(s).split(",") if x.strip()]
+ return [x.strip() for x in str(value).split(",") if x.strip()]
-def parse_map(s: str, value_type=int) -> Dict[str, Any]:
- out: Dict[str, Any] = {}
- if not s:
- return out
- for item in s.split(","):
- item = item.strip()
- if not item:
- continue
- if ":" not in item:
- raise ValueError(f"Item de mapa inválido: {item}. Use grupo:valor")
- k, v = item.split(":", 1)
- out[k.strip()] = value_type(v.strip())
- return out
-
-
-def load_json_safe(path: str | Path) -> Dict[str, Any]:
+def load_json_safe(path: Optional[str | Path]) -> Dict[str, Any]:
+ if not path:
+ return {}
try:
with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
+ data = json.load(f)
+ return data if isinstance(data, dict) else {}
except Exception:
return {}
@@ -170,34 +182,36 @@ def save_json(path: str | Path, data: Dict[str, Any]) -> None:
def lista_grupos(root: str | Path) -> List[str]:
root = str(root)
+
if not os.path.isdir(root):
return []
- out = []
- for g in sorted(os.listdir(root)):
- gdir = os.path.join(root, g)
- if not os.path.isdir(gdir):
+ result = []
+
+ for group in sorted(os.listdir(root)):
+ group_dir = os.path.join(root, group)
+
+ if not os.path.isdir(group_dir):
continue
+
if (
- os.path.isdir(os.path.join(gdir, "tensors"))
- and os.path.isdir(os.path.join(gdir, "masks"))
+ os.path.isdir(os.path.join(group_dir, "tensors"))
+ and os.path.isdir(os.path.join(group_dir, "masks"))
):
- out.append(g)
- return out
+ result.append(group)
+
+ return result
def listar_tensors(tensor_dir: str | Path) -> List[str]:
if not os.path.isdir(str(tensor_dir)):
return []
- return sorted([
- f for f in os.listdir(str(tensor_dir))
+
+ return sorted(
+ f
+ for f in os.listdir(str(tensor_dir))
if f.lower().endswith(TENSOR_EXT)
- ])
-
-
-def mask_npy_from_tensor_name(tensor_name: str) -> str:
- base, _ = os.path.splitext(tensor_name)
- return base + MASK_NPY_SUFFIX
+ )
def base_no_ext(filename: str) -> str:
@@ -205,49 +219,34 @@ def base_no_ext(filename: str) -> str:
def normalize_group_for_split(group_name: str) -> str:
- """
- chao_cana_copypaste -> chao_cana
- outros ficam iguais.
- """
m = RE_GROUP_COPYPASTE_SUFFIX.match(group_name)
if m:
return m.group(1)
return group_name
-def output_group_name(source_group: str, split_group: str, is_synthetic: bool, mode: str) -> str:
- """
- mode:
- source: mantém nome original do grupo da fonte
- base: usa grupo normalizado do split
- suffix: sintético vai para _synthetic, real fica base
- """
+def output_group_name(
+ source_group: str,
+ actual_group: str,
+ is_synthetic: bool,
+ mode: str,
+) -> str:
if mode == "source":
return source_group
+
if mode == "base":
- return split_group
+ return actual_group
+
if mode == "suffix":
- if is_synthetic:
- return f"{split_group}_synthetic"
- return split_group
- return source_group
+ return f"{actual_group}_synthetic" if is_synthetic else actual_group
+
+ return actual_group
-def classify_source_and_family(filename_no_ext: str, meta: Optional[Dict[str, Any]] = None) -> Tuple[str, str, bool]:
- """
- Retorna:
- source: original | augmented | copypaste | synthetic | unknown
- family: id da família para impedir vazamento
- is_synthetic: True para aug/copy/synthetic
-
- Regras:
- original_abc -> original, abc
- augmented_abc_aug_00 -> augmented, abc
- abc_aug_00 -> augmented, abc
- abc_cp_00 -> copypaste, abc
- meta.synthetic == true -> synthetic/copypaste, tenta family pelo receiver/base
- abc -> unknown, abc, tratado como original real
- """
+def classify_source_and_family(
+ filename_no_ext: str,
+ meta: Optional[Dict[str, Any]] = None,
+) -> Tuple[str, str, bool]:
meta = meta or {}
m = RE_ORIGINAL_PREFIX.match(filename_no_ext)
@@ -259,20 +258,19 @@ def classify_source_and_family(filename_no_ext: str, meta: Optional[Dict[str, An
return "augmented", m.group(1), True
if RE_AUG_SUFFIX.search(filename_no_ext):
- fam = RE_AUG_SUFFIX.sub("", filename_no_ext)
- return "augmented", fam, True
+ family = RE_AUG_SUFFIX.sub("", filename_no_ext)
+ return "augmented", family, True
m = RE_COPYPASTE_SUFFIX.match(filename_no_ext)
if m:
return "copypaste", m.group(1), True
- # Meta do copy/paste v1/v2.
cp = meta.get("copy_paste_augmentation")
if isinstance(cp, dict):
- recv = cp.get("receiver")
- if isinstance(recv, dict):
- fam = str(recv.get("base") or filename_no_ext)
- return "copypaste", fam, True
+ receiver = cp.get("receiver")
+ if isinstance(receiver, dict):
+ family = str(receiver.get("base") or filename_no_ext)
+ return "copypaste", family, True
return "copypaste", filename_no_ext, True
if bool(meta.get("synthetic", False)):
@@ -281,6 +279,69 @@ def classify_source_and_family(filename_no_ext: str, meta: Optional[Dict[str, An
return "unknown", filename_no_ext, False
+def read_mask(path: str | Path) -> np.ndarray:
+ mask = np.load(str(path), mmap_mode="r")
+ arr = np.asarray(mask)
+
+ if arr.ndim > 2:
+ arr = np.squeeze(arr)
+
+ if arr.ndim != 2:
+ raise RuntimeError(
+ f"Máscara semântica deve ser HxW; veio {arr.shape}: {path}"
+ )
+
+ return arr
+
+
+def infer_actual_group(
+ mask: np.ndarray,
+ presence_min_pixels: int,
+ presence_min_fraction: float,
+ ignore_ids: Sequence[int],
+) -> Tuple[str, Tuple[int, ...]]:
+ ignore_set = set(int(x) for x in ignore_ids)
+
+ unique_ids = set(int(x) for x in np.unique(mask).tolist())
+
+ unknown = sorted(
+ x
+ for x in unique_ids
+ if x not in CLASS_ID_TO_NAME and x not in ignore_set
+ )
+
+ if unknown:
+ raise RuntimeError(
+ f"Máscara possui IDs desconhecidos: {unknown}"
+ )
+
+ present = []
+ n = int(mask.size)
+
+ for class_id in sorted(CLASS_ID_TO_NAME):
+ count = int(np.count_nonzero(mask == class_id))
+ frac = count / max(n, 1)
+
+ if (
+ count >= max(1, int(presence_min_pixels))
+ and frac >= float(presence_min_fraction)
+ ):
+ present.append(class_id)
+
+ group = GROUP_BY_CLASS_SET.get(frozenset(present))
+
+ if group is None:
+ raise RuntimeError(
+ f"Combinação de classes não mapeável: {present}"
+ )
+
+ return group, tuple(present)
+
+
+# ============================================================
+# Dataclasses
+# ============================================================
+
@dataclass
class Sample:
src_root: str
@@ -288,14 +349,13 @@ class Sample:
source_root_train_only: bool
source_group: str
- split_group: str
- output_group_default: str
+ split_group_folder: str
tensor_path: str
tensor_name: str
base: str
-
mask_path: str
+
source: str
family: str
is_synthetic: bool
@@ -304,26 +364,77 @@ class Sample:
preview_path: Optional[str]
visual_path: Optional[str]
+ actual_group: str = ""
+ present_ids: Tuple[int, ...] = field(default_factory=tuple)
+
+ frac_chao: float = 0.0
+ frac_cana: float = 0.0
+ frac_erva: float = 0.0
+ frac_ignore: float = 0.0
+
+ comp_cana: int = 0
+ comp_erva: int = 0
+ largest_cana_frac: float = 0.0
+ largest_erva_frac: float = 0.0
+
+ weed_near_cane_frac: float = 0.0
+ weed_contact_cane_frac: float = 0.0
+
+ brightness: float = 0.0
+ contrast: float = 0.0
+ saturation: float = 0.0
+ dhash64: int = 0
+
+ feature_vector: Optional[np.ndarray] = None
+ stratum: str = ""
+ agri_risk: float = 0.0
+ audit_error: str = ""
+
+
+@dataclass
+class FamilyUnit:
+ actual_group: str
+ family: str
+ leakage_id: str
+ samples: List[Sample]
+
+ feature_vector: np.ndarray
+ stratum: str
+ agri_risk: float
+
+ dhash64: int
+ frac_chao: float
+ frac_cana: float
+ frac_erva: float
+
+ assigned_split: Optional[str] = None
+ assignment_reason: str = ""
+
# ============================================================
-# Coleta de amostras
+# Coleta
# ============================================================
def collect_samples_from_root(
src_root: str | Path,
train_only_roots_norm: set[str],
- label: str = "",
- groups_filter: Optional[set[str]] = None,
+ label: str,
+ groups_filter: Optional[set[str]],
) -> List[Sample]:
src_root = str(src_root)
- src_root_norm = norm_path(src_root)
- root_train_only = src_root_norm in train_only_roots_norm
- root_label = label or os.path.basename(os.path.normpath(src_root)) or src_root
+ root_norm = norm_path(src_root)
+ root_train_only = root_norm in train_only_roots_norm
samples: List[Sample] = []
for group_name in lista_grupos(src_root):
- if groups_filter and group_name not in groups_filter and normalize_group_for_split(group_name) not in groups_filter:
+ split_group_folder = normalize_group_for_split(group_name)
+
+ if (
+ groups_filter
+ and group_name not in groups_filter
+ and split_group_folder not in groups_filter
+ ):
continue
group_dir = os.path.join(src_root, group_name)
@@ -333,333 +444,1532 @@ def collect_samples_from_root(
preview_dir = os.path.join(group_dir, "previews")
visual_dir = os.path.join(group_dir, "visuals")
- split_group = normalize_group_for_split(group_name)
-
for tensor_name in listar_tensors(tensor_dir):
base = base_no_ext(tensor_name)
- mask_name = mask_npy_from_tensor_name(tensor_name)
- tensor_path = os.path.join(tensor_dir, tensor_name)
- mask_path = os.path.join(mask_dir, mask_name)
- if not os.path.exists(mask_path):
+ tensor_path = os.path.join(tensor_dir, tensor_name)
+ mask_path = os.path.join(mask_dir, base + MASK_SUFFIX)
+
+ if not os.path.isfile(mask_path):
continue
meta_path = os.path.join(meta_dir, base + ".json")
- if not os.path.exists(meta_path):
+ if not os.path.isfile(meta_path):
meta_path = None
- meta = load_json_safe(meta_path) if meta_path else {}
- source, family, is_synthetic = classify_source_and_family(base, meta)
+ meta = load_json_safe(meta_path)
+ source, family, is_synthetic = classify_source_and_family(
+ base,
+ meta,
+ )
preview_path = None
for ext in (".png", ".jpg", ".jpeg"):
- cand = os.path.join(preview_dir, base + ext)
- if os.path.exists(cand):
- preview_path = cand
+ candidate = os.path.join(preview_dir, base + ext)
+ if os.path.isfile(candidate):
+ preview_path = candidate
break
visual_path = None
for ext in (".png", ".jpg", ".jpeg"):
- cand = os.path.join(visual_dir, base + "_debug" + ext)
- if os.path.exists(cand):
- visual_path = cand
- break
- cand2 = os.path.join(visual_dir, base + ext)
- if os.path.exists(cand2):
- visual_path = cand2
+ candidate = os.path.join(
+ visual_dir,
+ base + "_debug" + ext,
+ )
+ if os.path.isfile(candidate):
+ visual_path = candidate
break
- samples.append(Sample(
- src_root=src_root,
- source_root_label=root_label,
- source_root_train_only=root_train_only,
+ candidate = os.path.join(
+ visual_dir,
+ base + ext,
+ )
+ if os.path.isfile(candidate):
+ visual_path = candidate
+ break
- source_group=group_name,
- split_group=split_group,
- output_group_default=group_name,
-
- tensor_path=tensor_path,
- tensor_name=tensor_name,
- base=base,
-
- mask_path=mask_path,
- source=source,
- family=family,
- is_synthetic=is_synthetic,
-
- meta_path=meta_path,
- preview_path=preview_path,
- visual_path=visual_path,
- ))
+ samples.append(
+ Sample(
+ src_root=src_root,
+ source_root_label=label,
+ source_root_train_only=root_train_only,
+ source_group=group_name,
+ split_group_folder=split_group_folder,
+ tensor_path=tensor_path,
+ tensor_name=tensor_name,
+ base=base,
+ mask_path=mask_path,
+ source=source,
+ family=family,
+ is_synthetic=is_synthetic,
+ meta_path=meta_path,
+ preview_path=preview_path,
+ visual_path=visual_path,
+ )
+ )
return samples
def collect_all_samples(
- src_roots: List[str],
- train_only_roots: List[str],
- groups: Optional[List[str]] = None,
+ src_roots: Sequence[str],
+ train_only_roots: Sequence[str],
+ groups: Optional[Sequence[str]],
) -> List[Sample]:
- train_only_norm = {norm_path(p) for p in train_only_roots}
+ train_only_norm = {
+ norm_path(x)
+ for x in train_only_roots
+ }
+
groups_filter = set(groups) if groups else None
- all_samples: List[Sample] = []
+ result = []
+
for i, root in enumerate(src_roots):
- label = f"root{i}"
- samples = collect_samples_from_root(
- src_root=root,
- train_only_roots_norm=train_only_norm,
- label=label,
- groups_filter=groups_filter,
+ result.extend(
+ collect_samples_from_root(
+ src_root=root,
+ train_only_roots_norm=train_only_norm,
+ label=f"root{i}",
+ groups_filter=groups_filter,
+ )
)
- all_samples.extend(samples)
- return all_samples
+ return result
# ============================================================
-# Split por família real
+# Features agrícolas
# ============================================================
-def allocate_counts(n: int, p_train: float, p_val: float, p_test: float, min_train: int, min_val: int, min_test: int) -> Tuple[int, int, int]:
- n_train = int(round(n * p_train))
- n_val = int(round(n * p_val))
- n_test = n - n_train - n_val
+def connected_component_stats(binary: np.ndarray) -> Tuple[int, float]:
+ binary = np.ascontiguousarray(
+ binary.astype(np.uint8)
+ )
- if n_test < 0:
- excesso = -n_test
- take_train = min(excesso, max(0, n_train))
- n_train -= take_train
- excesso -= take_train
- if excesso > 0:
- take_val = min(excesso, max(0, n_val))
- n_val -= take_val
- excesso -= take_val
- n_test = 0
+ n, _labels, stats, _centroids = cv2.connectedComponentsWithStats(
+ binary,
+ connectivity=8,
+ )
- min_sum = min_train + min_val + min_test
+ if n <= 1:
+ return 0, 0.0
- if n >= min_sum:
- n_train = max(n_train, min_train)
- n_val = max(n_val, min_val)
- n_test = max(n_test, min_test)
+ areas = stats[1:, cv2.CC_STAT_AREA].astype(np.float64)
- total = n_train + n_val + n_test
- while total > n:
- if n_test > min_test:
- n_test -= 1
- elif n_val > min_val:
- n_val -= 1
- elif n_train > min_train:
- n_train -= 1
+ return (
+ int(len(areas)),
+ float(np.max(areas) / max(binary.size, 1)),
+ )
+
+
+def weed_cane_relation(
+ mask_small: np.ndarray,
+ near_radius_frac: float,
+) -> Tuple[float, float]:
+ cane = mask_small == 1
+ weed = mask_small == 2
+
+ if not np.any(cane) or not np.any(weed):
+ return 0.0, 0.0
+
+ h, w = mask_small.shape
+ radius = max(
+ 1,
+ int(round(min(h, w) * float(near_radius_frac))),
+ )
+
+ dist = cv2.distanceTransform(
+ (~cane).astype(np.uint8),
+ cv2.DIST_L2,
+ 3,
+ )
+
+ weed_dist = dist[weed]
+
+ near_frac = (
+ float(np.mean(weed_dist <= radius))
+ if weed_dist.size
+ else 0.0
+ )
+
+ kernel = np.ones((3, 3), np.uint8)
+ cane_dilated = cv2.dilate(
+ cane.astype(np.uint8),
+ kernel,
+ iterations=1,
+ ) > 0
+
+ weed_count = int(np.count_nonzero(weed))
+ contact = int(np.count_nonzero(weed & cane_dilated))
+
+ contact_frac = contact / max(weed_count, 1)
+
+ return float(near_frac), float(contact_frac)
+
+
+def grid_fraction(
+ binary: np.ndarray,
+ rows: int = 4,
+ cols: int = 6,
+) -> np.ndarray:
+ h, w = binary.shape
+ result = []
+
+ for gy in range(rows):
+ y0 = int(round(gy * h / rows))
+ y1 = int(round((gy + 1) * h / rows))
+
+ for gx in range(cols):
+ x0 = int(round(gx * w / cols))
+ x1 = int(round((gx + 1) * w / cols))
+
+ tile = binary[y0:y1, x0:x1]
+
+ result.append(
+ float(np.mean(tile))
+ if tile.size
+ else 0.0
+ )
+
+ return np.asarray(
+ result,
+ dtype=np.float32,
+ )
+
+
+def dhash64(img_bgr: np.ndarray) -> int:
+ gray = cv2.cvtColor(
+ img_bgr,
+ cv2.COLOR_BGR2GRAY,
+ )
+
+ small = cv2.resize(
+ gray,
+ (9, 8),
+ interpolation=cv2.INTER_AREA,
+ )
+
+ diff = small[:, 1:] > small[:, :-1]
+
+ value = 0
+ for bit in diff.flatten():
+ value = (value << 1) | int(bool(bit))
+
+ return int(value)
+
+
+def preview_features(
+ preview_path: Optional[str],
+) -> Tuple[np.ndarray, float, float, float, int]:
+ if not preview_path:
+ return (
+ np.zeros(35, dtype=np.float32),
+ 0.0,
+ 0.0,
+ 0.0,
+ 0,
+ )
+
+ img = cv2.imread(
+ preview_path,
+ cv2.IMREAD_COLOR,
+ )
+
+ if img is None:
+ return (
+ np.zeros(35, dtype=np.float32),
+ 0.0,
+ 0.0,
+ 0.0,
+ 0,
+ )
+
+ hsv = cv2.cvtColor(
+ img,
+ cv2.COLOR_BGR2HSV,
+ )
+
+ brightness = float(
+ np.mean(hsv[..., 2]) / 255.0
+ )
+
+ saturation = float(
+ np.mean(hsv[..., 1]) / 255.0
+ )
+
+ gray = cv2.cvtColor(
+ img,
+ cv2.COLOR_BGR2GRAY,
+ )
+
+ contrast = float(
+ np.std(gray) / 255.0
+ )
+
+ hist_h = cv2.calcHist(
+ [hsv],
+ [0],
+ None,
+ [12],
+ [0, 180],
+ ).flatten()
+
+ hist_s = cv2.calcHist(
+ [hsv],
+ [1],
+ None,
+ [8],
+ [0, 256],
+ ).flatten()
+
+ hist_v = cv2.calcHist(
+ [hsv],
+ [2],
+ None,
+ [8],
+ [0, 256],
+ ).flatten()
+
+ hist = np.concatenate([
+ hist_h,
+ hist_s,
+ hist_v,
+ ]).astype(np.float32)
+
+ hist_sum = float(hist.sum())
+ if hist_sum > 0:
+ hist /= hist_sum
+
+ low = cv2.resize(
+ gray,
+ (7, 1),
+ interpolation=cv2.INTER_AREA,
+ ).astype(np.float32).flatten() / 255.0
+
+ feat = np.concatenate([
+ hist,
+ low,
+ ]).astype(np.float32)
+
+ return (
+ feat,
+ brightness,
+ contrast,
+ saturation,
+ dhash64(img),
+ )
+
+
+def tensor_summary_features(
+ tensor_path: str,
+ mask: np.ndarray,
+ enabled: bool,
+ stride: int,
+) -> np.ndarray:
+ """
+ Features multiespectrais baratas.
+
+ Usa mmap + subamostragem espacial.
+ Para cada canal disponível:
+ mean, std, p10, p50, p90
+
+ Para os 5 primeiros canais físicos, acrescenta:
+ média na CANA e média na ERVA
+
+ Também calcula NDVI/NDRE aproximados a partir de:
+ R=0, RE=3, NIR=4
+ quando C>=5.
+
+ Não depende de o treino usar 5 ou 7 canais.
+ """
+ if not enabled:
+ return np.zeros(49, dtype=np.float32)
+
+ try:
+ arr = np.load(
+ tensor_path,
+ mmap_mode="r",
+ )
+
+ x = np.asarray(arr)
+
+ if x.ndim == 4 and x.shape[0] == 1:
+ x = x[0]
+
+ if x.ndim != 3:
+ raise RuntimeError(
+ f"tensor não CHW: {x.shape}"
+ )
+
+ c, h, w = x.shape
+
+ step = max(1, int(stride))
+
+ ys = np.arange(
+ 0,
+ h,
+ step,
+ dtype=np.int64,
+ )
+
+ xs = np.arange(
+ 0,
+ w,
+ step,
+ dtype=np.int64,
+ )
+
+ x_small = np.asarray(
+ x[:, ys[:, None], xs[None, :]],
+ dtype=np.float32,
+ )
+
+ mask_small = cv2.resize(
+ mask.astype(np.uint8),
+ (len(xs), len(ys)),
+ interpolation=cv2.INTER_NEAREST,
+ )
+
+ feats = []
+
+ max_channels = min(c, 7)
+
+ for ch in range(7):
+ if ch < max_channels:
+ values = x_small[ch].reshape(-1)
+ values = values[np.isfinite(values)]
+
+ if values.size:
+ feats.extend([
+ float(np.mean(values)),
+ float(np.std(values)),
+ float(np.percentile(values, 10)),
+ float(np.percentile(values, 50)),
+ float(np.percentile(values, 90)),
+ ])
+ else:
+ feats.extend([0.0] * 5)
else:
- break
- total = n_train + n_val + n_test
+ feats.extend([0.0] * 5)
- while total < n:
- if n_train - min_train <= n_val - min_val:
- n_train += 1
- else:
- n_val += 1
- total = n_train + n_val + n_test
- else:
- n_train = min(n, max(1, min_train))
- resto = n - n_train
- n_val = max(0, min(resto, min_val))
- n_test = max(0, resto - n_val)
+ # médias por classe dos 5 canais físicos
+ for class_id in (1, 2):
+ class_mask = mask_small == class_id
- diff = n - (n_train + n_val + n_test)
- if diff != 0:
- if diff > 0:
- take = min(diff, n - n_train)
- n_train += take
- diff -= take
- if diff > 0:
- n_val += diff
+ for ch in range(5):
+ if ch < c and np.any(class_mask):
+ vals = x_small[ch][class_mask]
+ vals = vals[np.isfinite(vals)]
+ feats.append(
+ float(np.mean(vals))
+ if vals.size
+ else 0.0
+ )
+ else:
+ feats.append(0.0)
+
+ # NDVI / NDRE globais do Raw5 físico.
+ if c >= 5:
+ r = x_small[0]
+ re = x_small[3]
+ nir = x_small[4]
+
+ def nd(a, b):
+ den = a + b
+ out = np.zeros_like(a, dtype=np.float32)
+ np.divide(
+ a - b,
+ den,
+ out=out,
+ where=np.abs(den) > 1e-6,
+ )
+ return np.nan_to_num(
+ out,
+ nan=0.0,
+ posinf=1.0,
+ neginf=-1.0,
+ )
+
+ ndvi = nd(nir, r)
+ ndre = nd(nir, re)
+
+ feats.extend([
+ float(np.mean(ndvi)),
+ float(np.std(ndvi)),
+ float(np.mean(ndre)),
+ float(np.std(ndre)),
+ ])
else:
- diff = -diff
- take = min(diff, n_test)
- n_test -= take
- diff -= take
- if diff > 0:
- n_val -= diff
+ feats.extend([0.0] * 4)
- return n_train, n_val, n_test
+ # 35 + 10 + 4 = 49
+ return np.asarray(
+ feats,
+ dtype=np.float32,
+ )
+
+ except Exception:
+ return np.zeros(
+ 49,
+ dtype=np.float32,
+ )
-def build_family_split(
- samples: List[Sample],
+def target_size_bin(
+ frac_erva: float,
+ tiny: float,
+ small: float,
+ medium: float,
+) -> str:
+ if frac_erva <= 0.0:
+ return "none"
+ if frac_erva < tiny:
+ return "tiny"
+ if frac_erva < small:
+ return "small"
+ if frac_erva < medium:
+ return "medium"
+ return "large"
+
+
+def compute_agri_risk(
+ frac_cana: float,
+ frac_erva: float,
+ weed_near_cane_frac: float,
+ weed_contact_cane_frac: float,
+) -> float:
+ """
+ Risco agrícola relativo.
+
+ Não é score de qualidade.
+ É somente uma forma de marcar casos que são "prova difícil":
+ - cana + erva simultâneas;
+ - erva perto/encostada na cana;
+ - target pequeno em presença de cana.
+ """
+ has_cana = frac_cana > 0.0
+ has_erva = frac_erva > 0.0
+
+ score = 0.0
+
+ if has_cana and has_erva:
+ score += 0.35
+
+ score += 0.30 * float(weed_near_cane_frac)
+ score += 0.20 * float(weed_contact_cane_frac)
+
+ if has_cana and has_erva and frac_erva < 0.02:
+ score += 0.15
+
+ return float(
+ max(0.0, min(score, 1.0))
+ )
+
+
+def analyze_sample(
+ sample: Sample,
+ args,
+) -> None:
+ mask = read_mask(
+ sample.mask_path
+ )
+
+ actual_group, present_ids = infer_actual_group(
+ mask=mask,
+ presence_min_pixels=args.presence_min_pixels,
+ presence_min_fraction=args.presence_min_fraction,
+ ignore_ids=args.ignore_ids,
+ )
+
+ sample.actual_group = actual_group
+ sample.present_ids = present_ids
+
+ sample.frac_chao = float(
+ np.mean(mask == 0)
+ )
+ sample.frac_cana = float(
+ np.mean(mask == 1)
+ )
+ sample.frac_erva = float(
+ np.mean(mask == 2)
+ )
+
+ sample.frac_ignore = float(
+ sum(
+ np.mean(mask == int(i))
+ for i in args.ignore_ids
+ )
+ )
+
+ # Para morfologia, reduz bastante. A feature é relativa, não pixel-exata.
+ small_w = min(320, mask.shape[1])
+ small_h = max(
+ 1,
+ int(round(
+ mask.shape[0]
+ * small_w
+ / max(mask.shape[1], 1)
+ )),
+ )
+
+ mask_small = cv2.resize(
+ mask.astype(np.uint8),
+ (small_w, small_h),
+ interpolation=cv2.INTER_NEAREST,
+ )
+
+ (
+ sample.comp_cana,
+ sample.largest_cana_frac,
+ ) = connected_component_stats(
+ mask_small == 1
+ )
+
+ (
+ sample.comp_erva,
+ sample.largest_erva_frac,
+ ) = connected_component_stats(
+ mask_small == 2
+ )
+
+ (
+ sample.weed_near_cane_frac,
+ sample.weed_contact_cane_frac,
+ ) = weed_cane_relation(
+ mask_small,
+ args.near_radius_frac,
+ )
+
+ (
+ visual_feat,
+ sample.brightness,
+ sample.contrast,
+ sample.saturation,
+ sample.dhash64,
+ ) = preview_features(
+ sample.preview_path
+ )
+
+ tensor_feat = tensor_summary_features(
+ tensor_path=sample.tensor_path,
+ mask=mask,
+ enabled=bool(args.use_tensor_features),
+ stride=args.tensor_feature_stride,
+ )
+
+ cana_grid = grid_fraction(
+ mask_small == 1,
+ )
+
+ erva_grid = grid_fraction(
+ mask_small == 2,
+ )
+
+ size_bin = target_size_bin(
+ sample.frac_erva,
+ args.target_tiny_frac,
+ args.target_small_frac,
+ args.target_medium_frac,
+ )
+
+ if (
+ sample.frac_cana > 0
+ and sample.frac_erva > 0
+ and sample.weed_near_cane_frac >= args.close_weed_cane_threshold
+ ):
+ proximity_bin = "close"
+ elif sample.frac_cana > 0 and sample.frac_erva > 0:
+ proximity_bin = "separate"
+ else:
+ proximity_bin = "na"
+
+ sample.stratum = (
+ f"{sample.actual_group}"
+ f"|target={size_bin}"
+ f"|caneweed={proximity_bin}"
+ )
+
+ sample.agri_risk = compute_agri_risk(
+ frac_cana=sample.frac_cana,
+ frac_erva=sample.frac_erva,
+ weed_near_cane_frac=sample.weed_near_cane_frac,
+ weed_contact_cane_frac=sample.weed_contact_cane_frac,
+ )
+
+ scalar_feat = np.asarray([
+ sample.frac_chao,
+ sample.frac_cana,
+ sample.frac_erva,
+ sample.frac_ignore,
+ math.log1p(sample.comp_cana) / 6.0,
+ math.log1p(sample.comp_erva) / 6.0,
+ sample.largest_cana_frac,
+ sample.largest_erva_frac,
+ sample.weed_near_cane_frac,
+ sample.weed_contact_cane_frac,
+ sample.brightness,
+ sample.contrast,
+ sample.saturation,
+ sample.agri_risk,
+ ], dtype=np.float32)
+
+ sample.feature_vector = np.concatenate([
+ scalar_feat,
+ cana_grid,
+ erva_grid,
+ visual_feat,
+ tensor_feat,
+ ]).astype(np.float32)
+
+
+# ============================================================
+# Família / near-duplicate leakage
+# ============================================================
+
+def hamming64(a: int, b: int) -> int:
+ return int(
+ (int(a) ^ int(b)).bit_count()
+ )
+
+
+class UnionFind:
+ def __init__(self, n: int):
+ self.parent = list(range(n))
+ self.rank = [0] * n
+
+ def find(self, x: int) -> int:
+ while self.parent[x] != x:
+ self.parent[x] = self.parent[
+ self.parent[x]
+ ]
+ x = self.parent[x]
+ return x
+
+ def union(self, a: int, b: int) -> None:
+ ra = self.find(a)
+ rb = self.find(b)
+
+ if ra == rb:
+ return
+
+ if self.rank[ra] < self.rank[rb]:
+ ra, rb = rb, ra
+
+ self.parent[rb] = ra
+
+ if self.rank[ra] == self.rank[rb]:
+ self.rank[ra] += 1
+
+
+def build_real_family_units(
+ samples: Sequence[Sample],
+) -> List[FamilyUnit]:
+ grouped: Dict[Tuple[str, str], List[Sample]] = {}
+
+ for s in samples:
+ if s.is_synthetic:
+ continue
+ if s.source_root_train_only:
+ continue
+
+ grouped.setdefault(
+ (s.actual_group, s.family),
+ [],
+ ).append(s)
+
+ units = []
+
+ for (group, family), members in grouped.items():
+ vectors = np.stack([
+ s.feature_vector
+ for s in members
+ ], axis=0)
+
+ representative = max(
+ members,
+ key=lambda s: s.agri_risk,
+ )
+
+ stratum_counts: Dict[str, int] = {}
+ for s in members:
+ stratum_counts[s.stratum] = (
+ stratum_counts.get(s.stratum, 0) + 1
+ )
+
+ stratum = max(
+ stratum_counts.items(),
+ key=lambda kv: (kv[1], kv[0]),
+ )[0]
+
+ units.append(
+ FamilyUnit(
+ actual_group=group,
+ family=family,
+ leakage_id=family,
+ samples=list(members),
+ feature_vector=np.mean(
+ vectors,
+ axis=0,
+ ).astype(np.float32),
+ stratum=stratum,
+ agri_risk=float(
+ max(s.agri_risk for s in members)
+ ),
+ dhash64=representative.dhash64,
+ frac_chao=float(
+ np.mean([s.frac_chao for s in members])
+ ),
+ frac_cana=float(
+ np.mean([s.frac_cana for s in members])
+ ),
+ frac_erva=float(
+ np.mean([s.frac_erva for s in members])
+ ),
+ )
+ )
+
+ return units
+
+
+def merge_near_duplicate_family_units(
+ units: Sequence[FamilyUnit],
+ enabled: bool,
+ max_hamming: int,
+ mask_l1: float,
+) -> Tuple[List[FamilyUnit], Dict[Tuple[str, str], str], int]:
+ """
+ Une famílias reais muito semelhantes para que não possam cair uma no
+ TRAIN e outra no VAL.
+
+ É propositalmente conservador.
+ Só compara famílias do mesmo grupo real.
+ """
+ units = list(units)
+
+ if not enabled or len(units) <= 1:
+ mapping = {
+ (u.actual_group, u.family): u.leakage_id
+ for u in units
+ }
+ return units, mapping, 0
+
+ result_units = []
+ family_to_leakage: Dict[Tuple[str, str], str] = {}
+ merged_count = 0
+
+ by_group: Dict[str, List[FamilyUnit]] = {}
+
+ for u in units:
+ by_group.setdefault(
+ u.actual_group,
+ [],
+ ).append(u)
+
+ for group, group_units in by_group.items():
+ n = len(group_units)
+ uf = UnionFind(n)
+
+ # bucket conservador por frações de classes para evitar O(n²) total
+ buckets: Dict[Tuple[int, int, int], List[int]] = {}
+
+ for i, u in enumerate(group_units):
+ key = (
+ int(round(u.frac_chao / 0.02)),
+ int(round(u.frac_cana / 0.02)),
+ int(round(u.frac_erva / 0.02)),
+ )
+ buckets.setdefault(key, []).append(i)
+
+ for key, indices in buckets.items():
+ # compara bucket e vizinhos imediatos de composição
+ candidate_indices = []
+
+ a0, a1, a2 = key
+
+ for d0 in (-1, 0, 1):
+ for d1 in (-1, 0, 1):
+ for d2 in (-1, 0, 1):
+ candidate_indices.extend(
+ buckets.get(
+ (a0 + d0, a1 + d1, a2 + d2),
+ [],
+ )
+ )
+
+ candidate_indices = sorted(
+ set(candidate_indices)
+ )
+
+ for i in indices:
+ ui = group_units[i]
+
+ for j in candidate_indices:
+ if j <= i:
+ continue
+
+ uj = group_units[j]
+
+ if (
+ hamming64(
+ ui.dhash64,
+ uj.dhash64,
+ )
+ > int(max_hamming)
+ ):
+ continue
+
+ l1 = (
+ abs(ui.frac_chao - uj.frac_chao)
+ + abs(ui.frac_cana - uj.frac_cana)
+ + abs(ui.frac_erva - uj.frac_erva)
+ )
+
+ if l1 > float(mask_l1):
+ continue
+
+ uf.union(i, j)
+
+ clusters: Dict[int, List[FamilyUnit]] = {}
+
+ for i, u in enumerate(group_units):
+ clusters.setdefault(
+ uf.find(i),
+ [],
+ ).append(u)
+
+ for cluster_index, members in enumerate(clusters.values()):
+ if len(members) > 1:
+ merged_count += len(members) - 1
+
+ leakage_id = (
+ members[0].family
+ if len(members) == 1
+ else f"near_{group}_{cluster_index:05d}"
+ )
+
+ all_samples = []
+ for m in members:
+ all_samples.extend(
+ m.samples
+ )
+ family_to_leakage[
+ (group, m.family)
+ ] = leakage_id
+
+ vectors = np.stack([
+ m.feature_vector
+ for m in members
+ ], axis=0)
+
+ hardest = max(
+ members,
+ key=lambda m: m.agri_risk,
+ )
+
+ stratum_counts: Dict[str, int] = {}
+ for m in members:
+ stratum_counts[m.stratum] = (
+ stratum_counts.get(m.stratum, 0) + 1
+ )
+
+ stratum = max(
+ stratum_counts.items(),
+ key=lambda kv: (kv[1], kv[0]),
+ )[0]
+
+ result_units.append(
+ FamilyUnit(
+ actual_group=group,
+ family=members[0].family,
+ leakage_id=leakage_id,
+ samples=all_samples,
+ feature_vector=np.mean(
+ vectors,
+ axis=0,
+ ).astype(np.float32),
+ stratum=stratum,
+ agri_risk=float(
+ max(m.agri_risk for m in members)
+ ),
+ dhash64=hardest.dhash64,
+ frac_chao=float(
+ np.mean([m.frac_chao for m in members])
+ ),
+ frac_cana=float(
+ np.mean([m.frac_cana for m in members])
+ ),
+ frac_erva=float(
+ np.mean([m.frac_erva for m in members])
+ ),
+ )
+ )
+
+ return (
+ result_units,
+ family_to_leakage,
+ merged_count,
+ )
+
+
+# ============================================================
+# Seleção diversa de VAL/TEST
+# ============================================================
+
+def robust_standardize(
+ features: np.ndarray,
+) -> np.ndarray:
+ x = np.asarray(
+ features,
+ dtype=np.float32,
+ )
+
+ med = np.median(
+ x,
+ axis=0,
+ )
+
+ q25 = np.percentile(
+ x,
+ 25,
+ axis=0,
+ )
+
+ q75 = np.percentile(
+ x,
+ 75,
+ axis=0,
+ )
+
+ scale = q75 - q25
+ std = np.std(
+ x,
+ axis=0,
+ )
+
+ scale = np.where(
+ scale > 1e-6,
+ scale,
+ std,
+ )
+
+ scale = np.where(
+ scale > 1e-6,
+ scale,
+ 1.0,
+ )
+
+ z = (x - med) / scale
+
+ return np.clip(
+ z,
+ -8.0,
+ 8.0,
+ ).astype(np.float32)
+
+
+def diverse_order(
+ units: Sequence[FamilyUnit],
+ seed: int,
+) -> List[int]:
+ """
+ Ordem k-center:
+ começa pela unidade mais próxima do centro do estrato
+ e depois vai pegando a mais distante do conjunto já coberto.
+
+ Isso produz uma prova representativa + ampla, não somente casos extremos.
+ """
+ units = list(units)
+
+ if len(units) <= 1:
+ return list(range(len(units)))
+
+ x = np.stack([
+ u.feature_vector
+ for u in units
+ ], axis=0)
+
+ z = robust_standardize(x)
+
+ centroid = np.mean(
+ z,
+ axis=0,
+ )
+
+ dist_center = np.sum(
+ (z - centroid) ** 2,
+ axis=1,
+ )
+
+ rng = np.random.default_rng(
+ int(seed)
+ )
+
+ jitter = rng.uniform(
+ 0.0,
+ 1e-8,
+ size=len(units),
+ )
+
+ first = int(
+ np.argmin(
+ dist_center + jitter
+ )
+ )
+
+ order = [first]
+
+ selected = np.zeros(
+ len(units),
+ dtype=bool,
+ )
+ selected[first] = True
+
+ delta = z - z[first]
+
+ min_dist_sq = np.sum(
+ delta * delta,
+ axis=1,
+ )
+
+ min_dist_sq[first] = 0.0
+
+ while len(order) < len(units):
+ score = min_dist_sq.copy()
+ score[selected] = -1.0
+
+ idx = int(
+ np.argmax(
+ score + jitter
+ )
+ )
+
+ order.append(idx)
+ selected[idx] = True
+
+ delta = z - z[idx]
+
+ dist_sq = np.sum(
+ delta * delta,
+ axis=1,
+ )
+
+ min_dist_sq = np.minimum(
+ min_dist_sq,
+ dist_sq,
+ )
+
+ return order
+
+
+def allocate_stratum_counts(
+ n: int,
+ p_train: float,
+ p_val: float,
+ p_test: float,
+) -> Tuple[int, int, int]:
+ if n <= 0:
+ return 0, 0, 0
+
+ n_val = int(round(
+ n * p_val
+ ))
+
+ n_test = int(round(
+ n * p_test
+ ))
+
+ # Garante pelo menos uma família de val em estratos minimamente grandes.
+ if p_val > 0 and n >= 5:
+ n_val = max(
+ 1,
+ n_val,
+ )
+
+ if p_test > 0 and n >= 10:
+ n_test = max(
+ 1,
+ n_test,
+ )
+
+ while n_val + n_test >= n:
+ if n_test > 0:
+ n_test -= 1
+ elif n_val > 0:
+ n_val -= 1
+ else:
+ break
+
+ n_train = n - n_val - n_test
+
+ return (
+ n_train,
+ n_val,
+ n_test,
+ )
+
+
+def assign_agri_diverse_split(
+ units: Sequence[FamilyUnit],
p_train: float,
p_val: float,
p_test: float,
seed: int,
- mins: Dict[str, int],
- caps_map: Optional[Dict[str, int]] = None,
-) -> Tuple[Dict[str, Dict[str, str]], Dict[str, Dict[str, int]]]:
+) -> Dict[Tuple[str, str], str]:
"""
- Usa apenas amostras reais/originais para decidir a partição das famílias.
+ Estratifica por "situação agrícola" e escolhe VAL/TEST por diversidade.
- Retorna:
- family_split[split_group][family] = train|val|test
- group_summary[split_group] = contagens de famílias
+ Dentro de cada estrato:
+ - escolhe representantes diversos para VAL;
+ - escolhe representantes ainda não usados para TEST;
+ - restante vai para TRAIN.
"""
- caps_map = caps_map or {}
+ strata: Dict[str, List[FamilyUnit]] = {}
- families_by_group: Dict[str, set[str]] = {}
+ for u in units:
+ strata.setdefault(
+ u.stratum,
+ [],
+ ).append(u)
- for s in samples:
- if s.source_root_train_only:
- continue
- if s.is_synthetic:
- continue
- if s.source in ("augmented", "copypaste", "synthetic"):
- continue
+ assignment: Dict[Tuple[str, str], str] = {}
- families_by_group.setdefault(s.split_group, set()).add(s.family)
+ for stratum_index, stratum in enumerate(sorted(strata)):
+ members = strata[stratum]
- family_split: Dict[str, Dict[str, str]] = {}
- group_summary: Dict[str, Dict[str, int]] = {}
-
- for group_name in sorted(families_by_group.keys()):
- fams = sorted(families_by_group[group_name])
- rng = random.Random(seed)
- rng.shuffle(fams)
-
- total_familias = len(fams)
- if total_familias == 0:
- group_summary[group_name] = {"familias": 0, "train_families": 0, "val_families": 0, "test_families": 0}
- continue
-
- n_tr, n_va, n_te = allocate_counts(
- total_familias,
+ n_train, n_val, n_test = allocate_stratum_counts(
+ len(members),
p_train,
p_val,
p_test,
- mins["train"],
- mins["val"],
- mins["test"],
)
- fam_train = set(fams[:n_tr])
- fam_val = set(fams[n_tr:n_tr + n_va])
- fam_test = set(fams[n_tr + n_va:n_tr + n_va + n_te])
+ order = diverse_order(
+ members,
+ seed=seed + stratum_index * 9973,
+ )
- if group_name in caps_map:
- cap = int(caps_map[group_name])
- if len(fam_train) > cap:
- fam_list = list(fam_train)
- rng.shuffle(fam_list)
- kept = set(fam_list[:cap])
- dropped = set(fam_list[cap:])
- fam_train = kept
- print(
- f"[{group_name}] cap-train-families={cap} → "
- f"mantidas {len(kept)} famílias, descartadas {len(dropped)} do TRAIN"
+ # Distribui as posições mais representativas/diversas para a prova.
+ val_idx = set(
+ order[:n_val]
+ )
+
+ test_idx = set(
+ order[n_val:n_val + n_test]
+ )
+
+ for i, unit in enumerate(members):
+ if i in val_idx:
+ split = "val"
+ reason = "agri_diverse_val"
+ elif i in test_idx:
+ split = "test"
+ reason = "agri_diverse_test"
+ else:
+ split = "train"
+ reason = "agri_diverse_train"
+
+ unit.assigned_split = split
+ unit.assignment_reason = reason
+
+ assignment[
+ (unit.actual_group, unit.leakage_id)
+ ] = split
+
+ return assignment
+
+
+def assign_random_split(
+ units: Sequence[FamilyUnit],
+ p_train: float,
+ p_val: float,
+ p_test: float,
+ seed: int,
+) -> Dict[Tuple[str, str], str]:
+ by_group: Dict[str, List[FamilyUnit]] = {}
+
+ for u in units:
+ by_group.setdefault(
+ u.actual_group,
+ [],
+ ).append(u)
+
+ assignment = {}
+
+ for group_index, group in enumerate(sorted(by_group)):
+ members = list(
+ by_group[group]
+ )
+
+ rng = random.Random(
+ seed + group_index * 1009
+ )
+ rng.shuffle(members)
+
+ n = len(members)
+
+ n_train = int(round(
+ n * p_train
+ ))
+
+ n_val = int(round(
+ n * p_val
+ ))
+
+ n_test = (
+ n
+ - n_train
+ - n_val
+ )
+
+ if n_test < 0:
+ n_test = 0
+ n_train = max(
+ 0,
+ n - n_val,
+ )
+
+ for i, unit in enumerate(members):
+ if i < n_train:
+ split = "train"
+ elif i < n_train + n_val:
+ split = "val"
+ else:
+ split = "test"
+
+ unit.assigned_split = split
+ unit.assignment_reason = "random_group"
+
+ assignment[
+ (unit.actual_group, unit.leakage_id)
+ ] = split
+
+ return assignment
+
+
+# ============================================================
+# Aplicar split aos samples
+# ============================================================
+
+def decide_sample_split(
+ sample: Sample,
+ family_to_leakage: Dict[Tuple[str, str], str],
+ assignment: Dict[Tuple[str, str], str],
+ args,
+) -> Tuple[Optional[str], str]:
+ # Para dados reais, a máscara define o grupo/família de leakage.
+ # Para sintéticos/copy-paste, a família deve respeitar o split da
+ # FAMÍLIA REAL DE ORIGEM/receiver. O grupo sintético pode mudar depois
+ # do copy-paste (ex.: chao_cana -> chao_cana_erva), então usar
+ # actual_group aqui faria o filho perder o vínculo com o pai real.
+ family_lookup_group = (
+ sample.actual_group
+ if not sample.is_synthetic
+ else normalize_group_for_split(sample.source_group)
+ )
+
+ leakage_id = family_to_leakage.get(
+ (family_lookup_group, sample.family),
+ sample.family,
+ )
+
+ assigned = assignment.get(
+ (family_lookup_group, leakage_id)
+ )
+
+ is_train_only_candidate = (
+ sample.source_root_train_only
+ or (
+ bool(args.synthetic_train_only)
+ and sample.is_synthetic
+ )
+ or sample.source in (
+ "augmented",
+ "copypaste",
+ "synthetic",
+ )
+ )
+
+ if is_train_only_candidate:
+ if bool(args.synthetic_respect_family_split):
+ if assigned is None:
+ if bool(args.allow_orphan_synthetic_train):
+ return (
+ "train",
+ "train_only_orphan_allowed",
+ )
+
+ return (
+ None,
+ "skip_train_only_orphan_no_real_family",
)
- group_map: Dict[str, str] = {}
- for f in fam_train:
- group_map[f] = "train"
- for f in fam_val:
- group_map[f] = "val"
- for f in fam_test:
- group_map[f] = "test"
+ if assigned != "train":
+ return (
+ None,
+ f"skip_train_only_family_assigned_{assigned}",
+ )
- family_split[group_name] = group_map
- group_summary[group_name] = {
- "familias": total_familias,
- "train_families": len(fam_train),
- "val_families": len(fam_val),
- "test_families": len(fam_test),
- }
+ return (
+ "train",
+ "train_only_family_train",
+ )
- return family_split, group_summary
+ return (
+ "train",
+ "train_only_forced",
+ )
+
+ if assigned is None:
+ return (
+ None,
+ "skip_real_no_family_assignment",
+ )
+
+ return (
+ assigned,
+ f"real_assigned_{assigned}",
+ )
# ============================================================
# Cópia
# ============================================================
-def copy_optional_file(src_path: Optional[str], dst_dir: str, dst_base: str, suffix: str = "") -> Optional[str]:
- if not src_path or not os.path.exists(src_path):
- return None
-
- garantir(dst_dir)
- ext = os.path.splitext(src_path)[1]
- dst = os.path.join(dst_dir, dst_base + suffix + ext)
- shutil.copy2(src_path, dst)
- return dst
-
-
-def copy_named_optional(src_dir: str, dst_dir: str, base: str, ext: str) -> Optional[str]:
- src = os.path.join(src_dir, base + ext)
- if not os.path.exists(src):
- return None
- garantir(dst_dir)
- dst = os.path.join(dst_dir, base + ext)
- shutil.copy2(src, dst)
- return dst
-
-
def copiar_sample(
- s: Sample,
+ sample: Sample,
dst_root: str,
split_name: str,
out_group_name: str,
- copy_meta_preview: bool = True,
- copy_visuals: bool = True,
+ copy_meta_preview: bool,
+ copy_visuals: bool,
) -> Optional[Dict[str, Any]]:
- dst_group_dir = os.path.join(dst_root, split_name, "group", out_group_name)
+ dst_group_dir = os.path.join(
+ dst_root,
+ split_name,
+ "group",
+ out_group_name,
+ )
- dst_tensor_dir = os.path.join(dst_group_dir, "tensors")
- dst_mask_dir = os.path.join(dst_group_dir, "masks")
- dst_meta_dir = os.path.join(dst_group_dir, "metas")
- dst_preview_dir = os.path.join(dst_group_dir, "previews")
- dst_visual_dir = os.path.join(dst_group_dir, "visuals")
+ dst_tensor_dir = os.path.join(
+ dst_group_dir,
+ "tensors",
+ )
- garantir(dst_tensor_dir)
- garantir(dst_mask_dir)
+ dst_mask_dir = os.path.join(
+ dst_group_dir,
+ "masks",
+ )
- tensor_dst = os.path.join(dst_tensor_dir, s.tensor_name)
- mask_dst = os.path.join(dst_mask_dir, os.path.basename(s.mask_path))
+ garantir(
+ dst_tensor_dir
+ )
+ garantir(
+ dst_mask_dir
+ )
- if not (os.path.exists(s.tensor_path) and os.path.exists(s.mask_path)):
+ if not (
+ os.path.isfile(sample.tensor_path)
+ and os.path.isfile(sample.mask_path)
+ ):
return None
- shutil.copy2(s.tensor_path, tensor_dst)
- shutil.copy2(s.mask_path, mask_dst)
+ tensor_dst = os.path.join(
+ dst_tensor_dir,
+ sample.tensor_name,
+ )
- base = s.base
+ mask_dst = os.path.join(
+ dst_mask_dir,
+ os.path.basename(sample.mask_path),
+ )
+
+ shutil.copy2(
+ sample.tensor_path,
+ tensor_dst,
+ )
+
+ shutil.copy2(
+ sample.mask_path,
+ mask_dst,
+ )
+
+ base = sample.base
+
+ mask_png_src = os.path.join(
+ os.path.dirname(sample.mask_path),
+ base + ".png",
+ )
- # mask debug PNG opcional
- mask_png_src = os.path.join(os.path.dirname(s.mask_path), base + ".png")
mask_png_dst = None
- if os.path.exists(mask_png_src):
- mask_png_dst = os.path.join(dst_mask_dir, base + ".png")
- shutil.copy2(mask_png_src, mask_png_dst)
- # aux masks
+ if os.path.isfile(mask_png_src):
+ mask_png_dst = os.path.join(
+ dst_mask_dir,
+ base + ".png",
+ )
+
+ shutil.copy2(
+ mask_png_src,
+ mask_png_dst,
+ )
+
aux_masks: Dict[str, Dict[str, Optional[str]]] = {}
- src_group_dir = os.path.join(s.src_root, s.source_group)
+
+ src_group_dir = os.path.join(
+ sample.src_root,
+ sample.source_group,
+ )
for aux_dir in AUX_MASK_DIRS:
- aux_src_dir = os.path.join(src_group_dir, aux_dir)
- aux_dst_dir = os.path.join(dst_group_dir, aux_dir)
+ aux_src_dir = os.path.join(
+ src_group_dir,
+ aux_dir,
+ )
- aux_npy_src = os.path.join(aux_src_dir, base + ".npy")
- aux_png_src = os.path.join(aux_src_dir, base + ".png")
+ aux_dst_dir = os.path.join(
+ dst_group_dir,
+ aux_dir,
+ )
+
+ aux_npy_src = os.path.join(
+ aux_src_dir,
+ base + ".npy",
+ )
+
+ aux_png_src = os.path.join(
+ aux_src_dir,
+ base + ".png",
+ )
aux_npy_dst = None
aux_png_dst = None
- if os.path.exists(aux_npy_src):
- garantir(aux_dst_dir)
- aux_npy_dst = os.path.join(aux_dst_dir, base + ".npy")
- shutil.copy2(aux_npy_src, aux_npy_dst)
+ if os.path.isfile(aux_npy_src):
+ garantir(
+ aux_dst_dir
+ )
- if os.path.exists(aux_png_src):
- aux_png_dst = os.path.join(aux_dst_dir, base + ".png")
- shutil.copy2(aux_png_src, aux_png_dst)
+ aux_npy_dst = os.path.join(
+ aux_dst_dir,
+ base + ".npy",
+ )
+
+ shutil.copy2(
+ aux_npy_src,
+ aux_npy_dst,
+ )
+
+ if os.path.isfile(aux_png_src):
+ aux_png_dst = os.path.join(
+ aux_dst_dir,
+ base + ".png",
+ )
+
+ shutil.copy2(
+ aux_png_src,
+ aux_png_dst,
+ )
aux_masks[aux_dir] = {
"npy": aux_npy_dst,
@@ -668,8 +1978,8 @@ def copiar_sample(
if MULTI_HEAD and aux_npy_dst is None:
raise RuntimeError(
- f"multi_head=true, mas máscara auxiliar ausente: "
- f"{aux_dir}/{base}.npy em {src_group_dir}"
+ f"multi_head=true e máscara auxiliar ausente: "
+ f"{aux_dir}/{base}.npy"
)
meta_dst = None
@@ -677,146 +1987,215 @@ def copiar_sample(
visual_dst = None
if copy_meta_preview:
- if s.meta_path and os.path.exists(s.meta_path):
- garantir(dst_meta_dir)
- meta_dst = os.path.join(dst_meta_dir, base + ".json")
- shutil.copy2(s.meta_path, meta_dst)
+ if (
+ sample.meta_path
+ and os.path.isfile(sample.meta_path)
+ ):
+ dst_meta_dir = os.path.join(
+ dst_group_dir,
+ "metas",
+ )
- if s.preview_path and os.path.exists(s.preview_path):
- garantir(dst_preview_dir)
- preview_dst = os.path.join(dst_preview_dir, base + os.path.splitext(s.preview_path)[1])
- shutil.copy2(s.preview_path, preview_dst)
+ garantir(
+ dst_meta_dir
+ )
- if copy_visuals and s.visual_path and os.path.exists(s.visual_path):
- garantir(dst_visual_dir)
- visual_dst = os.path.join(dst_visual_dir, os.path.basename(s.visual_path))
- shutil.copy2(s.visual_path, visual_dst)
+ meta_dst = os.path.join(
+ dst_meta_dir,
+ base + ".json",
+ )
+
+ shutil.copy2(
+ sample.meta_path,
+ meta_dst,
+ )
+
+ if (
+ sample.preview_path
+ and os.path.isfile(sample.preview_path)
+ ):
+ dst_preview_dir = os.path.join(
+ dst_group_dir,
+ "previews",
+ )
+
+ garantir(
+ dst_preview_dir
+ )
+
+ ext = os.path.splitext(
+ sample.preview_path
+ )[1]
+
+ preview_dst = os.path.join(
+ dst_preview_dir,
+ base + ext,
+ )
+
+ shutil.copy2(
+ sample.preview_path,
+ preview_dst,
+ )
+
+ if (
+ copy_visuals
+ and sample.visual_path
+ and os.path.isfile(sample.visual_path)
+ ):
+ dst_visual_dir = os.path.join(
+ dst_group_dir,
+ "visuals",
+ )
+
+ garantir(
+ dst_visual_dir
+ )
+
+ visual_dst = os.path.join(
+ dst_visual_dir,
+ os.path.basename(sample.visual_path),
+ )
+
+ shutil.copy2(
+ sample.visual_path,
+ visual_dst,
+ )
return {
"split": split_name,
"group": out_group_name,
- "source_group": s.source_group,
- "split_group": s.split_group,
+ "source_group": sample.source_group,
+ "actual_group": sample.actual_group,
"base": base,
- "family": s.family,
- "source": s.source,
- "is_synthetic": int(bool(s.is_synthetic)),
- "source_root": s.src_root,
- "source_root_label": s.source_root_label,
- "source_root_train_only": int(bool(s.source_root_train_only)),
-
+ "family": sample.family,
+ "source": sample.source,
+ "is_synthetic": int(sample.is_synthetic),
+ "stratum": sample.stratum,
+ "agri_risk": sample.agri_risk,
+ "frac_chao": sample.frac_chao,
+ "frac_cana": sample.frac_cana,
+ "frac_erva": sample.frac_erva,
+ "weed_near_cane_frac": sample.weed_near_cane_frac,
+ "weed_contact_cane_frac": sample.weed_contact_cane_frac,
+ "source_root": sample.src_root,
"tensor": tensor_dst,
"mask_npy": mask_dst,
"mask_png": mask_png_dst,
-
- "mask_vegetation_npy": aux_masks.get("masks_vegetation", {}).get("npy"),
- "mask_vegetation_png": aux_masks.get("masks_vegetation", {}).get("png"),
-
- "mask_cana_npy": aux_masks.get("masks_cana", {}).get("npy"),
- "mask_cana_png": aux_masks.get("masks_cana", {}).get("png"),
-
+ "mask_vegetation_npy": aux_masks.get(
+ "masks_vegetation",
+ {},
+ ).get("npy"),
+ "mask_cana_npy": aux_masks.get(
+ "masks_cana",
+ {},
+ ).get("npy"),
"meta": meta_dst,
"preview": preview_dst,
"visual_debug": visual_dst,
}
-def decide_sample_split(
- s: Sample,
- family_split: Dict[str, Dict[str, str]],
- args,
-) -> Tuple[Optional[str], str]:
- """
- Retorna (split, reason)
- split None = ignorado
- """
- group_map = family_split.get(s.split_group, {})
- assigned = group_map.get(s.family)
+# ============================================================
+# Relatórios
+# ============================================================
- is_train_only_candidate = (
- s.source_root_train_only
- or (bool(args.synthetic_train_only) and s.is_synthetic)
- or s.source in ("augmented", "copypaste", "synthetic")
+def write_csv(
+ path: str | Path,
+ rows: Sequence[Dict[str, Any]],
+ fieldnames: Sequence[str],
+) -> None:
+ garantir(
+ os.path.dirname(str(path))
)
- if is_train_only_candidate:
- if bool(args.synthetic_respect_family_split):
- if assigned is None:
- if bool(args.allow_orphan_synthetic_train):
- return "train", "train_only_orphan_allowed"
- return None, "skip_train_only_orphan_no_real_family"
+ with open(
+ path,
+ "w",
+ newline="",
+ encoding="utf-8",
+ ) as f:
+ writer = csv.DictWriter(
+ f,
+ fieldnames=list(fieldnames),
+ extrasaction="ignore",
+ )
- if assigned != "train":
- return None, f"skip_train_only_family_assigned_{assigned}"
-
- return "train", "train_only_family_train"
-
- return "train", "train_only_forced"
-
- # real/original
- if assigned is None:
- return None, "skip_real_no_family_assignment"
-
- return assigned, f"real_assigned_{assigned}"
+ writer.writeheader()
+ writer.writerows(rows)
-# ============================================================
-# Manifestos
-# ============================================================
-
-def write_manifest(path: str, rows: List[Dict[str, Any]]) -> None:
- garantir(os.path.dirname(path))
-
- fieldnames = [
- "split",
- "group",
- "source_group",
- "split_group",
- "base",
- "family",
- "source",
- "is_synthetic",
- "source_root",
- "source_root_label",
- "source_root_train_only",
-
- "tensor",
- "mask_npy",
- "mask_png",
-
- "mask_vegetation_npy",
- "mask_vegetation_png",
-
- "mask_cana_npy",
- "mask_cana_png",
-
- "meta",
- "preview",
- "visual_debug",
- ]
-
- with open(path, "w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
- w.writeheader()
- w.writerows(rows)
+def sample_audit_row(
+ sample: Sample,
+ split_name: Optional[str],
+ reason: str,
+) -> Dict[str, Any]:
+ return {
+ "source_group": sample.source_group,
+ "actual_group": sample.actual_group,
+ "folder_mask_mismatch": (
+ normalize_group_for_split(sample.source_group)
+ != sample.actual_group
+ ),
+ "base": sample.base,
+ "family": sample.family,
+ "source": sample.source,
+ "is_synthetic": int(sample.is_synthetic),
+ "split": split_name or "",
+ "reason": reason,
+ "stratum": sample.stratum,
+ "agri_risk": sample.agri_risk,
+ "present_ids": ",".join(
+ map(str, sample.present_ids)
+ ),
+ "frac_chao": sample.frac_chao,
+ "frac_cana": sample.frac_cana,
+ "frac_erva": sample.frac_erva,
+ "frac_ignore": sample.frac_ignore,
+ "comp_cana": sample.comp_cana,
+ "comp_erva": sample.comp_erva,
+ "largest_cana_frac": sample.largest_cana_frac,
+ "largest_erva_frac": sample.largest_erva_frac,
+ "weed_near_cane_frac": sample.weed_near_cane_frac,
+ "weed_contact_cane_frac": sample.weed_contact_cane_frac,
+ "brightness": sample.brightness,
+ "contrast": sample.contrast,
+ "saturation": sample.saturation,
+ "dhash64": f"{sample.dhash64:016x}",
+ "tensor": sample.tensor_path,
+ "mask": sample.mask_path,
+ "preview": sample.preview_path or "",
+ "audit_error": sample.audit_error,
+ }
-def write_skipped(path: str, rows: List[Dict[str, Any]]) -> None:
- garantir(os.path.dirname(path))
- fieldnames = [
- "source_root",
- "source_group",
- "split_group",
- "base",
- "family",
- "source",
- "is_synthetic",
- "reason",
- ]
- with open(path, "w", newline="", encoding="utf-8") as f:
- w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
- w.writeheader()
- w.writerows(rows)
+def family_audit_rows(
+ units: Sequence[FamilyUnit],
+) -> List[Dict[str, Any]]:
+ rows = []
+
+ for u in sorted(
+ units,
+ key=lambda x: (
+ x.actual_group,
+ x.stratum,
+ x.leakage_id,
+ ),
+ ):
+ rows.append({
+ "actual_group": u.actual_group,
+ "family": u.family,
+ "leakage_id": u.leakage_id,
+ "samples_in_unit": len(u.samples),
+ "stratum": u.stratum,
+ "agri_risk": u.agri_risk,
+ "frac_chao": u.frac_chao,
+ "frac_cana": u.frac_cana,
+ "frac_erva": u.frac_erva,
+ "assigned_split": u.assigned_split or "",
+ "assignment_reason": u.assignment_reason,
+ })
+
+ return rows
# ============================================================
@@ -825,208 +2204,384 @@ def write_skipped(path: str, rows: List[Dict[str, Any]]) -> None:
def main() -> None:
ap = argparse.ArgumentParser(
- description="Split multi-source OAK-FCC-3 sem vazamento, com sintéticos/aug apenas no train."
+ description=(
+ "Split agrícola Teacher V2: família, máscara, diversidade e "
+ "prova real sem leakage."
+ )
)
- ap.add_argument("--train", type=float, default=0.70)
- ap.add_argument("--val", type=float, default=0.29)
- ap.add_argument("--test", type=float, default=0.01)
- ap.add_argument("--seed", type=int, default=42)
+ ap.add_argument(
+ "--train",
+ type=float,
+ default=0.85,
+ )
- ap.add_argument("--min-train", type=int, default=1)
- ap.add_argument("--min-val", type=int, default=1)
- ap.add_argument("--min-test", type=int, default=0)
+ ap.add_argument(
+ "--val",
+ type=float,
+ default=0.15,
+ )
+
+ ap.add_argument(
+ "--test",
+ type=float,
+ default=0.0,
+ )
+
+ ap.add_argument(
+ "--seed",
+ type=int,
+ default=42,
+ )
+
+ ap.add_argument(
+ "--strategy",
+ choices=[
+ "agri_diverse",
+ "random",
+ ],
+ default="agri_diverse",
+ )
ap.add_argument(
"--resolucao",
- type=str,
default=None,
- help="Sobrescreve resolução no formato WxH. Ex: 1024x640.",
+ help="WxH, ex. 1280x800",
)
ap.add_argument(
"--src-root",
- type=str,
default=None,
- help="Compatibilidade: uma raiz normalizada. Default: dataset//group",
)
ap.add_argument(
"--src-roots",
- type=str,
default="",
- help="Lista de raízes separadas por vírgula. Ex: dataset/1024x640/group,dataset/copypaste/group",
)
ap.add_argument(
"--train-only-roots",
- type=str,
default="",
- help="Raízes que devem entrar somente no TRAIN. Ex: dataset/copypaste/group",
)
ap.add_argument(
"--dst-root",
- type=str,
default="dataset/split",
- help="Raiz do split.",
)
ap.add_argument(
"--groups",
- type=str,
default=None,
- help="Lista de grupos/split_groups separados por vírgula.",
- )
-
- ap.add_argument(
- "--cap-train-families",
- type=str,
- default="",
- help="Mapa 'grupo:cap,...' para limitar famílias reais no TRAIN. Ex: 'chao:50'",
)
ap.add_argument(
"--clear-dst",
action="store_true",
- help="Apaga dst-root antes de copiar.",
)
ap.add_argument(
"--no-meta-preview",
action="store_true",
- help="Não copia metas/previews para o split.",
)
ap.add_argument(
"--no-visuals",
action="store_true",
- help="Não copia pasta visuals/debug.",
)
+ ap.add_argument(
+ "--output-group-mode",
+ choices=[
+ "source",
+ "base",
+ "suffix",
+ ],
+ default="base",
+ help=(
+ "base é recomendado: o grupo final segue a máscara real."
+ ),
+ )
+
+ # máscara
+ ap.add_argument(
+ "--presence-min-pixels",
+ type=int,
+ default=1,
+ )
+
+ ap.add_argument(
+ "--presence-min-fraction",
+ type=float,
+ default=0.0,
+ )
+
+ ap.add_argument(
+ "--ignore-ids",
+ nargs="*",
+ type=int,
+ default=[255],
+ )
+
+ # Teacher/agri strata
+ ap.add_argument(
+ "--target-tiny-frac",
+ type=float,
+ default=0.002,
+ )
+
+ ap.add_argument(
+ "--target-small-frac",
+ type=float,
+ default=0.010,
+ )
+
+ ap.add_argument(
+ "--target-medium-frac",
+ type=float,
+ default=0.050,
+ )
+
+ ap.add_argument(
+ "--near-radius-frac",
+ type=float,
+ default=0.02,
+ )
+
+ ap.add_argument(
+ "--close-weed-cane-threshold",
+ type=float,
+ default=0.25,
+ )
+
+ # features
+ ap.add_argument(
+ "--use-tensor-features",
+ action="store_true",
+ default=False,
+ help=(
+ "Inclui diversidade espectral do tensor no split. "
+ "Recomendado após validar custo no dry-run."
+ ),
+ )
+
+ ap.add_argument(
+ "--tensor-feature-stride",
+ type=int,
+ default=16,
+ help=(
+ "Subamostragem espacial para features do tensor. "
+ "16 é leve mesmo em 1280x800."
+ ),
+ )
+
+ # near duplicates
+ ap.add_argument(
+ "--merge-near-duplicate-families",
+ action="store_true",
+ default=False,
+ help=(
+ "Une famílias reais quase idênticas para impedir leakage."
+ ),
+ )
+
+ ap.add_argument(
+ "--near-dup-hamming",
+ type=int,
+ default=2,
+ )
+
+ ap.add_argument(
+ "--near-dup-mask-l1",
+ type=float,
+ default=0.02,
+ )
+
+ # synthetic
ap.add_argument(
"--synthetic-train-only",
action="store_true",
default=True,
- help="Força samples synthetic/augmented/copypaste para TRAIN apenas.",
)
ap.add_argument(
"--allow-synthetic-val",
dest="synthetic_train_only",
action="store_false",
- help="Permite sintéticos no val/test. Não recomendado.",
)
ap.add_argument(
"--synthetic-respect-family-split",
action="store_true",
default=True,
- help="Só inclui sintético no TRAIN se a família real correspondente caiu no TRAIN.",
)
ap.add_argument(
"--no-synthetic-respect-family-split",
dest="synthetic_respect_family_split",
action="store_false",
- help="Inclui sintéticos no TRAIN mesmo sem checar a família real. Mais arriscado.",
)
ap.add_argument(
"--allow-orphan-synthetic-train",
action="store_true",
default=False,
- help="Permite sintético no TRAIN mesmo sem família real encontrada.",
- )
-
- ap.add_argument(
- "--output-group-mode",
- choices=["source", "base", "suffix"],
- default="source",
- help="Nome do grupo de saída. source mantém chao_cana_copypaste; base junta em chao_cana; suffix cria _synthetic.",
)
+ # relatórios
ap.add_argument(
"--manifest",
- type=str,
default="",
- help="CSV de manifesto. Default: /split_manifest.csv",
+ )
+
+ ap.add_argument(
+ "--audit",
+ default="",
+ )
+
+ ap.add_argument(
+ "--families",
+ default="",
)
ap.add_argument(
"--summary",
- type=str,
default="",
- help="JSON de resumo. Default: /split_summary.json",
)
ap.add_argument(
"--skipped",
- type=str,
default="",
- help="CSV de samples ignorados. Default: /split_skipped.csv",
+ )
+
+ ap.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Audita e decide split, mas não copia arquivos.",
)
args = ap.parse_args()
+ # resolução
if args.resolucao:
try:
w, h = args.resolucao.lower().split("x")
- resolucao = (int(w), int(h))
+ resolution = (
+ int(w),
+ int(h),
+ )
except Exception:
- resolucao = RESOLUCAO
+ raise SystemExit(
+ f"[ERRO] resolução inválida: {args.resolucao}"
+ )
else:
- resolucao = RESOLUCAO
+ resolution = RESOLUCAO
- default_src = os.path.join("dataset", f"{resolucao[0]}x{resolucao[1]}", "group")
+ default_src = os.path.join(
+ "dataset",
+ f"{resolution[0]}x{resolution[1]}",
+ "group",
+ )
+
+ src_roots = parse_csv_list(
+ args.src_roots
+ )
- src_roots = parse_csv_list(args.src_roots)
if args.src_root:
- src_roots.insert(0, args.src_root)
- if not src_roots:
- src_roots = [default_src]
+ src_roots.insert(
+ 0,
+ args.src_root,
+ )
+
+ if not src_roots:
+ src_roots = [
+ default_src
+ ]
- # Remove duplicatas preservando ordem.
seen = set()
- src_roots_unique = []
- for r in src_roots:
- nr = norm_path(r)
+ unique_src_roots = []
+
+ for root in src_roots:
+ nr = norm_path(root)
+
if nr not in seen:
seen.add(nr)
- src_roots_unique.append(r)
- src_roots = src_roots_unique
+ unique_src_roots.append(root)
- train_only_roots = parse_csv_list(args.train_only_roots)
+ src_roots = unique_src_roots
- for r in src_roots:
- if not os.path.isdir(r):
- raise SystemExit(f"[ERRO] src-root não encontrado: {r}")
+ train_only_roots = parse_csv_list(
+ args.train_only_roots
+ )
- dst_root = args.dst_root
+ for root in src_roots:
+ if not os.path.isdir(root):
+ raise SystemExit(
+ f"[ERRO] src-root não encontrado: {root}"
+ )
+
+ soma = (
+ args.train
+ + args.val
+ + args.test
+ )
- soma = args.train + args.val + args.test
if soma <= 0:
- raise ValueError("Soma de proporções deve ser > 0.")
+ raise SystemExit(
+ "[ERRO] soma train+val+test deve ser > 0"
+ )
p_train = args.train / soma
p_val = args.val / soma
p_test = args.test / soma
- mins = {
- "train": max(0, args.min_train),
- "val": max(0, args.min_val),
- "test": max(0, args.min_test),
- }
+ groups = (
+ parse_csv_list(args.groups)
+ if args.groups
+ else None
+ )
- groups = parse_csv_list(args.groups) if args.groups else None
- caps_map = parse_map(args.cap_train_families, int)
+ dst_root = args.dst_root
- if args.clear_dst:
- print(f"[INFO] Limpando destino: {dst_root}")
- limpar_dir(dst_root)
+ if (
+ args.clear_dst
+ and not args.dry_run
+ ):
+ print(
+ f"[INFO] Limpando destino: {dst_root}"
+ )
+ limpar_dir(
+ dst_root
+ )
else:
- garantir(dst_root)
+ garantir(
+ dst_root
+ )
+ print("==========================================")
+ print("SPLIT AGRÍCOLA TEACHER V2")
+ print(f"Resolution : {resolution[0]}x{resolution[1]}")
+ print(f"Strategy : {args.strategy}")
+ print(
+ f"Split : train={p_train:.3f} "
+ f"val={p_val:.3f} test={p_test:.3f}"
+ )
+ print(f"Tensor feat: {args.use_tensor_features}")
+ print(
+ f"Near-dups : {args.merge_near_duplicate_families}"
+ )
+ print(f"Dry-run : {args.dry_run}")
+ print("SRC:")
+ for root in src_roots:
+ marker = (
+ " [TRAIN_ONLY]"
+ if norm_path(root)
+ in {norm_path(x) for x in train_only_roots}
+ else ""
+ )
+ print(f" - {root}{marker}")
+ print("==========================================")
+
+ print("[1/6] Coletando samples...")
samples = collect_all_samples(
src_roots=src_roots,
train_only_roots=train_only_roots,
@@ -1034,41 +2589,135 @@ def main() -> None:
)
if not samples:
- print("[WARN] Nenhuma amostra encontrada.")
- return
-
- family_split, family_summary = build_family_split(
- samples=samples,
- p_train=p_train,
- p_val=p_val,
- p_test=p_test,
- seed=args.seed,
- mins=mins,
- caps_map=caps_map,
- )
-
- print("==========================================")
- print("Split OAK-FCC-3 Multi-source")
- print("SRC_ROOTS:")
- for r in src_roots:
- marker = " [TRAIN_ONLY]" if norm_path(r) in {norm_path(x) for x in train_only_roots} else ""
- print(f" - {r}{marker}")
- print(f"DST : {dst_root}")
- print(f"Split : train={p_train:.3f}, val={p_val:.3f}, test={p_test:.3f}")
- print(f"Mínimos : train={mins['train']} val={mins['val']} test={mins['test']}")
- print(f"Seed : {args.seed}")
- print(f"Synthetic : train_only={args.synthetic_train_only} respect_family={args.synthetic_respect_family_split}")
- print("==========================================")
-
- print("\nFamílias reais por split_group:")
- for g, info in family_summary.items():
- print(
- f"[{g}] famílias={info['familias']} → "
- f"train={info['train_families']}, val={info['val_families']}, test={info['test_families']}"
+ raise SystemExit(
+ "[ERRO] Nenhum sample encontrado."
)
- all_rows: List[Dict[str, Any]] = []
- skipped_rows: List[Dict[str, Any]] = []
+ print(
+ f"[OK] samples encontrados: {len(samples)}"
+ )
+
+ print("[2/6] Auditando máscaras + features agrícolas...")
+ analyzed: List[Sample] = []
+ rejected: List[Sample] = []
+
+ for i, sample in enumerate(
+ samples,
+ start=1,
+ ):
+ try:
+ analyze_sample(
+ sample,
+ args,
+ )
+ analyzed.append(
+ sample
+ )
+ except Exception as exc:
+ sample.audit_error = str(exc)
+ rejected.append(
+ sample
+ )
+
+ if (
+ i % 100 == 0
+ or i == len(samples)
+ ):
+ print(
+ f" {i}/{len(samples)} "
+ f"ok={len(analyzed)} "
+ f"rejeitados={len(rejected)}"
+ )
+
+ real_analyzed = [
+ s
+ for s in analyzed
+ if not s.is_synthetic
+ and not s.source_root_train_only
+ ]
+
+ mismatches = [
+ s
+ for s in real_analyzed
+ if normalize_group_for_split(
+ s.source_group
+ ) != s.actual_group
+ ]
+
+ print(
+ f"[OK] reais auditados={len(real_analyzed)} "
+ f"mismatch pasta/máscara={len(mismatches)}"
+ )
+
+ print("[3/6] Construindo famílias reais...")
+ base_units = build_real_family_units(
+ real_analyzed
+ )
+
+ (
+ units,
+ family_to_leakage,
+ merged_count,
+ ) = merge_near_duplicate_family_units(
+ units=base_units,
+ enabled=args.merge_near_duplicate_families,
+ max_hamming=args.near_dup_hamming,
+ mask_l1=args.near_dup_mask_l1,
+ )
+
+ # Garante mapping mesmo sem merge.
+ for unit in units:
+ for sample in unit.samples:
+ family_to_leakage.setdefault(
+ (sample.actual_group, sample.family),
+ unit.leakage_id,
+ )
+
+ print(
+ f"[OK] famílias base={len(base_units)} "
+ f"unidades leakage={len(units)} "
+ f"famílias mescladas={merged_count}"
+ )
+
+ print("[4/6] Montando a prova...")
+ if args.strategy == "agri_diverse":
+ assignment = assign_agri_diverse_split(
+ units=units,
+ p_train=p_train,
+ p_val=p_val,
+ p_test=p_test,
+ seed=args.seed,
+ )
+ else:
+ assignment = assign_random_split(
+ units=units,
+ p_train=p_train,
+ p_val=p_val,
+ p_test=p_test,
+ seed=args.seed,
+ )
+
+ family_counts = {
+ "train": 0,
+ "val": 0,
+ "test": 0,
+ }
+
+ for unit in units:
+ if unit.assigned_split:
+ family_counts[
+ unit.assigned_split
+ ] += 1
+
+ print(
+ f"[OK] famílias/unidades → "
+ f"train={family_counts['train']} "
+ f"val={family_counts['val']} "
+ f"test={family_counts['test']}"
+ )
+
+ print("[5/6] Aplicando split aos samples...")
+ decisions: Dict[int, Tuple[Optional[str], str]] = {}
totals = {
"train": 0,
@@ -1081,121 +2730,306 @@ def main() -> None:
"real_test": 0,
}
- by_group: Dict[str, Dict[str, int]] = {}
+ stratum_counts: Dict[str, Dict[str, int]] = {}
- for s in samples:
- split_name, reason = decide_sample_split(s, family_split, args)
+ for sample in analyzed:
+ split_name, reason = decide_sample_split(
+ sample=sample,
+ family_to_leakage=family_to_leakage,
+ assignment=assignment,
+ args=args,
+ )
+
+ decisions[id(sample)] = (
+ split_name,
+ reason,
+ )
if split_name is None:
totals["skipped"] += 1
- skipped_rows.append({
- "source_root": s.src_root,
- "source_group": s.source_group,
- "split_group": s.split_group,
- "base": s.base,
- "family": s.family,
- "source": s.source,
- "is_synthetic": int(s.is_synthetic),
- "reason": reason,
- })
continue
- out_group = output_group_name(
- source_group=s.source_group,
- split_group=s.split_group,
- is_synthetic=s.is_synthetic,
- mode=args.output_group_mode,
- )
-
- row = copiar_sample(
- s=s,
- dst_root=dst_root,
- split_name=split_name,
- out_group_name=out_group,
- copy_meta_preview=not args.no_meta_preview,
- copy_visuals=not args.no_visuals,
- )
-
- if row is None:
- totals["skipped"] += 1
- skipped_rows.append({
- "source_root": s.src_root,
- "source_group": s.source_group,
- "split_group": s.split_group,
- "base": s.base,
- "family": s.family,
- "source": s.source,
- "is_synthetic": int(s.is_synthetic),
- "reason": "copy_failed_missing_tensor_or_mask",
- })
- continue
-
- all_rows.append(row)
-
totals[split_name] += 1
- if split_name == "train" and s.is_synthetic:
+
+ if (
+ split_name == "train"
+ and sample.is_synthetic
+ ):
totals["synthetic_train"] += 1
+
elif split_name == "train":
totals["real_train"] += 1
+
elif split_name == "val":
totals["real_val"] += 1
+
elif split_name == "test":
totals["real_test"] += 1
- gsum = by_group.setdefault(out_group, {"train": 0, "val": 0, "test": 0, "synthetic_train": 0, "skipped": 0})
- gsum[split_name] = gsum.get(split_name, 0) + 1
- if split_name == "train" and s.is_synthetic:
- gsum["synthetic_train"] += 1
+ ss = stratum_counts.setdefault(
+ sample.stratum,
+ {
+ "train": 0,
+ "val": 0,
+ "test": 0,
+ },
+ )
- manifest_path = args.manifest or os.path.join(dst_root, "split_manifest.csv")
- summary_path = args.summary or os.path.join(dst_root, "split_summary.json")
- skipped_path = args.skipped or os.path.join(dst_root, "split_skipped.csv")
+ ss[split_name] += 1
- write_manifest(manifest_path, all_rows)
- write_skipped(skipped_path, skipped_rows)
+ print(
+ f"[OK] samples → "
+ f"train={totals['train']} "
+ f"val={totals['val']} "
+ f"test={totals['test']} "
+ f"skipped={totals['skipped']}"
+ )
+
+ print("[6/6] Copiando / relatórios...")
+ manifest_rows = []
+
+ if not args.dry_run:
+ for sample in analyzed:
+ split_name, reason = decisions[
+ id(sample)
+ ]
+
+ if split_name is None:
+ continue
+
+ out_group = output_group_name(
+ source_group=sample.source_group,
+ actual_group=sample.actual_group,
+ is_synthetic=sample.is_synthetic,
+ mode=args.output_group_mode,
+ )
+
+ row = copiar_sample(
+ sample=sample,
+ dst_root=dst_root,
+ split_name=split_name,
+ out_group_name=out_group,
+ copy_meta_preview=not args.no_meta_preview,
+ copy_visuals=not args.no_visuals,
+ )
+
+ if row is not None:
+ row["decision_reason"] = reason
+ manifest_rows.append(
+ row
+ )
+
+ manifest_path = (
+ args.manifest
+ or os.path.join(
+ dst_root,
+ "split_manifest.csv",
+ )
+ )
+
+ audit_path = (
+ args.audit
+ or os.path.join(
+ dst_root,
+ "split_audit.csv",
+ )
+ )
+
+ families_path = (
+ args.families
+ or os.path.join(
+ dst_root,
+ "split_families.csv",
+ )
+ )
+
+ skipped_path = (
+ args.skipped
+ or os.path.join(
+ dst_root,
+ "split_skipped.csv",
+ )
+ )
+
+ summary_path = (
+ args.summary
+ or os.path.join(
+ dst_root,
+ "split_summary.json",
+ )
+ )
+
+ audit_rows = []
+
+ skipped_rows = []
+
+ for sample in analyzed:
+ split_name, reason = decisions[
+ id(sample)
+ ]
+
+ row = sample_audit_row(
+ sample,
+ split_name,
+ reason,
+ )
+
+ audit_rows.append(
+ row
+ )
+
+ if split_name is None:
+ skipped_rows.append(
+ row
+ )
+
+ for sample in rejected:
+ audit_rows.append(
+ sample_audit_row(
+ sample,
+ None,
+ "audit_rejected",
+ )
+ )
+
+ if audit_rows:
+ write_csv(
+ audit_path,
+ audit_rows,
+ list(audit_rows[0].keys()),
+ )
+
+ family_rows = family_audit_rows(
+ units
+ )
+
+ if family_rows:
+ write_csv(
+ families_path,
+ family_rows,
+ list(family_rows[0].keys()),
+ )
+
+ if skipped_rows:
+ write_csv(
+ skipped_path,
+ skipped_rows,
+ list(skipped_rows[0].keys()),
+ )
+
+ if (
+ not args.dry_run
+ and manifest_rows
+ ):
+ write_csv(
+ manifest_path,
+ manifest_rows,
+ list(manifest_rows[0].keys()),
+ )
summary = {
- "schema": "oak_fcc3_multi_source_split_v1",
+ "schema": "oak_fcc3_agri_teacher_v2_split_v1",
+ "resolution": list(resolution),
+ "strategy": args.strategy,
"src_roots": src_roots,
"train_only_roots": train_only_roots,
"dst_root": dst_root,
- "resolution": list(resolucao),
"proportions": {
"train": p_train,
"val": p_val,
"test": p_test,
},
- "mins": mins,
- "seed": args.seed,
- "multi_head": MULTI_HEAD,
"options": {
+ "use_tensor_features": args.use_tensor_features,
+ "tensor_feature_stride": args.tensor_feature_stride,
+ "merge_near_duplicate_families": args.merge_near_duplicate_families,
+ "near_dup_hamming": args.near_dup_hamming,
+ "near_dup_mask_l1": args.near_dup_mask_l1,
"synthetic_train_only": args.synthetic_train_only,
"synthetic_respect_family_split": args.synthetic_respect_family_split,
- "allow_orphan_synthetic_train": args.allow_orphan_synthetic_train,
"output_group_mode": args.output_group_mode,
},
- "family_summary": family_summary,
- "groups": by_group,
- "total": totals,
- "samples_seen": len(samples),
- "manifest": manifest_path,
- "skipped": skipped_path,
+ "teacher_bins": {
+ "target_tiny_frac": args.target_tiny_frac,
+ "target_small_frac": args.target_small_frac,
+ "target_medium_frac": args.target_medium_frac,
+ "near_radius_frac": args.near_radius_frac,
+ "close_weed_cane_threshold": args.close_weed_cane_threshold,
+ },
+ "counts": {
+ "samples_seen": len(samples),
+ "samples_analyzed": len(analyzed),
+ "samples_rejected": len(rejected),
+ "real_samples": len(real_analyzed),
+ "folder_mask_mismatches": len(mismatches),
+ "base_families": len(base_units),
+ "leakage_units": len(units),
+ "near_duplicate_families_merged": merged_count,
+ "family_split": family_counts,
+ "samples_split": totals,
+ },
+ "strata": stratum_counts,
+ "paths": {
+ "manifest": (
+ manifest_path
+ if not args.dry_run
+ else None
+ ),
+ "audit": audit_path,
+ "families": families_path,
+ "skipped": skipped_path,
+ },
+ "dry_run": bool(args.dry_run),
}
- save_json(summary_path, summary)
+ save_json(
+ summary_path,
+ summary,
+ )
- print("\nResumo global:")
- print(f" train total: {totals['train']}")
- print(f" real train: {totals['real_train']}")
- print(f" synthetic train: {totals['synthetic_train']}")
- print(f" val real: {totals['real_val']}")
- print(f" test real: {totals['real_test']}")
- print(f" skipped: {totals['skipped']}")
- print(f"\nManifest: {manifest_path}")
- print(f"Skipped : {skipped_path}")
- print(f"Summary : {summary_path}")
+ print("==========================================")
+ print("RESULTADO")
+ print(
+ f"Samples reais : {len(real_analyzed)}"
+ )
+ print(
+ f"Mismatch pasta/GT : {len(mismatches)}"
+ )
+ print(
+ f"Famílias base : {len(base_units)}"
+ )
+ print(
+ f"Unidades leakage : {len(units)}"
+ )
+ print(
+ f"Near-dups mescladas: {merged_count}"
+ )
+ print(
+ f"TRAIN : {totals['train']} "
+ f"(real={totals['real_train']} synthetic={totals['synthetic_train']})"
+ )
+ print(
+ f"VAL real : {totals['real_val']}"
+ )
+ print(
+ f"TEST real : {totals['real_test']}"
+ )
+ print(
+ f"Skipped : {totals['skipped']}"
+ )
+ print(f"Audit : {audit_path}")
+ print(f"Families : {families_path}")
+ print(f"Summary : {summary_path}")
- print("\n✅ Split multi-source sem vazamento concluído!")
+ if not args.dry_run:
+ print(f"Manifest : {manifest_path}")
+
+ print("==========================================")
+
+ if args.dry_run:
+ print("✅ DRY-RUN concluído. Nenhum dataset foi copiado.")
+ else:
+ print("✅ Split agrícola Teacher V2 concluído.")
if __name__ == "__main__":
diff --git a/Python/OAK/datasets/oak-fcc-3/_8_train_multihead_v2.py b/Python/OAK/datasets/oak-fcc-3/_8_train_multihead_v2.py
new file mode 100644
index 000000000..ef34ac788
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/_8_train_multihead_v2.py
@@ -0,0 +1,3038 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+_8_train_multihead_v2.py
+
+Agri Teacher V2 para SegFormer multiespectral OAK-FCC-3.
+
+Objetivo operacional: distinguir chão/cana/erva e maximizar a decisão
+pulverizável (vegetação não-cana), com proteção explícita da cana.
+
+Recursos principais:
+- Raw3/4/5/7 por nomes de canais, incluindo NDVI/NDRE;
+- augmentation multiespectral sincronizado e fisicamente coerente;
+- sampler agrícola de batches diversos;
+- decoder separado ou decoder compartilhado + classifiers leves;
+- inicialização multiespectral configurável;
+- CE + Dice por imagem, boundary/OHEM opcionais e safety loss;
+- AdamW com LR discriminativo, no-decay em norm/bias;
+- warmup + poly/cosine por optimizer step;
+- AMP, gradient accumulation correto e gradient clipping;
+- métricas agrícolas: target precision/recall/F1, cana safety, spray rate,
+ weed miss rate, macro IoU, threshold sweep, ECE e cenários por área;
+- checkpoint/resume completo com optimizer, scheduler, scaler, RNG e global step.
+
+Mantém as funções públicas usadas por _10_export_onnx.py e _11_validate_onnx.py.
+"""
+
+from __future__ import annotations
+
+import os
+os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "max_split_size_mb:128")
+
+import csv
+import copy
+import json
+import time
+import math
+import argparse
+import random
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple, Any
+
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from torch.utils.data import Dataset, DataLoader, Sampler, WeightedRandomSampler
+from torch.amp import autocast, GradScaler
+
+from transformers import SegformerForSemanticSegmentation
+
+
+# ============================================================
+# Seed / util
+# ============================================================
+
+def set_seed(seed: int = 42):
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ torch.cuda.manual_seed_all(seed)
+
+
+def ensure_dir(path: Path):
+ path.mkdir(parents=True, exist_ok=True)
+
+
+def load_json(path: str | Path) -> dict:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def save_json(path: Path, data: dict):
+ ensure_dir(path.parent)
+ with path.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+
+def safe_float(x, default=0.0) -> float:
+ try:
+ return float(x)
+ except Exception:
+ return float(default)
+
+
+# ============================================================
+# Labelmap
+# ============================================================
+
+def load_labelmap(labelmap_path: str):
+ """
+ Tenta usar helpers.carregar_labelmap_completo.
+ Fallback simples para labelmap com uma classe por linha.
+ """
+ try:
+ from helpers import carregar_labelmap_completo, _infer_ignore_id
+
+ _cor_para_id, _colormap_rgb, id_para_nome, ignore_rgb = carregar_labelmap_completo(labelmap_path)
+ ignore_id = _infer_ignore_id(ignore_rgb, 255)
+
+ id2label = {int(k): str(v) for k, v in id_para_nome.items()}
+ label2id = {v.lower(): k for k, v in id2label.items()}
+
+ return id2label, label2id, int(ignore_id)
+
+ except Exception as e:
+ print(f"[WARN] Não consegui usar helpers.carregar_labelmap_completo: {e}")
+ print("[WARN] Usando parser simples: uma classe por linha.")
+
+ id2label = {}
+ with open(labelmap_path, "r", encoding="utf-8") as f:
+ for line in f:
+ s = line.strip()
+ if not s or s.startswith("#"):
+ continue
+
+ parts = s.replace(",", " ").split()
+
+ if len(parts) >= 2 and parts[0].isdigit():
+ cid = int(parts[0])
+ name = parts[1]
+ else:
+ cid = len(id2label)
+ name = parts[0]
+
+ if name.lower() in ("ignore", "void", "background_ignore"):
+ continue
+
+ id2label[cid] = name
+
+ label2id = {v.lower(): k for k, v in id2label.items()}
+ return id2label, label2id, 255
+
+
+# ============================================================
+# Head config
+# ============================================================
+
+DEFAULT_HEADS = {
+ "semantic": {
+ "enabled": True,
+ "type": "multiclass",
+ "num_classes": 3,
+ "mask_dir": "masks",
+ "classes": {
+ "chao": 0,
+ "cana": 1,
+ "erva": 2,
+ },
+ "ignore_index": 255,
+ "loss_weight": 0.10,
+ },
+ "vegetation": {
+ "enabled": True,
+ "type": "binary",
+ "num_classes": 2,
+ "mask_dir": "masks_vegetation",
+ "classes": {
+ "background": 0,
+ "vegetation": 1,
+ },
+ "ignore_index": 255,
+ "loss_weight": 0.20,
+ },
+ "cana": {
+ "enabled": True,
+ "type": "binary",
+ "num_classes": 2,
+ "mask_dir": "masks_cana",
+ "classes": {
+ "not_cana": 0,
+ "cana": 1,
+ },
+ "ignore_index": 255,
+ "loss_weight": 0.25,
+ },
+ "target": {
+ "enabled": True,
+ "type": "binary",
+ "num_classes": 2,
+ "mask_dir": "__derived_target__",
+ "classes": {
+ "background": 0,
+ "target": 1,
+ },
+ "ignore_index": 255,
+ "loss_weight": 0.45,
+ "derived_from": ["vegetation", "cana"],
+ },
+}
+
+
+def merge_dict(dst: dict, src: dict) -> dict:
+ out = copy.deepcopy(dst)
+
+ def rec(a, b):
+ for k, v in b.items():
+ if isinstance(v, dict) and isinstance(a.get(k), dict):
+ rec(a[k], v)
+ else:
+ a[k] = v
+
+ if isinstance(src, dict):
+ rec(out, src)
+
+ return out
+
+
+def build_heads_config(config: dict, ignore_index: int) -> Dict[str, dict]:
+ cfg = merge_dict(DEFAULT_HEADS, config.get("heads", {}) or {})
+
+ # Compatibilidade com configs antigas.
+ if not bool(config.get("multi_head", True)):
+ print("[WARN] config.multi_head não está true. Este script vai treinar multi-head mesmo assim.")
+
+ for name, hcfg in cfg.items():
+ hcfg.setdefault("enabled", True)
+ hcfg.setdefault("ignore_index", ignore_index)
+ hcfg["num_classes"] = int(hcfg.get("num_classes", 2))
+ hcfg["loss_weight"] = float(hcfg.get("loss_weight", 1.0))
+ hcfg["mask_dir"] = str(hcfg.get("mask_dir", "masks"))
+
+ active = {k: v for k, v in cfg.items() if bool(v.get("enabled", True))}
+
+ if "semantic" not in active:
+ raise RuntimeError("A head 'semantic' deve estar habilitada neste contrato.")
+ if "vegetation" not in active:
+ raise RuntimeError("A head 'vegetation' deve estar habilitada neste contrato.")
+ if "cana" not in active:
+ raise RuntimeError("A head 'cana' deve estar habilitada neste contrato.")
+
+ total_w = sum(float(v.get("loss_weight", 0.0)) for v in active.values())
+ if total_w <= 0:
+ raise RuntimeError("Soma de loss_weight das heads deve ser > 0.")
+
+ # Normaliza os pesos para não bagunçar magnitude da loss.
+ for v in active.values():
+ v["loss_weight_norm"] = float(v.get("loss_weight", 0.0)) / total_w
+
+ return active
+
+
+
+# ============================================================
+# Training V2 defaults / helpers
+# ============================================================
+
+TRAINER_VERSION = "agri_teacher_v2.2"
+SOURCE_CHANNEL_ORDER = ["R", "G", "B", "RE", "NIR"]
+DERIVED_CHANNEL_ORDER = ["NDVI", "NDRE"]
+SUPPORTED_INPUT_CHANNELS = SOURCE_CHANNEL_ORDER + DERIVED_CHANNEL_ORDER
+
+DEFAULT_TRAINING_V2 = {
+ "model": {
+ # separate = 1 decoder SegFormer completo por head (legado)
+ # shared_light = 1 decoder compartilhado + classifiers 1x1 leves
+ "decoder_mode": "shared_light",
+ # mean_rgb | zero_extra | scaled_mean
+ "spectral_input_init": "zero_extra",
+ },
+ "data": {
+ # Valida conteúdo real de tensors/masks .npy antes do treino.
+ # Arquivos truncados podem existir e ainda assim passar pelo split por existência.
+ "validate_npy_content": True,
+ "skip_corrupt_samples": True,
+ # Mesmo com skip=true, aborta se a corrupção deixar de ser pontual.
+ "max_corrupt_fraction": 0.005,
+ },
+ "augmentation": {
+ "enabled": True,
+ "horizontal_flip_p": 0.50,
+ "vertical_flip_p": 0.00,
+ "affine_p": 0.70,
+ "rotate_deg": 5.0,
+ "scale_min": 0.90,
+ "scale_max": 1.10,
+ "translate_frac": 0.04,
+ "crop_p": 0.45,
+ "crop_scale_min": 0.70,
+ "crop_scale_max": 1.00,
+ "crop_focus_target_p": 0.55,
+ "crop_focus_cana_p": 0.25,
+ "global_gain_p": 0.35,
+ "global_gain_min": 0.92,
+ "global_gain_max": 1.08,
+ "band_gain_p": 0.25,
+ "band_gain_min": 0.96,
+ "band_gain_max": 1.04,
+ "rgb_gamma_p": 0.20,
+ "rgb_gamma_min": 0.94,
+ "rgb_gamma_max": 1.06,
+ "noise_p": 0.20,
+ "noise_sigma_min": 0.001,
+ "noise_sigma_max": 0.008,
+ "blur_p": 0.12,
+ "blur_kernel": 3,
+ "sensor_channel_dropout_p": 0.00,
+ "clip_physical": True,
+ },
+ "sampler": {
+ # random | diverse | weighted
+ "mode": "diverse",
+ "samples_per_epoch": 0,
+ "tiny_target_pct": 0.005,
+ "small_target_pct": 0.02,
+ "medium_target_pct": 0.10,
+ },
+ "class_weighting": {
+ # log_inverse = comportamento robusto semelhante ao legado, agora com dataset inteiro
+ # sqrt_inverse = inverso de frequência suavizado
+ "method": "log_inverse",
+ "log_offset": 1.02,
+ "power": 0.50,
+ "min_weight": 0.25,
+ "max_weight": 4.00,
+ },
+ "loss": {
+ "dice_reduction": "per_image",
+ "dice_smooth": 1.0,
+ "boundary_weight": 0.00,
+ "ohem_ratio": 0.00,
+ "safety": {
+ "enabled": True,
+ "weight": 0.08,
+ "cana_weight": 1.00,
+ "ground_weight": 0.20,
+ },
+ },
+ "optimizer": {
+ "encoder_lr": None, # None => usa --lr
+ "patch_lr_mult": 2.0,
+ "heads_lr_mult": 5.0,
+ "weight_decay": None, # None => usa --wd
+ "no_decay_bias": True,
+ "no_decay_norm": True,
+ "betas": [0.9, 0.999],
+ "eps": 1e-8,
+ },
+ "scheduler": {
+ # poly | cosine | constant
+ "mode": "poly",
+ "warmup_ratio": 0.05,
+ "warmup_start_factor": 0.10,
+ "poly_power": 1.0,
+ "min_lr_ratio": 0.02,
+ },
+ "optimization": {
+ "grad_clip_norm": 1.0,
+ "matmul_precision": "high",
+ "cudnn_benchmark": True,
+ "persistent_workers": True,
+ "prefetch_factor": 2,
+ },
+ "target_distillation": {
+ # Compatível com config.target_distillation legado.
+ "enabled": False,
+ "mode": "cross_head",
+ "start_epoch": 8,
+ "rampup_epochs": 12,
+ "hard_weight": 0.75,
+ "distill_weight": 0.25,
+ "teacher_confidence_min": 0.60,
+ "detach_teacher": True,
+ "w_sem_erva": 0.45,
+ "w_veg_not_cana": 0.35,
+ "w_veg_suppressed": 0.20,
+ "cana_suppression_power": 1.5,
+ },
+ "metrics": {
+ # Sweep mais amplo para separar qualidade do modelo de calibração do threshold.
+ "target_thresholds": [
+ 0.30,
+ 0.35,
+ 0.40,
+ 0.45,
+ 0.50,
+ 0.55,
+ 0.60,
+ 0.65,
+ 0.70,
+ 0.75,
+ 0.80,
+ 0.85,
+ 0.90,
+ 0.95,
+ ],
+ "ece_bins": 15,
+ "scenario_metrics": True,
+ "group_metrics": True,
+ # No train mantemos as métricas básicas, mas pulamos sweep/ECE/cenários
+ # pesados. A validação continua completa. Não altera gradientes.
+ "rich_train_metrics": False,
+ "operational_threshold": {
+ # Restrições são usadas somente para recomendar threshold e salvar
+ # best_operational. Não alteram a loss nem o score principal.
+ "max_cana_spray_rate": 0.02,
+ "max_ground_spray_rate": 0.03,
+ "max_weed_miss_rate": 0.20,
+ "score_weights": {
+ "target_iou": 0.35,
+ "target_f1": 0.20,
+ "cana_safety": 0.25,
+ "ground_safety": 0.10,
+ "weed_recall": 0.10
+ }
+ },
+ },
+ "selection_score": {
+ "target_iou": 0.40,
+ "cana_iou": 0.20,
+ "target_f1": 0.10,
+ "vegetation_miou": 0.10,
+ "semantic_miou": 0.05,
+ "cana_safety": 0.15,
+ },
+ "checkpoint": {
+ "early_stop_min_delta": 5e-4,
+ "save_best_safety": True,
+ # Mantém duas réguas extras: a régua histórica do trainer antigo e
+ # uma régua operacional que escolhe threshold sob restrições agrícolas.
+ "save_best_legacy": True,
+ "save_best_operational": True,
+ },
+}
+
+_LAST_BUILD_CONFIG: Optional[dict] = None
+
+
+def get_training_v2_config(config: dict) -> dict:
+ cfg = merge_dict(DEFAULT_TRAINING_V2, config.get("training_v2", {}) or {})
+
+ # Compatibilidade com target_distillation no nível raiz.
+ # Se training_v2.target_distillation foi declarado explicitamente, ele manda.
+ if (
+ "target_distillation" not in (config.get("training_v2", {}) or {})
+ and isinstance(config.get("target_distillation"), dict)
+ ):
+ cfg["target_distillation"] = merge_dict(
+ cfg.get("target_distillation", {}),
+ config.get("target_distillation", {}),
+ )
+
+ return cfg
+
+
+def seed_worker(worker_id: int):
+ seed = torch.initial_seed() % (2**32)
+ np.random.seed(seed)
+ random.seed(seed)
+
+
+def capture_rng_state() -> dict:
+ state = {
+ "python": random.getstate(),
+ "numpy": np.random.get_state(),
+ "torch": torch.get_rng_state(),
+ }
+ if torch.cuda.is_available():
+ state["cuda"] = torch.cuda.get_rng_state_all()
+ return state
+
+
+def restore_rng_state(state: Optional[dict]):
+ if not state:
+ return
+ try:
+ if "python" in state:
+ random.setstate(state["python"])
+ if "numpy" in state:
+ np.random.set_state(state["numpy"])
+ if "torch" in state:
+ torch.set_rng_state(state["torch"])
+ if torch.cuda.is_available() and "cuda" in state:
+ torch.cuda.set_rng_state_all(state["cuda"])
+ except Exception as exc:
+ print(f"[WARN] Não consegui restaurar RNG state: {exc}")
+
+
+# ============================================================
+# Channels
+# ============================================================
+
+def get_input_channel_names(config: dict) -> List[str]:
+ if "input_channels" in config:
+ configured = config["input_channels"]
+ if isinstance(configured, str):
+ names = [str(c).strip().upper() for c in configured.split(",") if str(c).strip()]
+ else:
+ names = [str(c).strip().upper() for c in configured if str(c).strip()]
+ else:
+ n = int(config.get("channels", 5))
+ if n < 1 or n > len(SOURCE_CHANNEL_ORDER):
+ raise RuntimeError(
+ "Config antiga sem input_channels só suporta channels entre 1 e 5. "
+ "Para NDVI/NDRE, declare input_channels explicitamente."
+ )
+ names = SOURCE_CHANNEL_ORDER[:n]
+
+ if not names:
+ raise RuntimeError("input_channels não pode ser vazio.")
+ duplicates = sorted({name for name in names if names.count(name) > 1})
+ if duplicates:
+ raise RuntimeError(f"Canais duplicados em input_channels: {duplicates}")
+ invalid = [c for c in names if c not in SUPPORTED_INPUT_CHANNELS]
+ if invalid:
+ raise RuntimeError(f"Canais inválidos em input_channels: {invalid}. Suportados={SUPPORTED_INPUT_CHANNELS}")
+
+ configured_count = config.get("channels")
+ if configured_count is not None and int(configured_count) != len(names):
+ raise RuntimeError(
+ f"config.channels={configured_count} incompatível com input_channels={names} ({len(names)})."
+ )
+ return names
+
+
+def get_input_channel_indices(config: dict) -> List[int]:
+ return list(range(len(get_input_channel_names(config))))
+
+
+# ============================================================
+# Multispectral augmentation
+# ============================================================
+
+class MultiSpectralAugmenter:
+ """Augmentation sincronizado para imagem multiespectral + todas as masks."""
+
+ def __init__(self, channel_names: List[str], cfg: dict, derived_cfg: Optional[dict] = None):
+ self.channel_names = [str(x).upper() for x in channel_names]
+ self.cfg = cfg or {}
+ self.derived_cfg = derived_cfg or {}
+ self._warned_derived_missing = False
+
+ def _rand(self):
+ return random.random()
+
+ def _uniform(self, a, b):
+ return random.uniform(float(a), float(b))
+
+ def _apply_crop(self, x: torch.Tensor, masks: Dict[str, torch.Tensor]):
+ cfg = self.cfg
+ if self._rand() >= float(cfg.get("crop_p", 0.0)):
+ return x, masks
+
+ h, w = x.shape[-2:]
+ scale = self._uniform(cfg.get("crop_scale_min", 0.70), cfg.get("crop_scale_max", 1.0))
+ crop_h = max(16, min(h, int(round(h * scale))))
+ crop_w = max(16, min(w, int(round(w * scale))))
+
+ focus_xy = None
+ r = self._rand()
+ p_target = float(cfg.get("crop_focus_target_p", 0.55))
+ p_cana = float(cfg.get("crop_focus_cana_p", 0.25))
+
+ focus_mask = None
+ if r < p_target and "target" in masks:
+ focus_mask = masks["target"] == 1
+ elif r < p_target + p_cana and "cana" in masks:
+ focus_mask = masks["cana"] == 1
+
+ if focus_mask is not None and bool(focus_mask.any()):
+ coords = torch.nonzero(focus_mask, as_tuple=False)
+ pos = coords[random.randrange(coords.shape[0])]
+ cy, cx = int(pos[0]), int(pos[1])
+ focus_xy = (cx, cy)
+
+ if focus_xy is None:
+ cx = random.randint(0, max(0, w - 1))
+ cy = random.randint(0, max(0, h - 1))
+ else:
+ cx, cy = focus_xy
+
+ x0 = max(0, min(w - crop_w, cx - crop_w // 2))
+ y0 = max(0, min(h - crop_h, cy - crop_h // 2))
+ x1, y1 = x0 + crop_w, y0 + crop_h
+
+ x_crop = x[:, y0:y1, x0:x1].unsqueeze(0)
+ x = F.interpolate(x_crop, size=(h, w), mode="bilinear", align_corners=False).squeeze(0)
+
+ out_masks = {}
+ for name, m in masks.items():
+ mc = m[y0:y1, x0:x1].unsqueeze(0).unsqueeze(0).float()
+ out_masks[name] = F.interpolate(mc, size=(h, w), mode="nearest").squeeze(0).squeeze(0).long()
+ return x, out_masks
+
+ def _apply_affine(self, x: torch.Tensor, masks: Dict[str, torch.Tensor]):
+ cfg = self.cfg
+ if self._rand() >= float(cfg.get("affine_p", 0.0)):
+ return x, masks
+
+ h, w = x.shape[-2:]
+ angle = math.radians(self._uniform(-cfg.get("rotate_deg", 5.0), cfg.get("rotate_deg", 5.0)))
+ scale = self._uniform(cfg.get("scale_min", 0.90), cfg.get("scale_max", 1.10))
+ trans = float(cfg.get("translate_frac", 0.04))
+ tx = self._uniform(-trans, trans) * 2.0
+ ty = self._uniform(-trans, trans) * 2.0
+
+ c = math.cos(angle) / max(scale, 1e-6)
+ s = math.sin(angle) / max(scale, 1e-6)
+ theta = x.new_tensor([[c, -s, tx], [s, c, ty]]).unsqueeze(0)
+ grid = F.affine_grid(theta, size=(1, x.shape[0], h, w), align_corners=False)
+
+ x = F.grid_sample(
+ x.unsqueeze(0), grid, mode="bilinear", padding_mode="reflection", align_corners=False
+ ).squeeze(0)
+
+ valid = ((grid[..., 0].abs() <= 1.0) & (grid[..., 1].abs() <= 1.0)).squeeze(0)
+ out_masks = {}
+ for name, m in masks.items():
+ mi = F.grid_sample(
+ m.unsqueeze(0).unsqueeze(0).float(),
+ grid,
+ mode="nearest",
+ padding_mode="zeros",
+ align_corners=False,
+ ).squeeze(0).squeeze(0).long()
+ ignore_index = 255
+ mi[~valid] = ignore_index
+ out_masks[name] = mi
+ return x, out_masks
+
+ def _can_recompute_derived(self) -> bool:
+ names = set(self.channel_names)
+ if "NDVI" in names and not {"NIR", "R"}.issubset(names):
+ return False
+ if "NDRE" in names and not {"NIR", "RE"}.issubset(names):
+ return False
+ return True
+
+ def _recompute_derived(self, x: torch.Tensor):
+ idx = {name: i for i, name in enumerate(self.channel_names)}
+ eps = float(self.derived_cfg.get("epsilon", 1e-6))
+ clip_min = float(self.derived_cfg.get("clip_min", -1.0))
+ clip_max = float(self.derived_cfg.get("clip_max", 1.0))
+
+ def nd(a, b):
+ denom = a + b
+ out = torch.zeros_like(a)
+ valid = denom.abs() > eps
+ out[valid] = (a[valid] - b[valid]) / denom[valid]
+ return out.clamp(clip_min, clip_max)
+
+ if "NDVI" in idx and "NIR" in idx and "R" in idx:
+ x[idx["NDVI"]] = nd(x[idx["NIR"]], x[idx["R"]])
+ if "NDRE" in idx and "NIR" in idx and "RE" in idx:
+ x[idx["NDRE"]] = nd(x[idx["NIR"]], x[idx["RE"]])
+ return x
+
+ def _blur(self, x: torch.Tensor, k: int):
+ k = int(k)
+ if k < 3:
+ return x
+ if k % 2 == 0:
+ k += 1
+ return F.avg_pool2d(x.unsqueeze(0), kernel_size=k, stride=1, padding=k // 2).squeeze(0)
+
+ def _apply_radiometric(self, x: torch.Tensor):
+ cfg = self.cfg
+ idx = {name: i for i, name in enumerate(self.channel_names)}
+ physical_idx = [idx[n] for n in SOURCE_CHANNEL_ORDER if n in idx]
+ has_derived = any(n in idx for n in DERIVED_CHANNEL_ORDER)
+ can_recompute = self._can_recompute_derived()
+
+ if has_derived and not can_recompute and not self._warned_derived_missing:
+ print(
+ "[AUG][WARN] Há NDVI/NDRE sem todas as bandas físicas necessárias. "
+ "Jitters espectrais independentes serão pulados para preservar coerência."
+ )
+ self._warned_derived_missing = True
+
+ # Ganho global é seguro para índices de diferença normalizada se aplicado
+ # igualmente às bandas físicas relevantes.
+ if physical_idx and self._rand() < float(cfg.get("global_gain_p", 0.0)):
+ gain = self._uniform(cfg.get("global_gain_min", 0.92), cfg.get("global_gain_max", 1.08))
+ x[physical_idx] *= gain
+
+ if physical_idx and (not has_derived or can_recompute):
+ if self._rand() < float(cfg.get("band_gain_p", 0.0)):
+ lo = float(cfg.get("band_gain_min", 0.96))
+ hi = float(cfg.get("band_gain_max", 1.04))
+ for i in physical_idx:
+ x[i] *= self._uniform(lo, hi)
+
+ if self._rand() < float(cfg.get("rgb_gamma_p", 0.0)):
+ gamma = self._uniform(cfg.get("rgb_gamma_min", 0.94), cfg.get("rgb_gamma_max", 1.06))
+ for name in ("R", "G", "B"):
+ if name in idx:
+ x[idx[name]] = torch.clamp(x[idx[name]], min=0.0).pow(gamma)
+
+ if self._rand() < float(cfg.get("noise_p", 0.0)):
+ sigma = self._uniform(cfg.get("noise_sigma_min", 0.001), cfg.get("noise_sigma_max", 0.008))
+ noise = torch.randn_like(x[physical_idx]) * float(sigma)
+ x[physical_idx] += noise
+
+ p_drop = float(cfg.get("sensor_channel_dropout_p", 0.0))
+ if p_drop > 0 and self._rand() < p_drop:
+ candidates = [i for i in physical_idx if self.channel_names[i] not in ("R", "G", "B")]
+ if candidates:
+ x[random.choice(candidates)] = 0.0
+
+ if self._rand() < float(cfg.get("blur_p", 0.0)):
+ x = self._blur(x, int(cfg.get("blur_kernel", 3)))
+
+ if bool(cfg.get("clip_physical", True)):
+ for i in physical_idx:
+ x[i].clamp_(0.0, 1.0)
+
+ if has_derived and can_recompute:
+ x = self._recompute_derived(x)
+
+ return x
+
+ def __call__(self, x: torch.Tensor, masks: Dict[str, torch.Tensor]):
+ if not bool(self.cfg.get("enabled", True)):
+ return x, masks
+
+ x, masks = self._apply_crop(x, masks)
+ x, masks = self._apply_affine(x, masks)
+
+ if self._rand() < float(self.cfg.get("horizontal_flip_p", 0.0)):
+ x = torch.flip(x, dims=[2])
+ masks = {k: torch.flip(v, dims=[1]) for k, v in masks.items()}
+
+ if self._rand() < float(self.cfg.get("vertical_flip_p", 0.0)):
+ x = torch.flip(x, dims=[1])
+ masks = {k: torch.flip(v, dims=[0]) for k, v in masks.items()}
+
+ x = self._apply_radiometric(x)
+ return x.contiguous(), {k: v.contiguous() for k, v in masks.items()}
+
+
+def inspect_npy_integrity(path: str | Path, expected_ndim: Optional[int] = None) -> dict:
+ """
+ Valida header + tamanho do payload de um .npy sem criar memory-map.
+
+ Isso evita esgotar recursos de mapeamento do Windows durante auditorias de
+ milhares de arquivos e ainda detecta truncamento real do payload.
+ """
+ p = Path(path)
+ if not p.exists():
+ raise FileNotFoundError(str(p))
+
+ file_size = int(p.stat().st_size)
+ with p.open("rb") as f:
+ version = np.lib.format.read_magic(f)
+ if version == (1, 0):
+ shape, fortran_order, dtype = np.lib.format.read_array_header_1_0(f)
+ elif version in ((2, 0), (3, 0)):
+ # v2/v3 usam header longo. Em caso de implementação NumPy antiga,
+ # o fallback abaixo ainda faz uma leitura convencional sem mmap.
+ try:
+ shape, fortran_order, dtype = np.lib.format.read_array_header_2_0(f)
+ except Exception:
+ arr = np.load(str(p), allow_pickle=False)
+ shape, dtype = tuple(arr.shape), arr.dtype
+ fortran_order = bool(arr.flags.f_contiguous and not arr.flags.c_contiguous)
+ if expected_ndim is not None and arr.ndim != int(expected_ndim):
+ raise ValueError(f"ndim={arr.ndim}, esperado={expected_ndim}, shape={arr.shape}")
+ return {"shape": tuple(shape), "dtype": str(dtype), "fortran_order": fortran_order, "file_size": file_size}
+ else:
+ arr = np.load(str(p), allow_pickle=False)
+ shape, dtype = tuple(arr.shape), arr.dtype
+ fortran_order = bool(arr.flags.f_contiguous and not arr.flags.c_contiguous)
+ if expected_ndim is not None and arr.ndim != int(expected_ndim):
+ raise ValueError(f"ndim={arr.ndim}, esperado={expected_ndim}, shape={arr.shape}")
+ return {"shape": tuple(shape), "dtype": str(dtype), "fortran_order": fortran_order, "file_size": file_size}
+
+ shape = tuple(int(v) for v in shape)
+ dtype = np.dtype(dtype)
+ data_offset = int(f.tell())
+
+ if expected_ndim is not None and len(shape) != int(expected_ndim):
+ raise ValueError(f"ndim={len(shape)}, esperado={expected_ndim}, shape={shape}")
+ if any(v <= 0 for v in shape):
+ raise ValueError(f"shape inválido={shape}")
+ if dtype.hasobject:
+ # Dataset numérico não deveria usar object. Leitura convencional para
+ # produzir diagnóstico explícito em vez de confiar no tamanho bruto.
+ arr = np.load(str(p), allow_pickle=False)
+ return {"shape": tuple(arr.shape), "dtype": str(arr.dtype), "fortran_order": fortran_order, "file_size": file_size}
+
+ expected_payload = int(np.prod(shape, dtype=np.int64)) * int(dtype.itemsize)
+ actual_payload = max(0, file_size - data_offset)
+ if actual_payload < expected_payload:
+ raise ValueError(
+ f"payload truncado: shape={shape} dtype={dtype} "
+ f"esperado={expected_payload} bytes, encontrado={actual_payload} bytes"
+ )
+
+ return {
+ "shape": shape,
+ "dtype": str(dtype),
+ "fortran_order": bool(fortran_order),
+ "file_size": file_size,
+ "payload_bytes": actual_payload,
+ "expected_payload_bytes": expected_payload,
+ }
+
+
+# ============================================================
+# Dataset OAK-FCC-3 multi-head
+# ============================================================
+
+class OakFcc3TensorMultiHeadDataset(Dataset):
+ def __init__(
+ self,
+ root: str | Path,
+ heads_config: Dict[str, dict],
+ channels: int = 5,
+ input_channel_names: Optional[List[str]] = None,
+ strict_channels: bool = False,
+ resize_hw: Optional[Tuple[int, int]] = None,
+ augment: bool = False,
+ augmentation_config: Optional[dict] = None,
+ derived_config: Optional[dict] = None,
+ data_validation_config: Optional[dict] = None,
+ ):
+ self.root = Path(root)
+ self.heads_config = heads_config
+ self.channels = int(channels)
+ self.input_channel_names = list(input_channel_names or SOURCE_CHANNEL_ORDER[:self.channels])
+ self.strict_channels = bool(strict_channels)
+ self.resize_hw = resize_hw
+ self.augment = bool(augment)
+ self.augmenter = MultiSpectralAugmenter(
+ self.input_channel_names,
+ augmentation_config or {},
+ derived_cfg=derived_config or {},
+ ) if self.augment else None
+ self.data_validation_config = data_validation_config or {}
+
+ self.samples = self._collect_samples()
+ if not self.samples:
+ raise RuntimeError(f"Nenhuma amostra encontrada em: {self.root}")
+ if bool(self.data_validation_config.get("validate_npy_content", True)):
+ self._validate_and_filter_npy_samples()
+ self._validate_tensor_contract()
+ self._add_sample_class_stats()
+
+ def _collect_samples(self):
+ samples = []
+ group_root = self.root / "group"
+ if not group_root.is_dir():
+ raise RuntimeError(f"Pasta group não encontrada em: {self.root}")
+
+ for group_dir in sorted(group_root.iterdir()):
+ if not group_dir.is_dir():
+ continue
+ tensors_dir = group_dir / "tensors"
+ metas_dir = group_dir / "metas"
+ previews_dir = group_dir / "previews"
+ if not tensors_dir.is_dir():
+ continue
+
+ for tensor_path in sorted(tensors_dir.glob("*.npy")):
+ base = tensor_path.stem
+ masks, missing = {}, []
+ for head_name, hcfg in self.heads_config.items():
+ mask_dir_name = str(hcfg.get("mask_dir"))
+ if mask_dir_name == "__derived_target__" or bool(hcfg.get("derived", False)):
+ masks[head_name] = None
+ continue
+ mask_path = group_dir / mask_dir_name / f"{base}.npy"
+ if not mask_path.exists():
+ missing.append(f"{head_name}:{mask_path}")
+ else:
+ masks[head_name] = mask_path
+ if missing:
+ print(f"[WARN] Pulando {tensor_path}, masks ausentes: {missing}")
+ continue
+
+ meta_path = metas_dir / f"{base}.json"
+ preview_path = previews_dir / f"{base}.png"
+ tensor_channels = None
+ if meta_path.exists():
+ meta = load_json(meta_path)
+ meta_channels = meta.get("channels")
+ if isinstance(meta_channels, list) and meta_channels:
+ tensor_channels = [str(c).strip().upper() for c in meta_channels]
+
+ samples.append({
+ "group": group_dir.name,
+ "base": base,
+ "tensor": tensor_path,
+ "masks": masks,
+ "meta": meta_path if meta_path.exists() else None,
+ "preview": preview_path if preview_path.exists() else None,
+ "tensor_channels": tensor_channels,
+ })
+ return samples
+
+ def _resolve_tensor_channel_indices(self, sample: dict, tensor_c: int) -> List[int]:
+ saved_names = sample.get("tensor_channels")
+ if saved_names:
+ if len(saved_names) != tensor_c:
+ raise RuntimeError(
+ f"Meta/tensor incompatíveis em {sample['tensor']}: meta.channels={saved_names}, tensor C={tensor_c}."
+ )
+ if len(set(saved_names)) != len(saved_names):
+ raise RuntimeError(f"Meta possui canais duplicados em {sample['tensor']}: {saved_names}")
+ missing = [name for name in self.input_channel_names if name not in saved_names]
+ if missing:
+ raise RuntimeError(
+ f"Tensor {sample['tensor']} não possui canais {missing}. Salvos={saved_names}, pedidos={self.input_channel_names}."
+ )
+ return [saved_names.index(name) for name in self.input_channel_names]
+
+ if tensor_c == self.channels:
+ return list(range(self.channels))
+ if tensor_c == len(SOURCE_CHANNEL_ORDER) and all(name in SOURCE_CHANNEL_ORDER for name in self.input_channel_names):
+ return [SOURCE_CHANNEL_ORDER.index(name) for name in self.input_channel_names]
+ raise RuntimeError(
+ f"Não foi possível inferir canais de {sample['tensor']}: tensor C={tensor_c}, "
+ f"pedidos={self.input_channel_names}, meta.channels ausente."
+ )
+
+ def _validate_and_filter_npy_samples(self):
+ """
+ Audita .npy por header + tamanho de payload, sem mmap.
+
+ A versão anterior usava milhares de memory-maps e podia gerar WinError 8
+ no Windows por falta de recursos de mapeamento, classificando um arquivo
+ saudável como "corrupt". Aqui só tratamos como corrupção problemas
+ estruturais reais do arquivo.
+ """
+ cfg = self.data_validation_config or {}
+ skip_corrupt = bool(cfg.get("skip_corrupt_samples", True))
+ max_corrupt_fraction = max(0.0, float(cfg.get("max_corrupt_fraction", 0.005)))
+
+ original_count = len(self.samples)
+ good_samples = []
+ bad_samples = []
+
+ for s in self.samples:
+ failures = []
+ try:
+ info = inspect_npy_integrity(s["tensor"], expected_ndim=3)
+ shape = tuple(info["shape"])
+ if int(shape[0]) <= 0 or int(shape[1]) <= 0 or int(shape[2]) <= 0:
+ failures.append(f"tensor:invalid_shape={shape}")
+ except Exception as exc:
+ failures.append(f"tensor:{type(exc).__name__}:{exc} | path={s['tensor']}")
+
+ for head_name, path in (s.get("masks") or {}).items():
+ if path is None:
+ continue
+ try:
+ info = inspect_npy_integrity(path, expected_ndim=2)
+ shape = tuple(info["shape"])
+ if int(shape[0]) <= 0 or int(shape[1]) <= 0:
+ failures.append(f"{head_name}:invalid_shape={shape} | path={path}")
+ except Exception as exc:
+ failures.append(f"{head_name}:{type(exc).__name__}:{exc} | path={path}")
+
+ if failures:
+ bad_samples.append({
+ "group": s.get("group"),
+ "base": s.get("base"),
+ "tensor": str(s.get("tensor")),
+ "failures": failures,
+ })
+ else:
+ good_samples.append(s)
+
+ if not bad_samples:
+ print(f"[DATA][INTEGRITY] root={self.root} OK | samples={original_count}")
+ return
+
+ print(
+ f"[DATA][CORRUPT][WARN] root={self.root} "
+ f"corrupt={len(bad_samples)}/{original_count}"
+ )
+ for item in bad_samples[:50]:
+ print(
+ f"[DATA][CORRUPT] group={item['group']} base={item['base']} "
+ f"tensor={item['tensor']}"
+ )
+ for failure in item["failures"]:
+ print(f" - {failure}")
+ if len(bad_samples) > 50:
+ print(f"[DATA][CORRUPT] ... +{len(bad_samples)-50} amostras não exibidas")
+
+ corrupt_fraction = len(bad_samples) / max(1, original_count)
+ if corrupt_fraction > max_corrupt_fraction:
+ raise RuntimeError(
+ "Corrupção de dataset acima do limite de segurança: "
+ f"{len(bad_samples)}/{original_count} ({corrupt_fraction*100:.3f}%) > "
+ f"{max_corrupt_fraction*100:.3f}%. Corrija o dataset antes de treinar."
+ )
+
+ if not skip_corrupt:
+ first = bad_samples[0]
+ raise RuntimeError(
+ "Amostra .npy corrompida encontrada e skip_corrupt_samples=false: "
+ f"group={first['group']} base={first['base']} failures={first['failures']}"
+ )
+
+ self.samples = good_samples
+ print(
+ f"[DATA][CORRUPT][SKIP] root={self.root} removidas={len(bad_samples)} "
+ f"restantes={len(self.samples)} | limite={max_corrupt_fraction*100:.3f}%"
+ )
+ if not self.samples:
+ raise RuntimeError(f"Todas as amostras foram removidas por corrupção em: {self.root}")
+
+ def _validate_tensor_contract(self):
+ """
+ Valida o contrato EFETIVO dos tensores.
+
+ Importante:
+ - metas novos podem trazer meta.channels explicitamente;
+ - metas legados podem não trazer essa lista;
+ - quando C == self.channels, _resolve_tensor_channel_indices() já define
+ que a ordem implícita é exatamente self.input_channel_names.
+
+ Portanto, strict_channels deve comparar o contrato resolvido, e não
+ simplesmente diferenciar "meta com channels" de "meta sem channels".
+ """
+ contracts = set()
+ spatial = set()
+ missing_channel_meta = 0
+
+ for sample in self.samples:
+ info = inspect_npy_integrity(sample["tensor"], expected_ndim=3)
+ shape = tuple(info["shape"])
+ tensor_c = int(shape[0])
+ indices = self._resolve_tensor_channel_indices(sample, tensor_c)
+ if len(indices) != self.channels:
+ raise RuntimeError(
+ f"Contrato de canais inválido em {sample['tensor']}: indices={indices}"
+ )
+
+ saved_names = sample.get("tensor_channels")
+ if saved_names:
+ # Contrato nominal explícito no meta, na ordem efetivamente usada.
+ effective_names = tuple(saved_names[i] for i in indices)
+ else:
+ # Contrato legado implícito já validado por
+ # _resolve_tensor_channel_indices().
+ missing_channel_meta += 1
+ effective_names = tuple(self.input_channel_names)
+
+ contracts.add((effective_names, len(indices)))
+ spatial.add(tuple(shape[-2:]))
+
+ if self.strict_channels and len(contracts) > 1:
+ raise RuntimeError(
+ f"Contratos de tensor efetivos diferentes no mesmo split: "
+ f"{sorted(contracts, key=str)}"
+ )
+
+ if missing_channel_meta > 0:
+ print(
+ f"[DATA][CHANNELS][WARN] root={self.root} "
+ f"amostras_sem_meta_channels={missing_channel_meta}/{len(self.samples)} | "
+ f"assumindo ordem={self.input_channel_names} porque C={self.channels}."
+ )
+
+ print(
+ f"[DATA][CHANNELS] root={self.root} pedidos={self.input_channel_names} "
+ f"contratos_efetivos={sorted(contracts, key=str)}"
+ )
+ print(
+ f"[DATA][SPATIAL] root={self.root} shapes={sorted(spatial)} "
+ f"resize_hw={self.resize_hw}"
+ )
+
+ def __len__(self):
+ return len(self.samples)
+
+ def _resize_tensor(self, x: torch.Tensor):
+ if self.resize_hw is None:
+ return x
+ h, w = self.resize_hw
+ if x.shape[-2:] != (h, w):
+ x = F.interpolate(x.unsqueeze(0), size=(h, w), mode="bilinear", align_corners=False).squeeze(0)
+ return x
+
+ def _resize_mask(self, y: torch.Tensor):
+ if self.resize_hw is None:
+ return y
+ h, w = self.resize_hw
+ if y.shape[-2:] != (h, w):
+ y = F.interpolate(y.unsqueeze(0).unsqueeze(0).float(), size=(h, w), mode="nearest").squeeze().long()
+ return y
+
+ def _load_masks_for_sample(self, s: dict, do_resize: bool = True) -> Dict[str, torch.Tensor]:
+ masks = {}
+ for head_name, path in s["masks"].items():
+ if path is None:
+ continue
+ y = np.load(str(path)).astype(np.int64)
+ yt = torch.from_numpy(np.ascontiguousarray(y)).long()
+ if do_resize:
+ yt = self._resize_mask(yt)
+ masks[head_name] = yt
+
+ if "target" in self.heads_config:
+ if "vegetation" not in masks or "cana" not in masks:
+ raise RuntimeError("Head target requer masks vegetation e cana.")
+ ignore_index = int(self.heads_config["target"].get("ignore_index", 255))
+ veg, cana = masks["vegetation"], masks["cana"]
+ valid = (veg != ignore_index) & (cana != ignore_index)
+ target = torch.zeros_like(veg, dtype=torch.long)
+ target[(veg == 1) & (cana == 0)] = 1
+ target[~valid] = ignore_index
+ masks["target"] = target
+ return masks
+
+ def load_mask_numpy(self, sample_idx: int, head_name: str) -> np.ndarray:
+ s = self.samples[int(sample_idx)]
+ if head_name == "target":
+ masks = self._load_masks_for_sample(s, do_resize=False)
+ return masks["target"].numpy()
+ path = s["masks"].get(head_name)
+ if path is None:
+ raise RuntimeError(f"Mask física ausente para head={head_name}")
+ return np.load(str(path)).astype(np.int64)
+
+ def __getitem__(self, idx):
+ s = self.samples[idx]
+ x = np.load(str(s["tensor"])).astype(np.float32)
+ if x.ndim != 3:
+ raise RuntimeError(f"Tensor inválido {s['tensor']}: shape={x.shape}")
+ indices = self._resolve_tensor_channel_indices(s, int(x.shape[0]))
+ x = x[indices, :, :]
+ xt = self._resize_tensor(torch.from_numpy(np.ascontiguousarray(x)).float())
+ masks = self._load_masks_for_sample(s, do_resize=True)
+
+ if self.augmenter is not None:
+ xt, masks = self.augmenter(xt, masks)
+
+ return {"image": xt, "masks": masks, "group": s["group"], "base": s["base"]}
+
+ def _add_sample_class_stats(self):
+ sem_cfg = self.heads_config.get("semantic", {})
+ classes = sem_cfg.get("classes", {}) or {}
+ chao_id = int(classes.get("chao", 0))
+ cana_id = int(classes.get("cana", 1))
+ erva_id = int(classes.get("erva", 2))
+ ignore = int(sem_cfg.get("ignore_index", 255))
+
+ for s in self.samples:
+ stats = {
+ "pixels_total": 0, "pixels_chao": 0, "pixels_cana": 0, "pixels_erva": 0,
+ "pixels_vegetation": 0, "pixels_target": 0,
+ "pct_cana": 0.0, "pct_erva": 0.0, "pct_target": 0.0,
+ "has_cana": False, "has_erva": False, "has_target": False,
+ }
+ sem_path = s["masks"].get("semantic")
+ veg_path = s["masks"].get("vegetation")
+ cana_path = s["masks"].get("cana")
+
+ if sem_path is not None and Path(sem_path).exists():
+ sem = np.load(str(sem_path)).astype(np.int64)
+ valid = sem != ignore
+ total = int(valid.sum())
+ stats["pixels_total"] = total
+ if total > 0:
+ stats["pixels_chao"] = int(((sem == chao_id) & valid).sum())
+ stats["pixels_cana"] = int(((sem == cana_id) & valid).sum())
+ stats["pixels_erva"] = int(((sem == erva_id) & valid).sum())
+ stats["pct_cana"] = stats["pixels_cana"] / total
+ stats["pct_erva"] = stats["pixels_erva"] / total
+
+ if veg_path is not None and cana_path is not None:
+ veg = np.load(str(veg_path)).astype(np.int64)
+ cana = np.load(str(cana_path)).astype(np.int64)
+ valid = (veg != ignore) & (cana != ignore)
+ total = int(valid.sum())
+ if total > 0:
+ target = (veg == 1) & (cana == 0) & valid
+ stats["pixels_vegetation"] = int(((veg == 1) & valid).sum())
+ stats["pixels_target"] = int(target.sum())
+ stats["pct_target"] = stats["pixels_target"] / total
+
+ stats["has_cana"] = stats["pixels_cana"] > 0
+ stats["has_erva"] = stats["pixels_erva"] > 0
+ stats["has_target"] = stats["pixels_target"] > 0
+ s["class_stats"] = stats
+
+
+def collate_fn(batch):
+ imgs = torch.stack([b["image"] for b in batch], dim=0)
+ head_names = list(batch[0]["masks"].keys())
+ masks = {h: torch.stack([b["masks"][h] for b in batch], dim=0) for h in head_names}
+ meta = {"group": [b["group"] for b in batch], "base": [b["base"] for b in batch]}
+ return imgs, masks, meta
+
+
+# ============================================================
+# Diverse agricultural batch sampler
+# ============================================================
+
+class DiverseAgriculturalBatchSampler(Sampler[List[int]]):
+ """Monta batches com target, cana, mistos e negativos sempre que possível."""
+
+ def __init__(self, ds: OakFcc3TensorMultiHeadDataset, batch_size: int, cfg: dict, seed: int = 42):
+ self.ds = ds
+ self.batch_size = int(batch_size)
+ self.cfg = cfg or {}
+ self.seed = int(seed)
+ self.epoch = 0
+ self.samples_per_epoch = int(self.cfg.get("samples_per_epoch", 0) or 0) or len(ds)
+ self.num_batches = max(1, math.ceil(self.samples_per_epoch / max(1, self.batch_size)))
+ self.pools = self._build_pools()
+ self._print_summary()
+
+ def _build_pools(self):
+ tiny = float(self.cfg.get("tiny_target_pct", 0.005))
+ small = float(self.cfg.get("small_target_pct", 0.02))
+ medium = float(self.cfg.get("medium_target_pct", 0.10))
+ pools = {k: [] for k in [
+ "mixed", "target_only", "cana_only", "ground",
+ "target_tiny", "target_small", "target_medium", "target_large", "any"
+ ]}
+ for i, s in enumerate(self.ds.samples):
+ st = s.get("class_stats", {})
+ has_t = bool(st.get("has_target", False))
+ has_c = bool(st.get("has_cana", False))
+ pct = float(st.get("pct_target", 0.0))
+ pools["any"].append(i)
+ if has_t and has_c:
+ pools["mixed"].append(i)
+ elif has_t:
+ pools["target_only"].append(i)
+ elif has_c:
+ pools["cana_only"].append(i)
+ else:
+ pools["ground"].append(i)
+ if has_t:
+ if pct <= tiny:
+ pools["target_tiny"].append(i)
+ elif pct <= small:
+ pools["target_small"].append(i)
+ elif pct <= medium:
+ pools["target_medium"].append(i)
+ else:
+ pools["target_large"].append(i)
+ return pools
+
+ def _print_summary(self):
+ txt = " | ".join(f"{k}={len(v)}" for k, v in self.pools.items() if k != "any")
+ print(f"[SAMPLER:DIVERSE] batches={self.num_batches} batch={self.batch_size} | {txt}")
+
+ def set_epoch(self, epoch: int):
+ self.epoch = int(epoch)
+
+ def __len__(self):
+ return self.num_batches
+
+ def __iter__(self):
+ rng = random.Random(self.seed + self.epoch * 100003)
+ queues = {}
+ pos = {}
+ for k, vals in self.pools.items():
+ q = list(vals)
+ rng.shuffle(q)
+ queues[k] = q
+ pos[k] = 0
+
+ def draw(pool_name: str, used: set):
+ q = queues.get(pool_name) or []
+ if not q:
+ return None
+ for _ in range(max(1, len(q) * 2)):
+ if pos[pool_name] >= len(q):
+ rng.shuffle(q)
+ pos[pool_name] = 0
+ idx = q[pos[pool_name]]
+ pos[pool_name] += 1
+ if idx not in used:
+ return idx
+ return None
+
+ pattern = [
+ "mixed", "target_tiny", "cana_only", "ground",
+ "target_small", "target_only", "target_medium", "target_large",
+ ]
+
+ for b in range(self.num_batches):
+ used, batch = set(), []
+ offset = b % len(pattern)
+ desired = pattern[offset:] + pattern[:offset]
+ for pool_name in desired:
+ if len(batch) >= self.batch_size:
+ break
+ idx = draw(pool_name, used)
+ if idx is not None:
+ batch.append(idx)
+ used.add(idx)
+
+ while len(batch) < self.batch_size:
+ idx = draw("any", used)
+ if idx is None:
+ idx = rng.randrange(len(self.ds))
+ batch.append(idx)
+ used.add(idx)
+ yield batch
+
+
+def build_sample_weights(ds, mode="target_focus"):
+ weights = []
+ for s in ds.samples:
+ st = s.get("class_stats", {})
+ has_cana = bool(st.get("has_cana", False))
+ has_target = bool(st.get("has_target", False))
+ pct_target = float(st.get("pct_target", 0.0))
+ w = 1.0
+ if not has_cana and not has_target:
+ w *= 0.55
+ if has_cana:
+ w *= 1.4
+ if has_target:
+ w *= 2.4
+ w *= 1.0 + min(2.0, 30.0 * pct_target)
+ weights.append(max(0.20, min(w, 8.0)))
+ return torch.tensor(weights, dtype=torch.double)
+
+
+# ============================================================
+# Normalization
+# ============================================================
+
+class FixedNormalizer(nn.Module):
+ def __init__(self, mean: List[float], std: List[float]):
+ super().__init__()
+ self.register_buffer("mean", torch.tensor(mean, dtype=torch.float32).view(1, -1, 1, 1))
+ self.register_buffer("std", torch.clamp(torch.tensor(std, dtype=torch.float32).view(1, -1, 1, 1), min=1e-6))
+ def forward(self, x):
+ return (x - self.mean) / self.std
+
+
+def normalize_per_batch(x: torch.Tensor, eps: float = 1e-6):
+ mean = x.mean(dim=(0, 2, 3), keepdim=True)
+ std = x.std(dim=(0, 2, 3), keepdim=True).clamp_min(eps)
+ return (x - mean) / std
+
+
+def build_normalizer(config: dict, args, device: torch.device):
+ input_channel_names = get_input_channel_names(config)
+ channels = len(input_channel_names)
+ model_family = config.get("modelo", "segformer")
+ model_name = config.get("model_name", "model")
+ stats_source_tag = config.get("stats_source_tag", f"stacked_raw{channels}")
+ candidates = []
+ if args.norm_stats:
+ candidates.append(Path(args.norm_stats))
+ candidates.append(Path("dataset") / f"{config['resolucao'][0]}x{config['resolucao'][1]}" / "group" / "norm_stats.json")
+ candidates.append(Path("backup") / model_family / model_name / stats_source_tag / "norm_stats.json")
+
+ for p in candidates:
+ if not p.exists():
+ continue
+ stats = load_json(p)
+ mean, std = stats.get("mean", []), stats.get("std", [])
+ raw_channels = stats.get("channels", [])
+ stat_channels = [str(c).strip().upper() for c in raw_channels] if isinstance(raw_channels, list) else []
+ if len(mean) != len(std):
+ raise RuntimeError(f"norm_stats incompatível: {p} | mean={len(mean)} std={len(std)}")
+ if stat_channels:
+ if len(stat_channels) != len(mean) or len(set(stat_channels)) != len(stat_channels):
+ raise RuntimeError(f"norm_stats channels inválidos: {p} | {stat_channels}")
+ missing = [n for n in input_channel_names if n not in stat_channels]
+ if missing:
+ raise RuntimeError(f"norm_stats {p} não possui {missing}. Disponíveis={stat_channels}")
+ ids = [stat_channels.index(n) for n in input_channel_names]
+ mean, std = [mean[i] for i in ids], [std[i] for i in ids]
+ selected = [stat_channels[i] for i in ids]
+ elif len(mean) == channels:
+ selected = list(input_channel_names)
+ elif len(mean) == len(SOURCE_CHANNEL_ORDER) and all(n in SOURCE_CHANNEL_ORDER for n in input_channel_names):
+ ids = [SOURCE_CHANNEL_ORDER.index(n) for n in input_channel_names]
+ mean, std = [mean[i] for i in ids], [std[i] for i in ids]
+ selected = list(input_channel_names)
+ else:
+ raise RuntimeError(f"norm_stats sem nomes incompatível: {p} mean/std={len(mean)} pedidos={input_channel_names}")
+
+ ma, sa = np.asarray(mean, np.float64), np.asarray(std, np.float64)
+ if not np.isfinite(ma).all() or not np.isfinite(sa).all() or np.any(sa <= 0):
+ raise RuntimeError(f"norm_stats contém valores inválidos: {p}")
+ print(f"[NORM] usando stats fixos: {p}")
+ print(f"[NORM] selected_channels={selected}")
+ return FixedNormalizer(mean, std).to(device), str(p)
+
+ if bool(getattr(args, "allow_missing_norm_stats", False)):
+ print("[NORM][WARN] norm_stats ausente; usando normalize_per_batch.")
+ return None, None
+ raise RuntimeError(
+ "norm_stats não encontrado. Use --norm_stats ou --allow-missing-norm-stats conscientemente. "
+ f"Candidatos={[str(p) for p in candidates]}"
+ )
+
+
+# ============================================================
+# Model: separate decoders or shared-light decoder
+# ============================================================
+
+def patch_segformer_encoder_input_channels(segformer_encoder: nn.Module, input_channel_names: List[str], init_mode: str = "zero_extra"):
+ names = [str(c).strip().upper() for c in input_channel_names]
+ if names == ["R", "G", "B"]:
+ return segformer_encoder
+
+ # Transformers <= versões usadas hoje no projeto: .encoder.patch_embeddings[0].proj
+ # Transformers recentes: .stages[0].patch_embeddings.proj
+ if hasattr(segformer_encoder, "encoder") and hasattr(segformer_encoder.encoder, "patch_embeddings"):
+ patch_owner = segformer_encoder.encoder.patch_embeddings[0]
+ elif hasattr(segformer_encoder, "stages") and len(segformer_encoder.stages) > 0:
+ patch_owner = segformer_encoder.stages[0].patch_embeddings
+ else:
+ raise RuntimeError("Não encontrei o primeiro patch embedding do SegFormer nesta versão do Transformers.")
+
+ proj = patch_owner.proj
+ old_weight = proj.weight.data.clone()
+ old_bias = proj.bias.data.clone() if proj.bias is not None else None
+ new_proj = nn.Conv2d(
+ in_channels=len(names), out_channels=proj.out_channels,
+ kernel_size=proj.kernel_size, stride=proj.stride, padding=proj.padding,
+ dilation=proj.dilation, groups=proj.groups, bias=proj.bias is not None,
+ padding_mode=proj.padding_mode,
+ )
+
+ mode = str(init_mode or "zero_extra").lower()
+ with torch.no_grad():
+ pretrained = {"R": 0, "G": 1, "B": 2}
+ mean_rgb = old_weight[:, :3].mean(dim=1)
+ has_rgb = any(n in pretrained for n in names)
+ for dst, name in enumerate(names):
+ if name in pretrained:
+ new_proj.weight[:, dst].copy_(old_weight[:, pretrained[name]])
+ elif mode == "mean_rgb":
+ new_proj.weight[:, dst].copy_(mean_rgb)
+ elif mode == "scaled_mean":
+ new_proj.weight[:, dst].copy_(mean_rgb * (3.0 / max(3.0, float(len(names)))))
+ elif mode == "zero_extra" and has_rgb:
+ new_proj.weight[:, dst].zero_()
+ else:
+ new_proj.weight[:, dst].copy_(mean_rgb)
+ if old_bias is not None:
+ new_proj.bias.copy_(old_bias)
+
+ patch_owner.proj = new_proj
+ print(f"[MODEL] input patch RGB(3) -> {names} | init={mode}")
+ return segformer_encoder
+
+
+def replace_segformer_decode_classifier(decode_head: nn.Module, num_classes: int):
+ old = getattr(decode_head, "classifier", None)
+ if not isinstance(old, nn.Conv2d):
+ raise RuntimeError(f"decode_head.classifier inesperado: {type(old)}")
+ new = nn.Conv2d(old.in_channels, int(num_classes), kernel_size=old.kernel_size, stride=old.stride,
+ padding=old.padding, dilation=old.dilation, groups=old.groups,
+ bias=old.bias is not None, padding_mode=old.padding_mode)
+ nn.init.xavier_uniform_(new.weight)
+ if new.bias is not None:
+ nn.init.zeros_(new.bias)
+ decode_head.classifier = new
+ return decode_head
+
+
+class MultiHeadSegFormer(nn.Module):
+ def __init__(self, backbone: str, input_channel_names: List[str], heads_config: Dict[str, dict],
+ semantic_id2label: Dict[int, str], semantic_label2id: Dict[str, int],
+ model_cfg: Optional[dict] = None):
+ super().__init__()
+ model_cfg = model_cfg or {}
+ semantic_classes = int(heads_config["semantic"].get("num_classes", len(semantic_id2label)))
+ names = [str(c).strip().upper() for c in input_channel_names]
+ base = SegformerForSemanticSegmentation.from_pretrained(
+ backbone, num_labels=semantic_classes,
+ id2label={int(k): str(v) for k, v in semantic_id2label.items()},
+ label2id={str(k): int(v) for k, v in semantic_label2id.items()},
+ ignore_mismatched_sizes=True,
+ )
+ self.decoder_mode = str(model_cfg.get("decoder_mode", "shared_light")).lower()
+ self.spectral_input_init = str(model_cfg.get("spectral_input_init", "zero_extra")).lower()
+ patch_segformer_encoder_input_channels(base.segformer, names, self.spectral_input_init)
+ base.config.num_channels = len(names)
+ base.config.input_channel_names = list(names)
+ base.config.agri_decoder_mode = self.decoder_mode
+ base.config.agri_trainer_version = TRAINER_VERSION
+ base.config.agri_spectral_input_init = self.spectral_input_init
+
+ self.segformer = base.segformer
+ self.heads_config = heads_config
+
+ if self.decoder_mode == "separate":
+ self.decode_heads = nn.ModuleDict()
+ for head_name, hcfg in heads_config.items():
+ h = copy.deepcopy(base.decode_head)
+ self.decode_heads[head_name] = replace_segformer_decode_classifier(h, int(hcfg["num_classes"]))
+ self.shared_decode_head = None
+ self.classifiers = None
+
+ elif self.decoder_mode == "shared_light":
+ self.decode_heads = None
+ self.shared_decode_head = copy.deepcopy(base.decode_head)
+ old_classifier = self.shared_decode_head.classifier
+ if not isinstance(old_classifier, nn.Conv2d):
+ raise RuntimeError("SegFormer decode_head.classifier não é Conv2d.")
+ feature_channels = int(old_classifier.in_channels)
+ self.shared_decode_head.classifier = nn.Identity()
+ self.classifiers = nn.ModuleDict()
+ for head_name, hcfg in heads_config.items():
+ clf = nn.Conv2d(feature_channels, int(hcfg["num_classes"]), kernel_size=1, bias=True)
+ nn.init.xavier_uniform_(clf.weight)
+ nn.init.zeros_(clf.bias)
+ self.classifiers[head_name] = clf
+ else:
+ raise RuntimeError(f"training_v2.model.decoder_mode inválido: {self.decoder_mode}")
+
+ self.config = base.config
+ print(f"[MODEL] decoder_mode={self.decoder_mode}")
+
+ def forward(self, pixel_values: torch.Tensor) -> Dict[str, torch.Tensor]:
+ outputs = self.segformer(pixel_values=pixel_values, output_hidden_states=True, return_dict=True)
+ hidden_states = outputs.hidden_states
+ if self.decoder_mode == "separate":
+ return {name: head(hidden_states) for name, head in self.decode_heads.items()}
+ features = self.shared_decode_head(hidden_states)
+ return {name: clf(features) for name, clf in self.classifiers.items()}
+
+
+def build_model(backbone: str, input_channel_names: List[str], heads_config: Dict[str, dict],
+ semantic_id2label: Dict[int, str], semantic_label2id: Dict[str, int], config: Optional[dict] = None):
+ global _LAST_BUILD_CONFIG
+ effective_config = config or _LAST_BUILD_CONFIG or {}
+ tcfg = get_training_v2_config(effective_config)
+ return MultiHeadSegFormer(
+ backbone=backbone,
+ input_channel_names=input_channel_names,
+ heads_config=heads_config,
+ semantic_id2label=semantic_id2label,
+ semantic_label2id=semantic_label2id,
+ model_cfg=tcfg.get("model", {}),
+ )
+
+
+# Override build_heads_config only to keep export/validate aware of the config
+_build_heads_config_base = build_heads_config
+
+def build_heads_config(config: dict, ignore_index: int) -> Dict[str, dict]:
+ global _LAST_BUILD_CONFIG
+ _LAST_BUILD_CONFIG = copy.deepcopy(config)
+ return _build_heads_config_base(config, ignore_index)
+
+
+# ============================================================
+# Class weighting / optimizer / scheduler
+# ============================================================
+
+def estimate_head_class_weights(ds: OakFcc3TensorMultiHeadDataset, head_name: str, num_classes: int,
+ ignore_index: int, method_cfg: dict):
+ counts = np.zeros(num_classes, dtype=np.float64)
+ for i in range(len(ds)):
+ m = ds.load_mask_numpy(i, head_name).reshape(-1)
+ m = m[(m != ignore_index) & (m >= 0) & (m < num_classes)]
+ if m.size:
+ counts += np.bincount(m, minlength=num_classes)[:num_classes]
+ freq = counts / max(counts.sum(), 1.0)
+ freq = np.clip(freq, 1e-12, 1.0)
+ method = str(method_cfg.get("method", "log_inverse")).lower()
+ if method == "sqrt_inverse":
+ power = float(method_cfg.get("power", 0.5))
+ weights = np.power(1.0 / freq, power)
+ elif method == "none":
+ weights = np.ones_like(freq)
+ else:
+ offset = float(method_cfg.get("log_offset", 1.02))
+ weights = 1.0 / np.log(offset + freq)
+ weights = weights / max(weights.mean(), 1e-12)
+ weights = np.clip(weights, float(method_cfg.get("min_weight", 0.25)), float(method_cfg.get("max_weight", 4.0)))
+ weights = weights / max(weights.mean(), 1e-12)
+ return torch.tensor(weights, dtype=torch.float32), counts
+
+
+def parse_class_weights_string(s: str) -> Dict[str, List[float]]:
+ out, txt = {}, str(s).strip()
+ if not txt:
+ return out
+ if "=" not in txt:
+ out["__single__"] = [float(x) for x in txt.split(",") if x.strip()]
+ return out
+ for block in txt.split(";"):
+ if not block.strip():
+ continue
+ k, v = block.split("=", 1)
+ out[k.strip()] = [float(x) for x in v.split(",") if x.strip()]
+ return out
+
+
+def build_criterions(ds_train, heads_config, class_weights_mode, device, seed, weighting_cfg: Optional[dict] = None):
+ criterions, debug = {}, {}
+ weighting_cfg = weighting_cfg or {}
+ parsed_manual = parse_class_weights_string(class_weights_mode) if str(class_weights_mode).lower() not in ("auto", "none") else {}
+ for head_name, hcfg in heads_config.items():
+ ncls, ignore = int(hcfg["num_classes"]), int(hcfg.get("ignore_index", 255))
+ weights, counts = None, None
+ mode = str(class_weights_mode).lower()
+ if mode == "auto":
+ w, counts = estimate_head_class_weights(ds_train, head_name, ncls, ignore, weighting_cfg)
+ weights = w.to(device)
+ elif mode != "none":
+ vals = parsed_manual.get(head_name, parsed_manual.get("__single__") if head_name == "semantic" else None)
+ if vals is not None:
+ if len(vals) != ncls:
+ raise RuntimeError(f"Pesos de {head_name} precisam ter {ncls} valores: {vals}")
+ weights = torch.tensor(vals, dtype=torch.float32, device=device)
+ criterions[head_name] = nn.CrossEntropyLoss(weight=weights, ignore_index=ignore)
+ debug[head_name] = {
+ "weights": weights.detach().cpu().tolist() if weights is not None else None,
+ "counts": counts.astype(np.int64).tolist() if counts is not None else None,
+ }
+ print(f"[LOSS:{head_name}] counts={debug[head_name]['counts']} weights={debug[head_name]['weights']}")
+ return criterions, debug
+
+
+def _normalization_parameter_names(model: nn.Module) -> set:
+ norm_types = (nn.LayerNorm, nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d, nn.GroupNorm, nn.InstanceNorm1d, nn.InstanceNorm2d, nn.InstanceNorm3d)
+ names = set()
+ for module_name, module in model.named_modules():
+ if isinstance(module, norm_types):
+ for pn, _ in module.named_parameters(recurse=False):
+ names.add(f"{module_name}.{pn}" if module_name else pn)
+ return names
+
+
+def build_optimizer(model: nn.Module, base_lr: float, base_wd: float, cfg: dict):
+ encoder_lr = float(cfg.get("encoder_lr") if cfg.get("encoder_lr") is not None else base_lr)
+ wd = float(cfg.get("weight_decay") if cfg.get("weight_decay") is not None else base_wd)
+ patch_mult = float(cfg.get("patch_lr_mult", 2.0))
+ heads_mult = float(cfg.get("heads_lr_mult", 5.0))
+ norm_names = _normalization_parameter_names(model)
+ groups = {}
+
+ def group_name(name: str):
+ is_patch = (
+ name.startswith("segformer.encoder.patch_embeddings.0") or
+ name.startswith("segformer.stages.0.patch_embeddings")
+ )
+ is_encoder = name.startswith("segformer.")
+ if is_patch:
+ section, lr = "patch", encoder_lr * patch_mult
+ elif is_encoder:
+ section, lr = "encoder", encoder_lr
+ else:
+ section, lr = "heads", encoder_lr * heads_mult
+ no_decay = (
+ (bool(cfg.get("no_decay_bias", True)) and name.endswith(".bias")) or
+ (bool(cfg.get("no_decay_norm", True)) and name in norm_names)
+ )
+ return section, lr, (0.0 if no_decay else wd)
+
+ for name, p in model.named_parameters():
+ if not p.requires_grad:
+ continue
+ section, lr, group_wd = group_name(name)
+ key = (section, lr, group_wd)
+ groups.setdefault(key, []).append(p)
+
+ param_groups = []
+ for (section, lr, group_wd), params in groups.items():
+ param_groups.append({"params": params, "lr": lr, "weight_decay": group_wd, "group_name": f"{section}_{'nodecay' if group_wd == 0 else 'decay'}"})
+ print(f"[OPT] {param_groups[-1]['group_name']}: params={sum(p.numel() for p in params):,} lr={lr:.3e} wd={group_wd:.3e}")
+
+ betas = cfg.get("betas", [0.9, 0.999])
+ return torch.optim.AdamW(param_groups, lr=encoder_lr, betas=(float(betas[0]), float(betas[1])), eps=float(cfg.get("eps", 1e-8)))
+
+
+class WarmupDecayScheduler:
+ """Step scheduler determinístico, salvo integralmente no checkpoint."""
+ def __init__(self, optimizer, total_steps: int, cfg: dict):
+ self.optimizer = optimizer
+ self.total_steps = max(1, int(total_steps))
+ self.cfg = copy.deepcopy(cfg or {})
+ self.base_lrs = [float(g["lr"]) for g in optimizer.param_groups]
+ self.step_num = 0
+ self._apply(self.step_num)
+
+ def _factor(self, step: int) -> float:
+ mode = str(self.cfg.get("mode", "poly")).lower()
+ warmup = int(round(self.total_steps * float(self.cfg.get("warmup_ratio", 0.05))))
+ warmup = max(0, min(warmup, self.total_steps - 1))
+ start = float(self.cfg.get("warmup_start_factor", 0.10))
+ min_ratio = float(self.cfg.get("min_lr_ratio", 0.02))
+ if warmup > 0 and step < warmup:
+ if warmup == 1:
+ return 1.0
+ t = step / float(warmup - 1)
+ return start + (1.0 - start) * t
+ if mode == "constant":
+ return 1.0
+ decay_steps = max(1, self.total_steps - warmup)
+ t = min(1.0, max(0.0, (step - warmup) / float(max(1, decay_steps - 1))))
+ if mode == "cosine":
+ raw = 0.5 * (1.0 + math.cos(math.pi * t))
+ else:
+ raw = (1.0 - t) ** float(self.cfg.get("poly_power", 1.0))
+ return min_ratio + (1.0 - min_ratio) * raw
+
+ def _apply(self, step: int):
+ f = self._factor(step)
+ for g, base in zip(self.optimizer.param_groups, self.base_lrs):
+ g["lr"] = float(base * f)
+
+ def step(self):
+ self.step_num = min(self.total_steps, self.step_num + 1)
+ self._apply(self.step_num)
+
+ def state_dict(self):
+ return {"step_num": self.step_num, "total_steps": self.total_steps, "base_lrs": self.base_lrs, "cfg": self.cfg}
+
+ def load_state_dict(self, state: dict):
+ self.step_num = int(state.get("step_num", 0))
+ saved_total = int(state.get("total_steps", self.total_steps))
+ if saved_total != self.total_steps:
+ print(f"[SCHED][WARN] total_steps checkpoint={saved_total}, atual={self.total_steps}; mantendo atual.")
+ self._apply(self.step_num)
+
+ def get_last_lr(self):
+ return [float(g["lr"]) for g in self.optimizer.param_groups]
+
+
+# ============================================================
+# Metrics / losses
+# ============================================================
+
+@torch.no_grad()
+def update_confusion_matrix(cm, preds, labels, num_classes, ignore_index=255):
+ preds, labels = preds.reshape(-1), labels.reshape(-1)
+ valid = labels != ignore_index
+ preds, labels = preds[valid], labels[valid]
+ valid2 = (labels >= 0) & (labels < num_classes) & (preds >= 0) & (preds < num_classes)
+ preds, labels = preds[valid2], labels[valid2]
+ if labels.numel() == 0:
+ return
+ idx = labels * num_classes + preds
+ cm += torch.bincount(idx, minlength=num_classes * num_classes).view(num_classes, num_classes)
+
+
+@torch.no_grad()
+def compute_iou_from_cm(cm, eps=1e-6):
+ cm = cm.float(); tp = torch.diag(cm); fp = cm.sum(0) - tp; fn = cm.sum(1) - tp
+ iou = (tp / (tp + fp + fn + eps)).cpu().tolist()
+ return float(np.mean(iou)), iou
+
+
+@torch.no_grad()
+def compute_pixel_acc_from_cm(cm, eps=1e-6):
+ cm = cm.float()
+ return float(torch.diag(cm).sum() / (cm.sum() + eps))
+
+
+def binary_stats_from_cm(cm, eps=1e-9):
+ c = cm.detach().float().cpu()
+ if c.numel() != 4:
+ return {}
+ tn, fp, fn, tp = [float(x) for x in c.reshape(-1)]
+ precision = tp / (tp + fp + eps)
+ recall = tp / (tp + fn + eps)
+ specificity = tn / (tn + fp + eps)
+ f1 = 2 * precision * recall / (precision + recall + eps)
+ return {"precision": precision, "recall": recall, "specificity": specificity, "f1": f1,
+ "tp": tp, "fp": fp, "fn": fn, "tn": tn}
+
+
+def dice_loss(logits, target, num_classes, ignore_index=255, smooth=1.0, reduction_mode="per_image"):
+ probs = torch.softmax(logits, dim=1)
+ valid = target != ignore_index
+ if not bool(valid.any()):
+ return logits.new_tensor(0.0)
+ tc = target.clone(); tc[~valid] = 0
+ onehot = F.one_hot(tc.long(), num_classes=num_classes).permute(0, 3, 1, 2).float()
+ vf = valid.unsqueeze(1).float(); probs = probs * vf; onehot = onehot * vf
+ if str(reduction_mode).lower() == "global":
+ dims = (0, 2, 3)
+ inter = (probs * onehot).sum(dims); den = probs.sum(dims) + onehot.sum(dims)
+ else:
+ dims = (2, 3)
+ inter = (probs * onehot).sum(dims); den = probs.sum(dims) + onehot.sum(dims)
+ dice = (2.0 * inter + smooth) / (den + smooth)
+ return 1.0 - dice.mean()
+
+
+def _boundary_mask(target: torch.Tensor, ignore_index: int):
+ valid = target != ignore_index
+ t = target.clone(); t[~valid] = 0
+ b = torch.zeros_like(valid)
+ b[:, 1:] |= valid[:, 1:] & valid[:, :-1] & (t[:, 1:] != t[:, :-1])
+ b[:, :-1] |= valid[:, :-1] & valid[:, 1:] & (t[:, :-1] != t[:, 1:])
+ b[:, :, 1:] |= valid[:, :, 1:] & valid[:, :, :-1] & (t[:, :, 1:] != t[:, :, :-1])
+ b[:, :, :-1] |= valid[:, :, :-1] & valid[:, :, 1:] & (t[:, :, :-1] != t[:, :, 1:])
+ return b
+
+
+def get_target_distill_ramp_factor(config: dict, epoch: Optional[int]) -> float:
+ cfg = get_training_v2_config(config).get("target_distillation", {})
+ if not bool(cfg.get("enabled", False)):
+ return 0.0
+ if epoch is None:
+ return 1.0
+ start, ramp = int(cfg.get("start_epoch", 8)), int(cfg.get("rampup_epochs", 12))
+ if epoch < start:
+ return 0.0
+ if ramp <= 0:
+ return 1.0
+ t = max(0.0, min(1.0, (epoch - start + 1) / float(ramp)))
+ return float(t * t * (3.0 - 2.0 * t))
+
+
+def build_target_teacher_from_heads(logits_by_head, target_shape, config, erva_id=2):
+ cfg = get_training_v2_config(config).get("target_distillation", {})
+ if not bool(cfg.get("enabled", False)) or str(cfg.get("mode", "cross_head")) != "cross_head":
+ return None
+ if any(k not in logits_by_head for k in ("semantic", "vegetation", "cana")):
+ return None
+ def rs(x):
+ return F.interpolate(x, size=target_shape, mode="bilinear", align_corners=False) if x.shape[-2:] != target_shape else x
+ sem, veg, cana = map(rs, (logits_by_head["semantic"], logits_by_head["vegetation"], logits_by_head["cana"]))
+ ps, pv, pc = torch.softmax(sem, 1), torch.softmax(veg, 1), torch.softmax(cana, 1)
+ not_cana = (1.0 - pc[:, 1]).clamp(0, 1)
+ teacher = (
+ float(cfg.get("w_sem_erva", 0.45)) * ps[:, int(erva_id)] +
+ float(cfg.get("w_veg_not_cana", 0.35)) * pv[:, 1] * not_cana +
+ float(cfg.get("w_veg_suppressed", 0.20)) * pv[:, 1] * not_cana.pow(float(cfg.get("cana_suppression_power", 1.5)))
+ ).clamp(0, 1)
+ if bool(cfg.get("detach_teacher", True)):
+ teacher = teacher.detach()
+ return teacher
+
+
+def compute_losses_for_batch(logits_by_head, masks_by_head, heads_config, criterions,
+ dice_weight=0.30, config=None, epoch=None):
+ cfg = config or {}
+ tv2 = get_training_v2_config(cfg)
+ lcfg = tv2.get("loss", {})
+ total, parts = None, {}
+ resized_logits = {}
+
+ for head_name, logits in logits_by_head.items():
+ target = masks_by_head[head_name]
+ hcfg = heads_config[head_name]
+ ncls, ignore = int(hcfg["num_classes"]), int(hcfg.get("ignore_index", 255))
+ hw = float(hcfg.get("loss_weight_norm", 1.0))
+ if logits.shape[-2:] != target.shape[-2:]:
+ logits = F.interpolate(logits, size=target.shape[-2:], mode="bilinear", align_corners=False)
+ resized_logits[head_name] = logits
+ ce = criterions[head_name](logits, target)
+ dl = dice_loss(logits, target, ncls, ignore, float(lcfg.get("dice_smooth", 1.0)), str(lcfg.get("dice_reduction", "per_image")))
+ head_loss = (1.0 - dice_weight) * ce + dice_weight * dl
+
+ bw = float(lcfg.get("boundary_weight", 0.0))
+ if bw > 0:
+ boundary = _boundary_mask(target, ignore)
+ if bool(boundary.any()):
+ cem = F.cross_entropy(logits, target, weight=criterions[head_name].weight, ignore_index=ignore, reduction="none")
+ bl = cem[boundary].mean()
+ head_loss = head_loss + bw * bl
+ parts[f"{head_name}_boundary"] = bl.detach()
+
+ ohem_ratio = float(lcfg.get("ohem_ratio", 0.0))
+ if 0 < ohem_ratio < 1:
+ cem = F.cross_entropy(logits, target, weight=criterions[head_name].weight, ignore_index=ignore, reduction="none")
+ valid = target != ignore
+ vals = cem[valid]
+ if vals.numel() > 0:
+ k = max(1, int(vals.numel() * ohem_ratio))
+ ohem = torch.topk(vals, k=k, largest=True).values.mean()
+ head_loss = 0.8 * head_loss + 0.2 * ohem
+ parts[f"{head_name}_ohem"] = ohem.detach()
+
+ if head_name == "target":
+ ramp = get_target_distill_ramp_factor(cfg, epoch)
+ dcfg = tv2.get("target_distillation", {})
+ if ramp > 0:
+ teacher = build_target_teacher_from_heads(logits_by_head, target.shape[-2:], cfg,
+ erva_id=int(cfg.get("heads", {}).get("semantic", {}).get("classes", {}).get("erva", 2)))
+ if teacher is not None:
+ binary_logits = logits[:, 1] - logits[:, 0]
+ valid = target != ignore
+ conf = torch.maximum(teacher, 1.0 - teacher)
+ valid &= conf >= float(dcfg.get("teacher_confidence_min", 0.60))
+ if bool(valid.any()):
+ distill = F.binary_cross_entropy_with_logits(binary_logits[valid], teacher[valid])
+ hard_w = float(dcfg.get("hard_weight", 0.75))
+ dist_w = float(dcfg.get("distill_weight", 0.25)) * ramp
+ denom = max(1e-6, hard_w + dist_w)
+ head_loss = (hard_w / denom) * head_loss + (dist_w / denom) * distill
+ parts["target_distill"] = distill.detach()
+ parts["target_distill_ramp"] = logits.new_tensor(ramp).detach()
+
+ weighted = hw * head_loss
+ total = weighted if total is None else total + weighted
+ parts[f"{head_name}_loss"] = head_loss.detach()
+ parts[f"{head_name}_ce"] = ce.detach()
+ parts[f"{head_name}_dice"] = dl.detach()
+
+ # Domain safety loss: target em cana custa mais que target em chão.
+ scfg = lcfg.get("safety", {}) or {}
+ if bool(scfg.get("enabled", True)) and "target" in resized_logits:
+ logits = resized_logits["target"]
+ binary = logits[:, 1] - logits[:, 0]
+ cana_gt = masks_by_head.get("cana")
+ sem_gt = masks_by_head.get("semantic")
+ safety_terms = []
+ if cana_gt is not None:
+ valid_cana = cana_gt == 1
+ if bool(valid_cana.any()):
+ lc = F.binary_cross_entropy_with_logits(binary[valid_cana], torch.zeros_like(binary[valid_cana]))
+ safety_terms.append(float(scfg.get("cana_weight", 1.0)) * lc)
+ parts["safety_cana"] = lc.detach()
+ if sem_gt is not None:
+ chao_id = int(heads_config.get("semantic", {}).get("classes", {}).get("chao", 0))
+ valid_ground = sem_gt == chao_id
+ if bool(valid_ground.any()):
+ lg = F.binary_cross_entropy_with_logits(binary[valid_ground], torch.zeros_like(binary[valid_ground]))
+ safety_terms.append(float(scfg.get("ground_weight", 0.20)) * lg)
+ parts["safety_ground"] = lg.detach()
+ if safety_terms:
+ safety = sum(safety_terms) / max(1.0, sum([float(scfg.get("cana_weight", 1.0)), float(scfg.get("ground_weight", 0.20))]))
+ total = total + float(scfg.get("weight", 0.08)) * safety
+ parts["safety_loss"] = safety.detach()
+
+ return total, parts
+
+
+def resize_logits_to_target_if_needed(logits, target):
+ return F.interpolate(logits, size=target.shape[-2:], mode="bilinear", align_corners=False) if logits.shape[-2:] != target.shape[-2:] else logits
+
+
+class AgriculturalMetricsMeter:
+ def __init__(
+ self,
+ thresholds: List[float],
+ ece_bins: int,
+ device: torch.device,
+ rich: bool = True,
+ scenario_metrics: bool = True,
+ group_metrics: bool = True,
+ operational_cfg: Optional[dict] = None,
+ ):
+ thresholds = [float(t) for t in thresholds]
+ if 0.5 not in thresholds:
+ thresholds.append(0.5)
+ self.thresholds = sorted(set(thresholds))
+ self.ece_bins = max(2, int(ece_bins))
+ self.device = device
+ self.rich = bool(rich)
+ self.scenario_metrics = bool(scenario_metrics) and self.rich
+ self.group_metrics = bool(group_metrics) and self.rich
+ self.operational_cfg = operational_cfg or {}
+
+ self.cana_total = self.cana_sprayed = 0
+ self.ground_total = self.ground_sprayed = 0
+ self.target_total = self.target_missed = 0
+ self.valid_total = self.predicted_target_total = 0
+
+ self.macro_target_iou_sum = 0.0
+ self.macro_target_iou_count = 0
+
+ # Estatísticas por threshold. Cana/chão usam os mesmos GTs globais.
+ self.threshold_counts = {
+ t: {
+ "tp": 0, "fp": 0, "fn": 0, "tn": 0,
+ "cana_sprayed": 0,
+ "ground_sprayed": 0,
+ "predicted_target": 0,
+ }
+ for t in self.thresholds
+ }
+
+ # Calibração. Acumuladores em float64; entradas são convertidas para
+ # float32 antes de somar, evitando overflow FP16 com --amp_val.
+ self.ece_count = np.zeros(self.ece_bins, np.float64)
+ self.ece_conf = np.zeros(self.ece_bins, np.float64)
+ self.ece_acc = np.zeros(self.ece_bins, np.float64)
+ self.brier_sum = 0.0
+ self.nll_sum = 0.0
+ self.calibration_count = 0
+
+ self.scenario = {
+ k: torch.zeros((2, 2), dtype=torch.int64, device=device)
+ for k in ["none", "tiny", "small", "medium", "large"]
+ }
+ self.group_stats: Dict[str, dict] = {}
+
+ # Métricas por frame, úteis porque uma única mancha errada de target
+ # sobre cana já pode ser operacionalmente relevante.
+ self.frames_total = 0
+ self.frames_with_target = 0
+ self.frames_target_detected = 0
+ self.frames_target_completely_missed = 0
+ self.frames_with_cana = 0
+ self.frames_cana_clean = 0
+ self.frames_with_ground = 0
+ self.frames_ground_clean = 0
+
+ # Consistência entre target direta e target composta
+ # vegetation==1 AND cana==0.
+ self.composed_valid = 0
+ self.composed_disagree = 0
+ self.composed_intersection = 0
+ self.composed_union = 0
+
+ @staticmethod
+ def _empty_group_stats() -> dict:
+ return {
+ "frames": 0,
+ "tp": 0, "fp": 0, "fn": 0, "tn": 0,
+ "cana_total": 0, "cana_sprayed": 0,
+ "ground_total": 0, "ground_sprayed": 0,
+ "target_total": 0, "target_missed": 0,
+ }
+
+ @staticmethod
+ def _binary_metrics(tp: int, fp: int, fn: int, tn: int = 0) -> dict:
+ eps = 1e-9
+ precision = tp / (tp + fp + eps)
+ recall = tp / (tp + fn + eps)
+ f1 = 2 * precision * recall / (precision + recall + eps)
+ iou = tp / (tp + fp + fn + eps)
+ specificity = tn / (tn + fp + eps)
+ return {
+ "precision": precision,
+ "recall": recall,
+ "f1": f1,
+ "iou": iou,
+ "specificity": specificity,
+ "tp": int(tp), "fp": int(fp), "fn": int(fn), "tn": int(tn),
+ }
+
+ @torch.no_grad()
+ def update(self, target_logits, masks, heads_config, meta: Optional[dict] = None, composed_pred: Optional[torch.Tensor] = None):
+ gt = masks.get("target")
+ if gt is None:
+ return
+
+ ignore = int(heads_config["target"].get("ignore_index", 255))
+ logits = resize_logits_to_target_if_needed(target_logits, gt).float()
+ prob = torch.softmax(logits, dim=1)[:, 1].float()
+ pred = (prob >= 0.5).long()
+ valid = gt != ignore
+
+ cana = masks.get("cana")
+ sem = masks.get("semantic")
+ chao_id = int(heads_config.get("semantic", {}).get("classes", {}).get("chao", 0))
+
+ cana_mask = ((cana == 1) & valid) if cana is not None else torch.zeros_like(valid)
+ ground_mask = ((sem == chao_id) & valid) if sem is not None else torch.zeros_like(valid)
+ target_mask = (gt == 1) & valid
+
+ self.cana_total += int(cana_mask.sum())
+ self.cana_sprayed += int(((pred == 1) & cana_mask).sum())
+ self.ground_total += int(ground_mask.sum())
+ self.ground_sprayed += int(((pred == 1) & ground_mask).sum())
+ self.target_total += int(target_mask.sum())
+ self.target_missed += int(((pred == 0) & target_mask).sum())
+ self.valid_total += int(valid.sum())
+ self.predicted_target_total += int(((pred == 1) & valid).sum())
+
+ if composed_pred is not None:
+ cp = composed_pred
+ if cp.shape[-2:] != gt.shape[-2:]:
+ cp = F.interpolate(cp.unsqueeze(1).float(), size=gt.shape[-2:], mode="nearest").squeeze(1).long()
+ dv = valid
+ direct = pred[dv]
+ comp = cp[dv]
+ self.composed_valid += int(direct.numel())
+ self.composed_disagree += int((direct != comp).sum())
+ self.composed_intersection += int(((direct == 1) & (comp == 1)).sum())
+ self.composed_union += int(((direct == 1) | (comp == 1)).sum())
+
+ groups = []
+ if isinstance(meta, dict):
+ groups = list(meta.get("group", []) or [])
+
+ # Métricas frame-a-frame, cenários e grupos.
+ for b in range(gt.shape[0]):
+ vb = valid[b]
+ if not bool(vb.any()):
+ continue
+ gb = gt[b][vb]
+ pb = pred[b][vb]
+ tp = int(((gb == 1) & (pb == 1)).sum())
+ fp = int(((gb == 0) & (pb == 1)).sum())
+ fn = int(((gb == 1) & (pb == 0)).sum())
+ tn = int(((gb == 0) & (pb == 0)).sum())
+
+ self.frames_total += 1
+ has_target = bool((gb == 1).any())
+ if has_target:
+ self.frames_with_target += 1
+ if tp > 0:
+ self.frames_target_detected += 1
+ else:
+ self.frames_target_completely_missed += 1
+
+ if cana is not None:
+ cb = cana_mask[b]
+ if bool(cb.any()):
+ self.frames_with_cana += 1
+ if not bool(((pred[b] == 1) & cb).any()):
+ self.frames_cana_clean += 1
+
+ if sem is not None:
+ grb = ground_mask[b]
+ if bool(grb.any()):
+ self.frames_with_ground += 1
+ if not bool(((pred[b] == 1) & grb).any()):
+ self.frames_ground_clean += 1
+
+ denom = tp + fp + fn
+ if denom > 0:
+ self.macro_target_iou_sum += tp / denom
+ self.macro_target_iou_count += 1
+
+ if self.scenario_metrics:
+ pct = float((gb == 1).float().mean())
+ if pct == 0:
+ bucket = "none"
+ elif pct <= 0.005:
+ bucket = "tiny"
+ elif pct <= 0.02:
+ bucket = "small"
+ elif pct <= 0.10:
+ bucket = "medium"
+ else:
+ bucket = "large"
+ update_confusion_matrix(self.scenario[bucket], pb, gb, 2, ignore_index=255)
+
+ if self.group_metrics:
+ group = str(groups[b] if b < len(groups) else "unknown")
+ gs = self.group_stats.setdefault(group, self._empty_group_stats())
+ gs["frames"] += 1
+ gs["tp"] += tp; gs["fp"] += fp; gs["fn"] += fn; gs["tn"] += tn
+ if cana is not None:
+ cb = cana_mask[b]
+ gs["cana_total"] += int(cb.sum())
+ gs["cana_sprayed"] += int(((pred[b] == 1) & cb).sum())
+ if sem is not None:
+ grb = ground_mask[b]
+ gs["ground_total"] += int(grb.sum())
+ gs["ground_sprayed"] += int(((pred[b] == 1) & grb).sum())
+ tb = target_mask[b]
+ gs["target_total"] += int(tb.sum())
+ gs["target_missed"] += int(((pred[b] == 0) & tb).sum())
+
+ if not self.rich:
+ return
+
+ pv = prob[valid].float()
+ gv = gt[valid].long()
+ cv = cana_mask[valid]
+ grv = ground_mask[valid]
+
+ for t in self.thresholds:
+ pp = pv >= float(t)
+ st = self.threshold_counts[t]
+ st["tp"] += int(((pp == 1) & (gv == 1)).sum())
+ st["fp"] += int(((pp == 1) & (gv == 0)).sum())
+ st["fn"] += int(((pp == 0) & (gv == 1)).sum())
+ st["tn"] += int(((pp == 0) & (gv == 0)).sum())
+ st["predicted_target"] += int(pp.sum())
+ if cana is not None:
+ st["cana_sprayed"] += int((pp & cv).sum())
+ if sem is not None:
+ st["ground_sprayed"] += int((pp & grv).sum())
+
+ # ECE em FP32, evitando overflow de soma em FP16 durante amp_val.
+ conf = torch.maximum(pv, 1.0 - pv).float()
+ correct = ((pv >= 0.5).long() == gv).float()
+ bins = torch.clamp((conf * self.ece_bins).long(), 0, self.ece_bins - 1)
+ for i in range(self.ece_bins):
+ m = bins == i
+ if bool(m.any()):
+ n = int(m.sum())
+ self.ece_count[i] += n
+ self.ece_conf[i] += float(conf[m].float().sum().cpu())
+ self.ece_acc[i] += float(correct[m].float().sum().cpu())
+
+ gvf = gv.float()
+ self.brier_sum += float(torch.sum((pv - gvf) ** 2).cpu())
+ pclip = torch.clamp(pv, 1e-6, 1.0 - 1e-6)
+ self.nll_sum += float(torch.sum(-(gvf * torch.log(pclip) + (1.0 - gvf) * torch.log(1.0 - pclip))).cpu())
+ self.calibration_count += int(pv.numel())
+
+ def _operational_score(self, row: dict) -> float:
+ cfg = self.operational_cfg or {}
+ w = cfg.get("score_weights", {}) or {}
+ vals = {
+ "target_iou": float(row.get("iou", 0.0)),
+ "target_f1": float(row.get("f1", 0.0)),
+ "cana_safety": float(row.get("cana_safety", 0.0)),
+ "ground_safety": 1.0 - float(row.get("ground_spray_rate", 1.0)),
+ "weed_recall": 1.0 - float(row.get("weed_miss_rate", 1.0)),
+ }
+ defaults = {"target_iou":0.35, "target_f1":0.20, "cana_safety":0.25, "ground_safety":0.10, "weed_recall":0.10}
+ total_w = 0.0
+ score = 0.0
+ for k, dv in defaults.items():
+ wk = float(w.get(k, dv))
+ total_w += wk
+ score += wk * vals[k]
+ return score / max(total_w, 1e-9)
+
+ def compute(self):
+ eps = 1e-9
+ out = {
+ "cana_spray_rate": self.cana_sprayed / max(1, self.cana_total),
+ "cana_safety": 1.0 - self.cana_sprayed / max(1, self.cana_total),
+ "ground_spray_rate": self.ground_sprayed / max(1, self.ground_total),
+ "ground_safety": 1.0 - self.ground_sprayed / max(1, self.ground_total),
+ "weed_miss_rate": self.target_missed / max(1, self.target_total),
+ "weed_recall": 1.0 - self.target_missed / max(1, self.target_total),
+ "predicted_target_rate": self.predicted_target_total / max(1, self.valid_total),
+ "macro_target_iou": self.macro_target_iou_sum / max(1, self.macro_target_iou_count),
+ "macro_target_iou_positive_union": self.macro_target_iou_sum / max(1, self.macro_target_iou_count),
+ "frame_target_detection_rate": self.frames_target_detected / max(1, self.frames_with_target),
+ "frame_target_complete_miss_rate": self.frames_target_completely_missed / max(1, self.frames_with_target),
+ "frame_cana_clean_rate": self.frames_cana_clean / max(1, self.frames_with_cana),
+ "frame_ground_clean_rate": self.frames_ground_clean / max(1, self.frames_with_ground),
+ "target_composed_disagreement_rate": self.composed_disagree / max(1, self.composed_valid),
+ "target_composed_agreement": 1.0 - self.composed_disagree / max(1, self.composed_valid),
+ "target_composed_iou": self.composed_intersection / max(1, self.composed_union),
+ "counts": {
+ "cana_total": self.cana_total, "cana_sprayed": self.cana_sprayed,
+ "ground_total": self.ground_total, "ground_sprayed": self.ground_sprayed,
+ "target_total": self.target_total, "target_missed": self.target_missed,
+ "valid_total": self.valid_total, "predicted_target_total": self.predicted_target_total,
+ "frames_total": self.frames_total, "frames_with_target": self.frames_with_target,
+ "frames_with_cana": self.frames_with_cana, "frames_with_ground": self.frames_with_ground,
+ },
+ }
+
+ sweep = {}
+ best_f1 = {"threshold": 0.5, "f1": -1.0, "iou": -1.0}
+
+ # Contrato:
+ # best_operational_threshold só existe quando há um threshold SAFE.
+ best_operational = {
+ "threshold": None,
+ "operational_score": -1.0,
+ "constraints_met": False,
+ }
+ feasible_rows = []
+
+ if self.rich:
+ max_cana = float(self.operational_cfg.get("max_cana_spray_rate", 0.02))
+ max_ground = float(self.operational_cfg.get("max_ground_spray_rate", 0.03))
+ max_miss = float(self.operational_cfg.get("max_weed_miss_rate", 0.20))
+
+ for t, st in self.threshold_counts.items():
+ bm = self._binary_metrics(st["tp"], st["fp"], st["fn"], st["tn"])
+ cana_spray = st["cana_sprayed"] / max(1, self.cana_total)
+ ground_spray = st["ground_sprayed"] / max(1, self.ground_total)
+ weed_miss = st["fn"] / max(1, self.target_total)
+ row = {
+ **bm,
+ "threshold": float(t),
+ "cana_spray_rate": cana_spray,
+ "cana_safety": 1.0 - cana_spray,
+ "ground_spray_rate": ground_spray,
+ "ground_safety": 1.0 - ground_spray,
+ "weed_miss_rate": weed_miss,
+ "weed_recall": 1.0 - weed_miss,
+ "predicted_target_rate": st["predicted_target"] / max(1, self.valid_total),
+ }
+ has_required_domains = (
+ self.cana_total > 0
+ and self.ground_total > 0
+ and self.target_total > 0
+ )
+ row["constraints_met"] = bool(
+ has_required_domains and
+ cana_spray <= max_cana and ground_spray <= max_ground and weed_miss <= max_miss
+ )
+ row["operational_score"] = self._operational_score(row)
+ sweep[str(t)] = row
+ if row["constraints_met"]:
+ feasible_rows.append(row)
+ if row["f1"] > best_f1["f1"]:
+ best_f1 = {"threshold": float(t), "f1": row["f1"], "iou": row["iou"]}
+
+ if feasible_rows:
+ chosen = max(
+ feasible_rows,
+ key=lambda r: r["operational_score"],
+ )
+ best_operational = dict(chosen)
+ best_operational["constraints_met"] = True
+
+ out["threshold_sweep"] = sweep
+ out["best_threshold"] = best_f1
+ out["best_operational_threshold"] = best_operational
+
+ total = self.ece_count.sum()
+ ece = 0.0
+ if self.rich and total > 0:
+ for n, cs, acs in zip(self.ece_count, self.ece_conf, self.ece_acc):
+ if n > 0:
+ ece += (n / total) * abs((acs / n) - (cs / n))
+ out["target_ece"] = float(ece) if self.rich else None
+ out["target_brier"] = (self.brier_sum / max(1, self.calibration_count)) if self.rich else None
+ out["target_nll"] = (self.nll_sum / max(1, self.calibration_count)) if self.rich else None
+
+ out["scenario_target"] = {}
+ if self.scenario_metrics:
+ for name, cm in self.scenario.items():
+ miou, ious = compute_iou_from_cm(cm)
+ bs = binary_stats_from_cm(cm)
+ out["scenario_target"][name] = {
+ "miou": miou,
+ "iou_target": ious[1],
+ **bs,
+ "cm": cm.detach().cpu().tolist(),
+ }
+
+ out["group_target"] = {}
+ if self.group_metrics:
+ for group, gs in sorted(self.group_stats.items()):
+ bm = self._binary_metrics(gs["tp"], gs["fp"], gs["fn"], gs["tn"])
+ out["group_target"][group] = {
+ **bm,
+ "frames": int(gs["frames"]),
+ "cana_spray_rate": gs["cana_sprayed"] / max(1, gs["cana_total"]),
+ "cana_safety": 1.0 - gs["cana_sprayed"] / max(1, gs["cana_total"]),
+ "ground_spray_rate": gs["ground_sprayed"] / max(1, gs["ground_total"]),
+ "weed_miss_rate": gs["target_missed"] / max(1, gs["target_total"]),
+ "counts": dict(gs),
+ }
+ return out
+
+
+@torch.no_grad()
+def update_operational_target_cm(cm_target, pred_veg, pred_cana, gt_veg, gt_cana, ignore_index=255):
+ valid = (gt_veg != ignore_index) & (gt_cana != ignore_index)
+ gt = ((gt_veg == 1) & (gt_cana == 0)).long()[valid]
+ pred = ((pred_veg == 1) & (pred_cana == 0)).long()[valid]
+ if gt.numel():
+ cm_target += torch.bincount(gt.reshape(-1) * 2 + pred.reshape(-1), minlength=4).view(2, 2)
+
+
+# ============================================================
+# Train / Val
+# ============================================================
+
+def run_one_epoch(model, loader, optimizer, device, heads_config, criterions, amp, scaler, train,
+ grad_accum=1, normalizer=None, dice_weight=0.30, config=None, epoch=None,
+ scheduler=None, grad_clip_norm=1.0, global_step=0):
+ model.train(train)
+ total_loss, n_batches, loss_sums = 0.0, 0, {}
+ cms = {name: torch.zeros((int(h["num_classes"]), int(h["num_classes"])), dtype=torch.int64, device=device) for name, h in heads_config.items()}
+ cm_op = torch.zeros((2, 2), dtype=torch.int64, device=device)
+ tv2 = get_training_v2_config(config or {})
+ mm = tv2.get("metrics", {})
+ rich_metrics = (not train) or bool(mm.get("rich_train_metrics", False))
+ agri = AgriculturalMetricsMeter(
+ mm.get("target_thresholds", [0.3,0.4,0.5,0.6,0.7]),
+ mm.get("ece_bins", 12),
+ device,
+ rich=rich_metrics,
+ scenario_metrics=bool(mm.get("scenario_metrics", True)),
+ group_metrics=bool(mm.get("group_metrics", True)),
+ operational_cfg=mm.get("operational_threshold", {}) or {},
+ )
+ grad_norm_sum = grad_norm_max = 0.0; grad_norm_count = 0; optimizer_steps = 0; skipped_steps = 0
+ t0 = time.time()
+
+ if train and optimizer is not None:
+ optimizer.zero_grad(set_to_none=True)
+
+ with torch.set_grad_enabled(train):
+ total_loader_steps = len(loader)
+ for step, (imgs, masks, _meta) in enumerate(loader):
+ imgs = imgs.to(device, non_blocking=True)
+ masks = {k: v.to(device, non_blocking=True) for k, v in masks.items()}
+ imgs = normalizer(imgs) if normalizer is not None else normalize_per_batch(imgs)
+
+ window_start = (step // max(1, grad_accum)) * max(1, grad_accum)
+ window_end = min(window_start + max(1, grad_accum), total_loader_steps)
+ accum_divisor = max(1, window_end - window_start)
+
+ with autocast(device_type="cuda", enabled=amp and device.type == "cuda"):
+ logits_by_head = model(pixel_values=imgs)
+ loss_raw, loss_parts = compute_losses_for_batch(
+ logits_by_head, masks, heads_config, criterions,
+ dice_weight=dice_weight, config=config, epoch=epoch,
+ )
+ loss_backward = loss_raw / accum_divisor if train else loss_raw
+
+ if train and optimizer is not None:
+ if amp and scaler is not None and device.type == "cuda":
+ scaler.scale(loss_backward).backward()
+ else:
+ loss_backward.backward()
+
+ boundary = ((step + 1) % max(1, grad_accum) == 0) or ((step + 1) == total_loader_steps)
+ if boundary:
+ if amp and scaler is not None and device.type == "cuda":
+ scaler.unscale_(optimizer)
+ gn = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=float(grad_clip_norm)) if grad_clip_norm and grad_clip_norm > 0 else torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=float("inf"))
+ gn_val = float(gn.detach().cpu()) if torch.is_tensor(gn) else float(gn)
+ grad_norm_sum += gn_val; grad_norm_max = max(grad_norm_max, gn_val); grad_norm_count += 1
+
+ did_step = True
+ if amp and scaler is not None and device.type == "cuda":
+ before = float(scaler.get_scale())
+ scaler.step(optimizer); scaler.update()
+ after = float(scaler.get_scale())
+ did_step = after >= before
+ else:
+ optimizer.step()
+
+ optimizer.zero_grad(set_to_none=True)
+ if did_step:
+ optimizer_steps += 1; global_step += 1
+ if scheduler is not None:
+ scheduler.step()
+ else:
+ skipped_steps += 1
+
+ total_loss += float(loss_raw.detach().item()); n_batches += 1
+ for k, v in loss_parts.items():
+ loss_sums[k] = loss_sums.get(k, 0.0) + float(v.item())
+
+ with torch.no_grad():
+ preds = {}
+ for head_name, logits in logits_by_head.items():
+ if head_name not in masks:
+ continue
+ lm = resize_logits_to_target_if_needed(logits, masks[head_name])
+ pr = torch.argmax(lm, dim=1); preds[head_name] = pr
+ update_confusion_matrix(cms[head_name], pr, masks[head_name], int(heads_config[head_name]["num_classes"]), int(heads_config[head_name].get("ignore_index", 255)))
+ composed_pred = None
+ if "vegetation" in preds and "cana" in preds:
+ update_operational_target_cm(cm_op, preds["vegetation"], preds["cana"], masks["vegetation"], masks["cana"], int(heads_config["vegetation"].get("ignore_index", 255)))
+ composed_pred = ((preds["vegetation"] == 1) & (preds["cana"] == 0)).long()
+ if "target" in logits_by_head:
+ agri.update(logits_by_head["target"], masks, heads_config, meta=_meta, composed_pred=composed_pred)
+
+ metrics = {
+ "loss": total_loss / max(1, n_batches), "time_s": time.time() - t0,
+ "heads": {}, "loss_parts": {k: v/max(1,n_batches) for k,v in loss_sums.items()},
+ "optimization": {"optimizer_steps": optimizer_steps, "skipped_steps": skipped_steps, "global_step": global_step,
+ "grad_norm_mean": grad_norm_sum/max(1,grad_norm_count), "grad_norm_max": grad_norm_max},
+ }
+ for name, cm in cms.items():
+ miou, ious = compute_iou_from_cm(cm); acc = compute_pixel_acc_from_cm(cm)
+ hm = {"miou": miou, "iou_per_class": ious, "acc": acc, "cm": cm.detach().cpu().tolist()}
+ if cm.shape == (2,2):
+ hm.update(binary_stats_from_cm(cm))
+ metrics["heads"][name] = hm
+ op_miou, op_iou = compute_iou_from_cm(cm_op)
+ metrics["operational_target"] = {"miou": op_miou, "iou_background": op_iou[0], "iou_target": op_iou[1], "acc": compute_pixel_acc_from_cm(cm_op), "cm": cm_op.detach().cpu().tolist(), **binary_stats_from_cm(cm_op)}
+ metrics["agricultural"] = agri.compute()
+
+ # Confusões semanticamente importantes para campo: cana confundida com
+ # erva é muito mais crítica que uma simples queda de mIoU.
+ if "semantic" in cms:
+ scm = cms["semantic"].detach().float().cpu()
+ classes = heads_config.get("semantic", {}).get("classes", {}) or {}
+ cid = int(classes.get("cana", 1)); eid = int(classes.get("erva", 2)); gid = int(classes.get("chao", 0))
+ def _row_rate(src, dst):
+ if src < 0 or dst < 0 or src >= scm.shape[0] or dst >= scm.shape[1]:
+ return 0.0
+ den = float(scm[src, :].sum())
+ return float(scm[src, dst]) / max(den, 1e-9)
+ metrics["agricultural"].update({
+ "semantic_cana_as_erva_rate": _row_rate(cid, eid),
+ "semantic_erva_as_cana_rate": _row_rate(eid, cid),
+ "semantic_cana_as_ground_rate": _row_rate(cid, gid),
+ "semantic_erva_as_ground_rate": _row_rate(eid, gid),
+ })
+ return metrics
+
+
+# ============================================================
+# Selection / checkpoints / logs
+# ============================================================
+
+def compute_selection_score(va: dict, config: Optional[dict] = None) -> dict:
+ heads, ag = va.get("heads", {}), va.get("agricultural", {})
+ target_vals = heads.get("target", {}).get("iou_per_class", []) or []
+ cana_vals = heads.get("cana", {}).get("iou_per_class", []) or []
+ target_iou = safe_float(target_vals[1] if len(target_vals) > 1 else va.get("operational_target", {}).get("iou_target"), 0.0)
+ cana_iou = safe_float(cana_vals[1] if len(cana_vals) > 1 else 0.0, 0.0)
+ target_f1 = safe_float(heads.get("target", {}).get("f1"), 0.0)
+ veg_miou = safe_float(heads.get("vegetation", {}).get("miou"), 0.0)
+ sem_miou = safe_float(heads.get("semantic", {}).get("miou"), 0.0)
+ cana_safety = safe_float(ag.get("cana_safety"), 0.0)
+
+ weights = get_training_v2_config(config or {}).get("selection_score", {})
+ score = (
+ float(weights.get("target_iou", 0.40)) * target_iou +
+ float(weights.get("cana_iou", 0.20)) * cana_iou +
+ float(weights.get("target_f1", 0.10)) * target_f1 +
+ float(weights.get("vegetation_miou", 0.10)) * veg_miou +
+ float(weights.get("semantic_miou", 0.05)) * sem_miou +
+ float(weights.get("cana_safety", 0.15)) * cana_safety
+ )
+
+ # Régua histórica exata do trainer anterior. Isso NÃO torna datasets,
+ # resolução ou preprocessamento diferentes diretamente comparáveis, mas
+ # permite olhar o mesmo score quando fizermos um A/B controlado.
+ legacy_score = (
+ 0.50 * target_iou +
+ 0.25 * cana_iou +
+ 0.15 * veg_miou +
+ 0.10 * sem_miou
+ )
+
+ best_op = ag.get("best_operational_threshold", {}) or {}
+ operational_score = safe_float(best_op.get("operational_score"), -1.0)
+
+ return {
+ "score": float(score),
+ "legacy_score": float(legacy_score),
+ "operational_score": float(operational_score),
+ "operational_threshold": best_op.get("threshold"),
+ "operational_constraints_met": bool(best_op.get("constraints_met", False)),
+ "target_iou": target_iou,
+ "target_head_iou": target_iou,
+ "operational_target_iou": safe_float(va.get("operational_target", {}).get("iou_target"), 0.0),
+ "cana_head_iou": cana_iou,
+ "target_f1": target_f1,
+ "vegetation_miou": veg_miou,
+ "semantic_miou": sem_miou,
+ "cana_safety": cana_safety,
+ "cana_spray_rate": safe_float(ag.get("cana_spray_rate"), 1.0),
+ }
+
+
+def atomic_torch_save(obj, path: Path):
+ ensure_dir(path.parent)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ torch.save(obj, tmp)
+ os.replace(tmp, path)
+
+
+def save_checkpoint(path, model, optimizer=None, scaler=None, epoch=0, best=None, extra=None,
+ scheduler=None, epochs_without_improve=0, global_step=0):
+ cfg = getattr(model, "config", None)
+ ckpt = {
+ "epoch": int(epoch), "model": model.state_dict(), "best": best or {},
+ "trainer_version": TRAINER_VERSION,
+ "model_contract": {
+ "num_channels": int(getattr(cfg, "num_channels", 0) or 0),
+ "input_channel_names": list(getattr(cfg, "input_channel_names", []) or []),
+ "decoder_mode": str(getattr(cfg, "agri_decoder_mode", "separate")),
+ "spectral_input_init": str(getattr(cfg, "agri_spectral_input_init", "mean_rgb")),
+ },
+ "epochs_without_improve": int(epochs_without_improve),
+ "global_step": int(global_step),
+ "rng_state": capture_rng_state(),
+ }
+ if optimizer is not None: ckpt["optimizer"] = optimizer.state_dict()
+ if scheduler is not None: ckpt["scheduler"] = scheduler.state_dict()
+ if scaler is not None: ckpt["scaler"] = scaler.state_dict()
+ if extra: ckpt["extra"] = extra
+ atomic_torch_save(ckpt, Path(path))
+
+
+def load_checkpoint(path, model, optimizer=None, scaler=None, map_location="cpu", scheduler=None, restore_rng=False):
+ ckpt = torch.load(path, map_location=map_location, weights_only=False)
+ saved = ckpt.get("model_contract", {}) or {}; cfg = getattr(model, "config", None)
+ cur_n = int(getattr(cfg, "num_channels", 0) or 0); cur_names = list(getattr(cfg, "input_channel_names", []) or [])
+ if int(saved.get("num_channels", 0) or 0) not in (0, cur_n):
+ raise RuntimeError(f"Checkpoint canais incompatíveis: saved={saved.get('num_channels')} atual={cur_n}")
+ if saved.get("input_channel_names") and cur_names and list(saved["input_channel_names"]) != cur_names:
+ raise RuntimeError(f"Checkpoint canais nominais incompatíveis: saved={saved['input_channel_names']} atual={cur_names}")
+ saved_decoder = str(saved.get("decoder_mode", ""))
+ cur_decoder = str(getattr(cfg, "agri_decoder_mode", ""))
+ if saved_decoder and cur_decoder and saved_decoder != cur_decoder:
+ raise RuntimeError(f"Checkpoint decoder_mode={saved_decoder}, modelo atual={cur_decoder}")
+ model.load_state_dict(ckpt["model"], strict=True)
+ if optimizer is not None and "optimizer" in ckpt:
+ optimizer.load_state_dict(ckpt["optimizer"])
+ if scheduler is not None and "scheduler" in ckpt:
+ scheduler.load_state_dict(ckpt["scheduler"])
+ if scaler is not None and "scaler" in ckpt:
+ scaler.load_state_dict(ckpt["scaler"])
+ if restore_rng:
+ restore_rng_state(ckpt.get("rng_state"))
+ return ckpt
+
+
+def append_train_log(path: Path, row: dict):
+ ensure_dir(path.parent); exists = path.exists()
+ with path.open("a", newline="", encoding="utf-8") as f:
+ w = csv.DictWriter(f, fieldnames=list(row.keys()))
+ if not exists: w.writeheader()
+ w.writerow(row)
+
+
+def append_jsonl(path: Path, obj: dict):
+ ensure_dir(path.parent)
+ with path.open("a", encoding="utf-8") as f:
+ f.write(json.dumps(obj, ensure_ascii=False, default=str) + "\n")
+
+
+def flatten_epoch_log(epoch, lr, tr, va, best, score_now: Optional[dict] = None):
+ score_now = score_now or {}
+ row = {
+ "epoch": epoch, "lr": lr,
+ "train_loss": tr["loss"], "val_loss": va["loss"],
+ "score": score_now.get("score"),
+ "legacy_score": score_now.get("legacy_score"),
+ "operational_score": score_now.get("operational_score"),
+ "operational_threshold": score_now.get("operational_threshold"),
+ "operational_constraints_met": score_now.get("operational_constraints_met"),
+ "best_score": best.get("score", -1),
+ "best_legacy_score": best.get("legacy_score", -1),
+ "best_operational_score": best.get("operational_score", -1),
+ "best_target_iou": best.get("target_iou", -1),
+ "best_cana_head_iou": best.get("cana_head_iou", -1),
+ "best_semantic_miou": best.get("semantic_miou", -1),
+ "best_cana_safety": best.get("cana_safety", -1),
+ }
+ for prefix, obj in (("train", tr), ("val", va)):
+ for hn, hm in obj.get("heads", {}).items():
+ row[f"{prefix}_{hn}_miou"] = hm.get("miou")
+ row[f"{prefix}_{hn}_acc"] = hm.get("acc")
+ for k in ("precision", "recall", "f1", "specificity"):
+ if k in hm:
+ row[f"{prefix}_{hn}_{k}"] = hm[k]
+ for i, v in enumerate(hm.get("iou_per_class", []) or []):
+ row[f"{prefix}_{hn}_iou_{i}"] = v
+
+ op = obj.get("operational_target", {}) or {}
+ row[f"{prefix}_op_target_iou"] = op.get("iou_target")
+ row[f"{prefix}_op_target_f1"] = op.get("f1")
+
+ ag = obj.get("agricultural", {}) or {}
+ for k in (
+ "cana_spray_rate", "cana_safety", "ground_spray_rate", "ground_safety",
+ "weed_miss_rate", "weed_recall", "macro_target_iou",
+ "frame_target_detection_rate", "frame_target_complete_miss_rate",
+ "frame_cana_clean_rate", "frame_ground_clean_rate",
+ "target_composed_disagreement_rate", "target_composed_agreement", "target_composed_iou",
+ "semantic_cana_as_erva_rate", "semantic_erva_as_cana_rate",
+ "semantic_cana_as_ground_rate", "semantic_erva_as_ground_rate",
+ "target_ece", "target_brier", "target_nll",
+ ):
+ row[f"{prefix}_{k}"] = ag.get(k)
+
+ bt = ag.get("best_threshold", {}) or {}
+ row[f"{prefix}_best_threshold"] = bt.get("threshold")
+ row[f"{prefix}_best_threshold_f1"] = bt.get("f1")
+ row[f"{prefix}_best_threshold_iou"] = bt.get("iou")
+
+ bop = ag.get("best_operational_threshold", {}) or {}
+ row[f"{prefix}_best_op_threshold"] = bop.get("threshold")
+ row[f"{prefix}_best_op_score"] = bop.get("operational_score")
+ row[f"{prefix}_best_op_constraints_met"] = bop.get("constraints_met")
+ row[f"{prefix}_best_op_spray_cana"] = bop.get("cana_spray_rate")
+ row[f"{prefix}_best_op_spray_ground"] = bop.get("ground_spray_rate")
+ row[f"{prefix}_best_op_weed_miss"] = bop.get("weed_miss_rate")
+
+ # Sweep completo em CSV para análise posterior sem precisar parsear JSONL.
+ for thr, tm in sorted((ag.get("threshold_sweep", {}) or {}).items(), key=lambda kv: float(kv[0])):
+ tag = str(thr).replace(".", "p")
+ for k in ("iou", "f1", "precision", "recall", "cana_spray_rate", "ground_spray_rate", "weed_miss_rate", "operational_score", "constraints_met"):
+ row[f"{prefix}_thr_{tag}_{k}"] = tm.get(k)
+
+ for group, gm in sorted((ag.get("group_target", {}) or {}).items()):
+ gtag = str(group).replace(" ", "_")
+ for k in ("iou", "f1", "precision", "recall", "cana_spray_rate", "ground_spray_rate", "weed_miss_rate"):
+ row[f"{prefix}_group_{gtag}_{k}"] = gm.get(k)
+
+ for scen, sm in sorted((ag.get("scenario_target", {}) or {}).items()):
+ for k in ("iou_target", "f1", "precision", "recall"):
+ row[f"{prefix}_scenario_{scen}_{k}"] = sm.get(k)
+
+ optm = obj.get("optimization", {}) or {}
+ for k in ("global_step", "optimizer_steps", "skipped_steps", "grad_norm_mean", "grad_norm_max"):
+ row[f"{prefix}_{k}"] = optm.get(k)
+ for k, v in obj.get("loss_parts", {}).items():
+ row[f"{prefix}_{k}"] = v
+ return row
+
+
+def pretty_iou(names: Dict[int, str], vals: List[float]):
+ return " | ".join(f"{names.get(i,i)}:{v:.3f}" for i,v in enumerate(vals))
+
+
+def binary_iou_text(head_name, vals):
+ names = {"vegetation": {0:"bg",1:"veg"}, "cana": {0:"not_cana",1:"cana"}, "target": {0:"bg",1:"target"}}.get(head_name,{0:"0",1:"1"})
+ return pretty_iou(names, vals)
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config", default="config.json")
+ parser.add_argument("--epochs", type=int, default=80)
+ parser.add_argument("--batch", type=int, default=1)
+ parser.add_argument("--lr", type=float, default=3e-5)
+ parser.add_argument("--wd", type=float, default=0.01)
+ parser.add_argument("--num_workers", type=int, default=2)
+ parser.add_argument("--amp", action="store_true")
+ parser.add_argument("--amp_val", action="store_true")
+ parser.add_argument("--grad_accum", type=int, default=4)
+ parser.add_argument("--grad_ckpt", action="store_true")
+ parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument("--ignore_index", type=int, default=None)
+ parser.add_argument("--class_weights", default="auto")
+ parser.add_argument("--dice_weight", type=float, default=0.30)
+ parser.add_argument("--norm_stats", default=None)
+ parser.add_argument("--allow-missing-norm-stats", dest="allow_missing_norm_stats", action="store_true")
+ parser.add_argument("--src-root", default="dataset/split")
+ parser.add_argument("--save-every", type=int, default=10)
+ parser.add_argument("--resume", action="store_true")
+ parser.add_argument("--resume-ckpt", default=None)
+ parser.add_argument("--early-stop", type=int, default=25)
+ # Legado + overrides V2
+ parser.add_argument("--balanced_sampler", action="store_true")
+ parser.add_argument("--samples_per_epoch", type=int, default=0)
+ parser.add_argument("--sampler", choices=["random","diverse","weighted"], default=None)
+ parser.add_argument("--decoder-mode", choices=["separate","shared_light"], default=None)
+ parser.add_argument("--spectral-init", choices=["mean_rgb","zero_extra","scaled_mean"], default=None)
+ parser.add_argument("--no-augment", action="store_true")
+ parser.add_argument("--grad-clip", type=float, default=None)
+ args = parser.parse_args()
+
+ set_seed(args.seed)
+ config = load_json(args.config)
+ tv2 = get_training_v2_config(config)
+ if args.decoder_mode: tv2["model"]["decoder_mode"] = args.decoder_mode
+ if args.spectral_init: tv2["model"]["spectral_input_init"] = args.spectral_init
+ if args.no_augment: tv2["augmentation"]["enabled"] = False
+ if args.sampler: tv2["sampler"]["mode"] = args.sampler
+ if args.balanced_sampler: tv2["sampler"]["mode"] = "weighted"
+ if args.samples_per_epoch > 0: tv2["sampler"]["samples_per_epoch"] = args.samples_per_epoch
+ if args.grad_clip is not None: tv2["optimization"]["grad_clip_norm"] = args.grad_clip
+ config["training_v2"] = tv2
+
+ optcfg = tv2.get("optimization", {})
+ try: torch.set_float32_matmul_precision(str(optcfg.get("matmul_precision", "high")))
+ except Exception: pass
+ if torch.backends.cudnn.is_available():
+ torch.backends.cudnn.benchmark = bool(optcfg.get("cudnn_benchmark", True))
+
+ W, H = config["resolucao"]
+ backbone = config.get("backbone", "nvidia/mit-b1")
+ input_channel_names = get_input_channel_names(config); channels = len(input_channel_names)
+ if config.get("fusion_mode", "stacked") != "stacked": raise RuntimeError("Este script exige fusion_mode='stacked'.")
+ model_family = config.get("modelo", "segformer"); model_name = config.get("model_name", "test")
+ stats_source_tag = config.get("stats_source_tag", f"stacked_raw{channels}")
+ save_dir = Path("backup") / model_family / model_name / stats_source_tag; ensure_dir(save_dir)
+
+ labelmap_path = Path("dataset") / "labelmap.txt"
+ semantic_id2label, semantic_label2id, ignore_from_labelmap = load_labelmap(str(labelmap_path))
+ ignore_index = int(args.ignore_index if args.ignore_index is not None else ignore_from_labelmap)
+ heads_config = build_heads_config(config, ignore_index)
+ heads_config["semantic"]["num_classes"] = len(semantic_id2label); heads_config["semantic"]["ignore_index"] = ignore_index
+
+ print("="*70)
+ print(f"Agri Teacher V2 | {TRAINER_VERSION}")
+ print(f"Backbone={backbone} | resolution={W}x{H} | channels={input_channel_names}")
+ print(f"decoder={tv2['model']['decoder_mode']} | spectral_init={tv2['model']['spectral_input_init']}")
+ print(f"augmentation={tv2['augmentation']['enabled']} | sampler={tv2['sampler']['mode']}")
+ print(f"save_dir={save_dir}")
+ for name,h in heads_config.items(): print(f"HEAD {name}: classes={h['num_classes']} weight={h['loss_weight']} norm={h['loss_weight_norm']:.3f}")
+ print("="*70)
+
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu"); print(f"Device={device}")
+ train_root, val_root = Path(args.src_root)/"train", Path(args.src_root)/"val"
+ resize_hw = (H, W)
+ ds_train = OakFcc3TensorMultiHeadDataset(
+ train_root, heads_config, channels, input_channel_names, True, resize_hw,
+ augment=bool(tv2["augmentation"].get("enabled", True)), augmentation_config=tv2["augmentation"],
+ derived_config=config.get("derived_channels", {}) or {},
+ data_validation_config=tv2.get("data", {}) or {},
+ )
+ ds_val = OakFcc3TensorMultiHeadDataset(
+ val_root, heads_config, channels, input_channel_names, True, resize_hw,
+ augment=False, augmentation_config=None, derived_config=config.get("derived_channels", {}) or {},
+ data_validation_config=tv2.get("data", {}) or {},
+ )
+ print(f"[DATA] train={len(ds_train)} val={len(ds_val)}")
+
+ sampler_mode = str(tv2["sampler"].get("mode", "diverse")).lower()
+ common_dl = dict(num_workers=args.num_workers, pin_memory=True, collate_fn=collate_fn, worker_init_fn=seed_worker)
+ if args.num_workers > 0:
+ common_dl["persistent_workers"] = bool(optcfg.get("persistent_workers", True))
+ common_dl["prefetch_factor"] = int(optcfg.get("prefetch_factor", 2))
+
+ diverse_batch_sampler = None
+ if sampler_mode == "diverse":
+ diverse_batch_sampler = DiverseAgriculturalBatchSampler(ds_train, args.batch, tv2["sampler"], args.seed)
+ dl_train = DataLoader(ds_train, batch_sampler=diverse_batch_sampler, **common_dl)
+ elif sampler_mode == "weighted":
+ weights = build_sample_weights(ds_train)
+ ns = int(tv2["sampler"].get("samples_per_epoch", 0) or 0) or len(ds_train)
+ sampler = WeightedRandomSampler(weights, num_samples=ns, replacement=True)
+ dl_train = DataLoader(ds_train, batch_size=args.batch, sampler=sampler, shuffle=False, drop_last=False, **common_dl)
+ print(f"[SAMPLER:WEIGHTED] min={float(weights.min()):.3f} max={float(weights.max()):.3f} mean={float(weights.mean()):.3f}")
+ else:
+ dl_train = DataLoader(ds_train, batch_size=args.batch, shuffle=True, drop_last=False, **common_dl)
+
+ val_workers = max(0, args.num_workers // 2)
+ dl_val_kwargs = dict(batch_size=1, shuffle=False, num_workers=val_workers, pin_memory=True, collate_fn=collate_fn, worker_init_fn=seed_worker, drop_last=False)
+ if val_workers > 0:
+ dl_val_kwargs["persistent_workers"] = bool(optcfg.get("persistent_workers", True)); dl_val_kwargs["prefetch_factor"] = int(optcfg.get("prefetch_factor", 2))
+ dl_val = DataLoader(ds_val, **dl_val_kwargs)
+
+ normalizer, norm_stats_path = build_normalizer(config, args, device)
+ experiment_stats_path = None
+ if normalizer is not None:
+ source_stats = load_json(norm_stats_path) if norm_stats_path else {}
+ selected = {"channels": input_channel_names, "mean": normalizer.mean.detach().cpu().view(-1).tolist(),
+ "std": normalizer.std.detach().cpu().view(-1).tolist(), "pixels": source_stats.get("pixels"),
+ "source_norm_stats": norm_stats_path, "stats_source_tag": stats_source_tag}
+ experiment_stats_path = save_dir/"norm_stats.json"; save_json(experiment_stats_path, selected)
+
+ model = build_model(backbone, input_channel_names, heads_config, semantic_id2label, semantic_label2id, config=config)
+ if args.grad_ckpt:
+ try: model.segformer.gradient_checkpointing_enable(); print("[MODEL] gradient checkpointing enabled")
+ except Exception as e: print(f"[WARN] gradient checkpointing: {e}")
+ model.to(device)
+
+ criterions, weight_debug = build_criterions(ds_train, heads_config, args.class_weights, device, args.seed, tv2.get("class_weighting", {}))
+ optimizer = build_optimizer(model, args.lr, args.wd, tv2.get("optimizer", {}))
+ optimizer_steps_per_epoch = max(1, math.ceil(len(dl_train) / max(1, args.grad_accum)))
+ total_optimizer_steps = optimizer_steps_per_epoch * args.epochs
+ scheduler = WarmupDecayScheduler(optimizer, total_optimizer_steps, tv2.get("scheduler", {}))
+ scaler = GradScaler(enabled=args.amp and device.type == "cuda")
+
+ snapshot = {
+ "trainer_version": TRAINER_VERSION, "config": config, "args": vars(args), "training_v2": tv2,
+ "semantic_id2label": semantic_id2label, "heads_config": heads_config, "ignore_index": ignore_index,
+ "norm_stats_path": norm_stats_path, "experiment_norm_stats_path": str(experiment_stats_path) if experiment_stats_path else None,
+ "class_weight_debug": weight_debug, "input_channel_names": input_channel_names,
+ "tensor_contract": "normalized_v2_final_channel_order", "stats_source_tag": stats_source_tag,
+ "optimizer_steps_per_epoch": optimizer_steps_per_epoch, "total_optimizer_steps": total_optimizer_steps,
+ }
+ snapshot_default = save_dir/"train_config_snapshot.json"
+ snapshot_path = snapshot_default if not snapshot_default.exists() else save_dir/f"train_config_snapshot_{TRAINER_VERSION}.json"
+ save_json(snapshot_path, snapshot)
+
+ last_path=save_dir/"last.pt"; best_score_path=save_dir/"best_score.pt"; best_target_path=save_dir/"best_target.pt"
+ best_cana_path=save_dir/"best_cana_head.pt"; best_sem_path=save_dir/"best_semantic_miou.pt"; best_safety_path=save_dir/"best_cana_safety.pt"
+ best_legacy_path=save_dir/"best_legacy_score.pt"; best_operational_path=save_dir/"best_operational.pt"
+
+ # Se o diretório já contém logs V2.0, não misturamos headers CSV diferentes.
+ legacy_train_log = save_dir/"train_log.csv"
+ legacy_jsonl = save_dir/"train_metrics.jsonl"
+ train_log_path = legacy_train_log if not legacy_train_log.exists() else save_dir/f"train_log_{TRAINER_VERSION}.csv"
+ jsonl_path = legacy_jsonl if not legacy_jsonl.exists() else save_dir/f"train_metrics_{TRAINER_VERSION}.jsonl"
+
+ start_epoch=1; global_step=0; epochs_without_improve=0
+ best={
+ "score":-1.0, "legacy_score":-1.0, "operational_score":-1.0,
+ "target_iou":-1.0, "cana_head_iou":-1.0, "semantic_miou":-1.0,
+ "vegetation_miou":-1.0, "cana_safety":-1.0
+ }
+ resume_path = Path(args.resume_ckpt) if args.resume_ckpt else last_path
+ if args.resume and resume_path.exists():
+ ckpt = load_checkpoint(resume_path, model, optimizer, scaler, "cpu", scheduler=scheduler, restore_rng=True)
+ start_epoch=int(ckpt.get("epoch",0))+1; best=ckpt.get("best",best); global_step=int(ckpt.get("global_step",0)); epochs_without_improve=int(ckpt.get("epochs_without_improve",0))
+ print(f"[RESUME] {resume_path} -> epoch={start_epoch} global_step={global_step}")
+
+ min_delta = float(tv2.get("checkpoint",{}).get("early_stop_min_delta",5e-4))
+ best_score_for_es = float(best.get("score", -1.0))
+
+ for epoch in range(start_epoch, args.epochs+1):
+ if diverse_batch_sampler is not None: diverse_batch_sampler.set_epoch(epoch)
+ lr_now = [float(g["lr"]) for g in optimizer.param_groups]
+ print(f"\n==== Epoch {epoch}/{args.epochs} | lr={[f'{x:.2e}' for x in lr_now]} ====")
+ if device.type=="cuda": torch.cuda.reset_peak_memory_stats()
+
+ tr = run_one_epoch(model, dl_train, optimizer, device, heads_config, criterions, args.amp, scaler, True,
+ max(1,args.grad_accum), normalizer, args.dice_weight, config, epoch,
+ scheduler=scheduler, grad_clip_norm=float(tv2["optimization"].get("grad_clip_norm",1.0)), global_step=global_step)
+ global_step = int(tr.get("optimization",{}).get("global_step",global_step))
+ va = run_one_epoch(model, dl_val, None, device, heads_config, criterions, args.amp_val, None, False,
+ 1, normalizer, args.dice_weight, config, epoch, scheduler=None, grad_clip_norm=0, global_step=global_step)
+ score_now = compute_selection_score(va, config)
+
+ th = va["heads"].get("target",{}); ag = va.get("agricultural",{})
+ tr_th = tr["heads"].get("target", {})
+ print(
+ f"TRAIN loss={tr['loss']:.4f} "
+ f"sem_mIoU={tr['heads']['semantic']['miou']:.4f} "
+ f"cana_mIoU={tr['heads']['cana']['miou']:.4f} "
+ f"targetIoU={tr_th.get('iou_per_class',[0,0])[1]:.4f} "
+ f"t={tr['time_s']:.1f}s"
+ )
+ print(
+ f"VAL loss={va['loss']:.4f} "
+ f"sem_mIoU={va['heads']['semantic']['miou']:.4f} "
+ f"canaIoU={va['heads']['cana']['iou_per_class'][1]:.4f} "
+ f"targetIoU={th.get('iou_per_class',[0,0])[1]:.4f} "
+ f"P={th.get('precision',0):.4f} R={th.get('recall',0):.4f} F1={th.get('f1',0):.4f} "
+ f"score={score_now['score']:.4f}"
+ )
+ print(
+ f"LEGACY score={score_now.get('legacy_score',0):.4f} "
+ f"opTargetIoU={score_now.get('operational_target_iou',0):.4f}"
+ )
+ print(
+ f"AGRI@0.50 cana_safety={ag.get('cana_safety',0):.5f} "
+ f"spray_cana={100*ag.get('cana_spray_rate',0):.3f}% "
+ f"spray_ground={100*ag.get('ground_spray_rate',0):.3f}% "
+ f"weed_miss={100*ag.get('weed_miss_rate',0):.3f}% "
+ f"macroIoU={ag.get('macro_target_iou',0):.4f}"
+ )
+ bt = ag.get("best_threshold", {}) or {}
+ print(f"THR-F1 best={bt.get('threshold')} F1={bt.get('f1',0):.4f} IoU={bt.get('iou',0):.4f}")
+ bop = ag.get("best_operational_threshold", {}) or {}
+ print(
+ f"THR-OP best={bop.get('threshold')} score={bop.get('operational_score',0):.4f} "
+ f"safe={'YES' if bop.get('constraints_met',False) else 'NO'} "
+ f"spray_cana={100*safe_float(bop.get('cana_spray_rate'),0):.3f}% "
+ f"spray_ground={100*safe_float(bop.get('ground_spray_rate'),0):.3f}% "
+ f"weed_miss={100*safe_float(bop.get('weed_miss_rate'),0):.3f}%"
+ )
+ print(
+ f"CAL ECE={safe_float(ag.get('target_ece'),0):.4f} "
+ f"Brier={safe_float(ag.get('target_brier'),0):.5f} "
+ f"NLL={safe_float(ag.get('target_nll'),0):.5f}"
+ )
+ print(
+ f"SAFE cana->erva={100*ag.get('semantic_cana_as_erva_rate',0):.3f}% "
+ f"erva->cana={100*ag.get('semantic_erva_as_cana_rate',0):.3f}% "
+ f"target/composed disagree={100*ag.get('target_composed_disagreement_rate',0):.3f}% "
+ f"IoU={ag.get('target_composed_iou',0):.4f}"
+ )
+ print(
+ f"FRAME target_detect={100*ag.get('frame_target_detection_rate',0):.2f}% "
+ f"complete_miss={100*ag.get('frame_target_complete_miss_rate',0):.2f}% "
+ f"cana_clean={100*ag.get('frame_cana_clean_rate',0):.2f}% "
+ f"ground_clean={100*ag.get('frame_ground_clean_rate',0):.2f}%"
+ )
+
+ sweep = ag.get("threshold_sweep", {}) or {}
+ if sweep:
+ print("THR-SWEEP:")
+ for tkey, tm in sorted(sweep.items(), key=lambda kv: float(kv[0])):
+ flag = "OK" if tm.get("constraints_met", False) else "--"
+ print(
+ f" t={float(tkey):.2f} IoU={tm.get('iou',0):.4f} F1={tm.get('f1',0):.4f} "
+ f"P={tm.get('precision',0):.4f} R={tm.get('recall',0):.4f} "
+ f"cana={100*tm.get('cana_spray_rate',0):.2f}% "
+ f"ground={100*tm.get('ground_spray_rate',0):.2f}% "
+ f"miss={100*tm.get('weed_miss_rate',0):.2f}% op={tm.get('operational_score',0):.4f} {flag}"
+ )
+
+ scen = ag.get("scenario_target", {}) or {}
+ if scen:
+ parts = []
+ for name in ("none", "tiny", "small", "medium", "large"):
+ sm = scen.get(name, {}) or {}
+ if name == "none":
+ parts.append(f"none FP={100*(1-safe_float(sm.get('specificity'),1)):.2f}%")
+ else:
+ parts.append(f"{name} IoU={sm.get('iou_target',0):.3f}/R={sm.get('recall',0):.3f}")
+ print("SCEN " + " | ".join(parts))
+
+ groups = ag.get("group_target", {}) or {}
+ if groups:
+ print("GROUP:")
+ for gname, gm in sorted(groups.items()):
+ print(
+ f" {gname}: IoU={gm.get('iou',0):.4f} F1={gm.get('f1',0):.4f} "
+ f"R={gm.get('recall',0):.4f} cana={100*gm.get('cana_spray_rate',0):.2f}% "
+ f"ground={100*gm.get('ground_spray_rate',0):.2f}% miss={100*gm.get('weed_miss_rate',0):.2f}%"
+ )
+
+ print("IoU semantic:", pretty_iou(semantic_id2label, va["heads"]["semantic"]["iou_per_class"]))
+ print("IoU vegetation:", binary_iou_text("vegetation", va["heads"]["vegetation"]["iou_per_class"]))
+ print("IoU cana:", binary_iou_text("cana", va["heads"]["cana"]["iou_per_class"]))
+ print("IoU target:", binary_iou_text("target", va["heads"]["target"]["iou_per_class"]))
+ if device.type=="cuda":
+ print(f"GPU peak={torch.cuda.max_memory_allocated()/1024**3:.2f} GiB")
+
+ old = copy.deepcopy(best)
+ op_score = safe_float(
+ score_now.get("operational_score"),
+ -1.0,
+ )
+ op_safe = bool(
+ score_now.get("operational_constraints_met", False)
+ )
+ best["score"] = max(float(best.get("score",-1)), score_now["score"])
+ best["legacy_score"] = max(float(best.get("legacy_score",-1)), score_now.get("legacy_score",-1))
+ # O histórico "best operational" só aceita epochs SAFE.
+ if op_safe:
+ best["operational_score"] = max(float(best.get("operational_score", -1.0)), op_score)
+ best["target_iou"] = max(float(best.get("target_iou",-1)), score_now["target_iou"])
+ best["cana_head_iou"] = max(float(best.get("cana_head_iou",-1)), score_now["cana_head_iou"])
+ best["semantic_miou"] = max(float(best.get("semantic_miou",-1)), score_now["semantic_miou"])
+ best["vegetation_miou"] = max(float(best.get("vegetation_miou",-1)), score_now["vegetation_miou"])
+ best["cana_safety"] = max(float(best.get("cana_safety",-1)), score_now["cana_safety"])
+
+ extra={"train":tr,"val":va,"score_now":score_now,"lr_groups":[float(g['lr']) for g in optimizer.param_groups]}
+ if score_now["score"] > float(old.get("score",-1)):
+ save_checkpoint(best_score_path,model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step); print(f"[BEST SCORE] {score_now['score']:.4f}")
+ if bool(tv2.get("checkpoint",{}).get("save_best_legacy",True)) and score_now.get("legacy_score",-1) > float(old.get("legacy_score",-1)):
+ save_checkpoint(best_legacy_path,model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step); print(f"[BEST LEGACY] {score_now.get('legacy_score',-1):.4f}")
+ if (bool(tv2.get("checkpoint", {}).get("save_best_operational", True)) and op_safe and op_score > float(old.get("operational_score", -1.0))):
+ save_checkpoint(best_operational_path, model, optimizer, scaler, epoch, best, extra, scheduler, epochs_without_improve, global_step); print(f"[BEST OPERATIONAL SAFE] {op_score:.4f} @thr={score_now.get('operational_threshold')}")
+ if score_now["target_iou"] > float(old.get("target_iou",-1)):
+ save_checkpoint(best_target_path,model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step); print(f"[BEST TARGET] {score_now['target_iou']:.4f}")
+ if score_now["cana_head_iou"] > float(old.get("cana_head_iou",-1)):
+ save_checkpoint(best_cana_path,model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step); print(f"[BEST CANA] {score_now['cana_head_iou']:.4f}")
+ if score_now["semantic_miou"] > float(old.get("semantic_miou",-1)):
+ save_checkpoint(best_sem_path,model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step); print(f"[BEST SEM] {score_now['semantic_miou']:.4f}")
+ if bool(tv2.get("checkpoint",{}).get("save_best_safety",True)) and score_now["cana_safety"] > float(old.get("cana_safety",-1)):
+ save_checkpoint(best_safety_path,model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step); print(f"[BEST SAFETY] {score_now['cana_safety']:.5f}")
+
+ # Early stop is driven only by the principal agricultural score.
+ if score_now["score"] > best_score_for_es + min_delta:
+ best_score_for_es = score_now["score"]; epochs_without_improve = 0
+ else:
+ epochs_without_improve += 1
+
+ save_checkpoint(last_path,model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step)
+ if args.save_every>0 and epoch%args.save_every==0:
+ save_checkpoint(save_dir/f"epoch_{epoch:04d}.pt",model,optimizer,scaler,epoch,best,extra,scheduler,epochs_without_improve,global_step)
+
+ append_train_log(train_log_path, flatten_epoch_log(epoch, lr_now[0], tr, va, best, score_now))
+ append_jsonl(jsonl_path, {"epoch":epoch,"train":tr,"val":va,"score_now":score_now,"best":best})
+
+ if args.early_stop>0 and epochs_without_improve>=args.early_stop:
+ print(f"[EARLY STOP] {epochs_without_improve} épocas sem ganho de score >= {min_delta}.")
+ break
+
+ print("\nTreino finalizado.")
+ print(json.dumps(best, indent=2, ensure_ascii=False))
+ print(f"Save dir: {save_dir}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/_9_review_dataset_multihead_v2.py b/Python/OAK/datasets/oak-fcc-3/_9_review_dataset_multihead_v2.py
new file mode 100644
index 000000000..069c1339c
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/_9_review_dataset_multihead_v2.py
@@ -0,0 +1,1478 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""
+_9_review_dataset_multihead_v2.py
+
+Auditoria/model-mining do dataset usando o mesmo contrato de inferência do
+_9_test_multihead_v2.py.
+
+Objetivo
+--------
+Rodar um checkpoint sobre TODO o dataset normalizado (ex.: dataset/960x600/group),
+medir onde GT e modelo discordam e separar automaticamente os casos que merecem
+revisão humana.
+
+IMPORTANTE: discordância modelo x GT NÃO é tratada automaticamente como erro do
+modelo. Em um dataset com rótulos imperfeitos, um conflito de alta confiança pode
+ser justamente um forte candidato a máscara incorreta.
+
+Saída de revisão (quando não usa --report_only):
+
+ dataset/revisao/group//
+ previews/.png
+ masks/.png # GT semantic colorido
+ predictions/.png # predição semantic colorida
+ panels/.png # opcional, --save_panels
+ final_masks/.png # NÃO é tocado pelo auditor
+ review_order.csv
+
+Modos de exportação:
+ --export_mode flagged
+ Exporta casos acionados pelos critérios técnicos.
+ --export_mode score --min_suspicion_pct 80
+ Exporta casos com review_score*100 >= 80.
+ --export_mode all
+ Exporta todas as amostras.
+
+suspicion_pct = review_score*100 é uma escala heurística de ranking,
+não uma probabilidade estatística de a máscara estar errada.
+
+Relatórios por execução/checkpoint:
+
+ dataset/revisao/reports//
+ review_report.csv # TODAS as amostras
+ review_candidates.csv # somente selecionadas
+ review_summary.json
+ confusion_semantic.csv
+ confusion_vegetation.csv
+ confusion_cana.csv
+ confusion_target.csv
+
+Exemplos
+--------
+
+1) Gerar revisão física dos casos suspeitos no dataset 960x600:
+
+python .\_9_review_dataset_multihead_v2.py ^
+ --config .\config.json ^
+ --root dataset\960x600\group ^
+ --ckpt backup\segformer_b1\2026_08_27_linear_demosaic\stacked_raw5\best_score.pt ^
+ --target_threshold auto ^
+ --export_mode score --min_suspicion_pct 80
+ --clear_review
+
+2) Somente relatório, sem copiar previews/masks/predictions:
+
+python .\_9_review_dataset_multihead_v2.py ^
+ --config .\config.json ^
+ --root dataset\960x600\group ^
+ --ckpt backup\...\best_cana_head.pt ^
+ --report_only ^
+ --run_name best_cana
+
+3) Forçar os 100 casos mais suspeitos de cada grupo para revisão:
+
+python .\_9_review_dataset_multihead_v2.py ^
+ --config .\config.json ^
+ --root dataset\960x600\group ^
+ --ckpt backup\...\best_score.pt ^
+ --top_k_per_group 100 ^
+ --clear_review
+
+4) Mesmo auditor usando ONNX Runtime + TensorRT (logits):
+
+python .\_9_review_dataset_multihead_v2.py ^
+ --config .\config.json ^
+ --root dataset\960x600\group ^
+ --onnx backup\...\best_score_audit.onnx ^
+ --onnx_provider tensorrt ^
+ --target_threshold auto ^
+ --export_mode flagged ^
+ --clear_review
+
+IMPORTANTE para ONNX:
+- O auditor precisa de LOGITS/probabilidades para medir alta confiança.
+- Exporte com --postprocess none ou --postprocess resize_logits.
+- ONNX mask-only (argmax_lowres/argmax_fullres) é recusado de propósito.
+- Normalização embutida é detectada pelo .export_meta.json para evitar dupla normalização.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import importlib.util
+import json
+import math
+import os
+import shutil
+import sys
+import time
+from collections import defaultdict
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Sequence, Tuple
+
+import cv2
+import numpy as np
+import torch
+
+
+# ============================================================
+# Utilidades gerais
+# ============================================================
+
+
+def load_json(path: Path) -> dict:
+ with path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def ensure_dir(path: Path) -> None:
+ path.mkdir(parents=True, exist_ok=True)
+
+
+def resolve_path(path_like: Optional[str], base: Optional[Path] = None) -> Optional[Path]:
+ if path_like is None:
+ return None
+ p = Path(path_like)
+ if p.is_absolute():
+ return p
+ return ((base or Path.cwd()) / p).resolve()
+
+
+def safe_float(v, default: float = 0.0) -> float:
+ try:
+ x = float(v)
+ return x if math.isfinite(x) else default
+ except Exception:
+ return default
+
+
+def pct(x: float) -> float:
+ return float(x) * 100.0
+
+
+def clear_dir(path: Path) -> None:
+ if path.exists():
+ shutil.rmtree(path)
+ path.mkdir(parents=True, exist_ok=True)
+
+
+def clear_generated_review(review_group_root: Path) -> None:
+ """Limpa somente artefatos regeneráveis e PRESERVA final_masks/."""
+ if not review_group_root.exists():
+ review_group_root.mkdir(parents=True, exist_ok=True)
+ return
+
+ for group_dir in review_group_root.iterdir():
+ if not group_dir.is_dir():
+ continue
+
+ for name in ("previews", "masks", "predictions", "panels"):
+ p = group_dir / name
+ if p.exists():
+ shutil.rmtree(p)
+
+ order_csv = group_dir / "review_order.csv"
+ if order_csv.exists():
+ order_csv.unlink()
+
+
+def load_test_module(script_path: Path):
+ if not script_path.is_file():
+ raise FileNotFoundError(
+ f"Script base não encontrado: {script_path}\n"
+ "Coloque este arquivo ao lado de _9_test_multihead_v2.py ou informe --test_script."
+ )
+ spec = importlib.util.spec_from_file_location("agri_test_multihead_v2", str(script_path))
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Não consegui carregar módulo de teste: {script_path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+
+def load_onnx_export_meta(onnx_path: Path) -> Tuple[dict, Optional[Path]]:
+ """Carrega o sidecar gerado por _10_export_onnx.py, quando existir."""
+ candidates = [
+ onnx_path.with_suffix(".export_meta.json"),
+ onnx_path.parent / f"{onnx_path.stem}.export_meta.json",
+ ]
+ for p in candidates:
+ if p.is_file():
+ try:
+ return load_json(p), p
+ except Exception as exc:
+ raise RuntimeError(f"Falha ao ler metadata ONNX: {p}: {exc}") from exc
+ return {}, None
+
+
+def onnx_normalization_embedded(meta: dict, mode: str = "auto") -> bool:
+ mode = str(mode or "auto").strip().lower()
+ if mode == "embedded":
+ return True
+ if mode == "external":
+ return False
+
+ contract = meta.get("input_contract", {}) if isinstance(meta, dict) else {}
+ if isinstance(contract, dict) and "normalization_embedded" in contract:
+ return bool(contract.get("normalization_embedded"))
+ if isinstance(meta, dict) and "include_norm" in meta:
+ return bool(meta.get("include_norm"))
+ return False
+
+
+def _extract_operational_threshold_from_ckpt_dict(ckpt: dict) -> Optional[float]:
+ if not isinstance(ckpt, dict):
+ return None
+
+ candidates = []
+ best = ckpt.get("best", {}) or {}
+ if isinstance(best, dict):
+ candidates.extend([
+ best.get("operational_threshold"),
+ best.get("best_operational_threshold"),
+ ])
+
+ extra = ckpt.get("extra", {}) or {}
+ if isinstance(extra, dict):
+ score_now = extra.get("score_now", {}) or {}
+ if isinstance(score_now, dict):
+ candidates.append(score_now.get("operational_threshold"))
+ val = extra.get("val", {}) or {}
+ if isinstance(val, dict):
+ agri = val.get("agricultural", {}) or {}
+ if isinstance(agri, dict):
+ bop = agri.get("best_operational_threshold", {}) or {}
+ if isinstance(bop, dict):
+ candidates.append(bop.get("threshold"))
+
+ for value in candidates:
+ try:
+ if value is not None:
+ v = float(value)
+ if 0.0 <= v <= 1.0:
+ return v
+ except Exception:
+ pass
+ return None
+
+
+def resolve_onnx_operational_threshold(meta: dict, meta_path: Optional[Path], onnx_path: Path) -> Tuple[Optional[float], str]:
+ """
+ Resolve o threshold operacional sem precisar executar PyTorch.
+
+ Ordem:
+ 1) metadata ONNX explícita;
+ 2) checkpoint_best do sidecar;
+ 3) checkpoint .pt referenciado no sidecar, carregado apenas para ler metadata.
+ """
+ if isinstance(meta, dict):
+ direct_candidates = [
+ meta.get("operational_threshold"),
+ meta.get("checkpoint_operational_threshold"),
+ ]
+ best = meta.get("checkpoint_best", {}) or {}
+ if isinstance(best, dict):
+ direct_candidates.extend([
+ best.get("operational_threshold"),
+ best.get("best_operational_threshold"),
+ ])
+ for value in direct_candidates:
+ try:
+ if value is not None:
+ v = float(value)
+ if 0.0 <= v <= 1.0:
+ return v, "onnx_export_meta"
+ except Exception:
+ pass
+
+ ckpt_txt = meta.get("checkpoint")
+ if ckpt_txt:
+ raw = Path(str(ckpt_txt))
+ candidates = [raw]
+ if not raw.is_absolute():
+ if meta_path is not None:
+ candidates.append((meta_path.parent / raw).resolve())
+ candidates.append((onnx_path.parent / raw).resolve())
+ candidates.append((Path.cwd() / raw).resolve())
+ ckpt_path = next((p for p in candidates if p.is_file()), None)
+ if ckpt_path is not None:
+ try:
+ # Carrega apenas para metadata. Nenhum modelo PyTorch é construído/executado.
+ ckpt = torch.load(str(ckpt_path), map_location="cpu", weights_only=False)
+ thr = _extract_operational_threshold_from_ckpt_dict(ckpt)
+ if thr is not None:
+ return thr, f"checkpoint_metadata:{ckpt_path}"
+ except Exception as exc:
+ print(f"[ONNX][WARN] não consegui ler threshold do checkpoint do sidecar: {exc}")
+
+ return None, "none"
+
+
+def validate_onnx_for_audit(tester, meta: dict, onnx_path: Path) -> None:
+ """
+ Auditoria precisa de probabilidades reais. Portanto, o ONNX deve devolver logits.
+ Modelos exportados apenas com argmax/mask não servem para high-confidence mining.
+ """
+ postprocess = str((meta or {}).get("postprocess", "")).strip().lower()
+ output_names = [str(x) for x in getattr(tester, "output_names", [])]
+
+ mask_only_by_meta = postprocess in ("argmax_lowres", "argmax_fullres")
+ mask_only_by_name = bool(output_names) and all(
+ name.lower().endswith("_mask") or "mask" in name.lower()
+ for name in output_names
+ )
+
+ if mask_only_by_meta or mask_only_by_name:
+ raise RuntimeError(
+ "Este ONNX devolve máscaras/argmax, mas o auditor precisa de LOGITS para "
+ "calcular probabilidades, high-confidence conflict e uncertainty.\n"
+ f"ONNX: {onnx_path}\n"
+ f"postprocess={postprocess or 'desconhecido'} outputs={output_names}\n"
+ "Exporte um ONNX de auditoria com --postprocess none (recomendado) "
+ "ou --postprocess resize_logits."
+ )
+
+ missing = []
+ for required in ("semantic", "vegetation", "cana"):
+ if not any(required in name.lower() for name in output_names):
+ missing.append(required)
+ if missing:
+ raise RuntimeError(
+ f"ONNX não expõe todas as heads necessárias para auditoria: faltando={missing}, "
+ f"outputs={output_names}"
+ )
+
+
+def write_csv(path: Path, rows: Sequence[dict], fieldnames: Optional[Sequence[str]] = None) -> None:
+ ensure_dir(path.parent)
+ if fieldnames is None:
+ keys = []
+ seen = set()
+ for row in rows:
+ for key in row.keys():
+ if key not in seen:
+ seen.add(key)
+ keys.append(key)
+ fieldnames = keys
+
+ with path.open("w", newline="", encoding="utf-8-sig") as f:
+ writer = csv.DictWriter(f, fieldnames=list(fieldnames), extrasaction="ignore")
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+
+
+def write_cm_csv(path: Path, cm: np.ndarray, labels: Sequence[str]) -> None:
+ rows = []
+ for gi, gt_name in enumerate(labels):
+ row = {"gt\\pred": gt_name}
+ for pi, pred_name in enumerate(labels):
+ row[pred_name] = int(cm[gi, pi])
+ rows.append(row)
+ write_csv(path, rows, ["gt\\pred", *labels])
+
+
+# ============================================================
+# Métricas
+# ============================================================
+
+
+def resize_ids(mask: np.ndarray, hw: Tuple[int, int]) -> np.ndarray:
+ h, w = hw
+ if mask.shape[:2] == (h, w):
+ return mask
+ return cv2.resize(mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST)
+
+
+def class_metrics_from_cm(cm: np.ndarray, labels: Sequence[str]) -> dict:
+ cm = cm.astype(np.float64)
+ tp = np.diag(cm)
+ support = cm.sum(axis=1)
+ pred_count = cm.sum(axis=0)
+ fp = pred_count - tp
+ fn = support - tp
+
+ precision = np.divide(tp, tp + fp, out=np.zeros_like(tp), where=(tp + fp) > 0)
+ recall = np.divide(tp, tp + fn, out=np.zeros_like(tp), where=(tp + fn) > 0)
+ f1 = np.divide(2 * precision * recall, precision + recall, out=np.zeros_like(tp), where=(precision + recall) > 0)
+ iou = np.divide(tp, tp + fp + fn, out=np.zeros_like(tp), where=(tp + fp + fn) > 0)
+
+ total = max(float(cm.sum()), 1.0)
+ out = {
+ "accuracy": float(tp.sum() / total),
+ "miou": float(np.mean(iou)) if len(iou) else 0.0,
+ "classes": {},
+ }
+ for i, name in enumerate(labels):
+ out["classes"][str(name)] = {
+ "support_pixels": int(support[i]),
+ "pred_pixels": int(pred_count[i]),
+ "precision": float(precision[i]),
+ "recall": float(recall[i]),
+ "f1": float(f1[i]),
+ "iou": float(iou[i]),
+ }
+ return out
+
+
+def directional_rate(cm: np.ndarray, gt_idx: int, pred_idx: int) -> float:
+ den = int(cm[gt_idx].sum())
+ if den <= 0:
+ return 0.0
+ return float(cm[gt_idx, pred_idx] / den)
+
+
+def binary_target_stats(pred: np.ndarray, gt: np.ndarray, ignore_id: int) -> dict:
+ valid = gt != ignore_id
+ gt1 = (gt == 1) & valid
+ pr1 = (pred == 1) & valid
+ tp = int((gt1 & pr1).sum())
+ fp = int((~gt1 & pr1 & valid).sum())
+ fn = int((gt1 & ~pr1).sum())
+ tn = int((~gt1 & ~pr1 & valid).sum())
+
+ precision = tp / max(tp + fp, 1)
+ recall = tp / max(tp + fn, 1)
+ f1 = 2 * precision * recall / max(precision + recall, 1e-12)
+ iou = tp / max(tp + fp + fn, 1)
+ return {
+ "tp": tp,
+ "fp": fp,
+ "fn": fn,
+ "tn": tn,
+ "precision": float(precision),
+ "recall": float(recall),
+ "f1": float(f1),
+ "iou": float(iou),
+ }
+
+
+def semantic_sample_signals(
+ pred: np.ndarray,
+ probs: np.ndarray,
+ gt: np.ndarray,
+ ignore_id: int,
+ high_confidence: float,
+ uncertainty_margin: float,
+) -> dict:
+ if pred.shape != gt.shape:
+ pred = resize_ids(pred, gt.shape[:2])
+
+ valid = gt != ignore_id
+ valid_n = int(valid.sum())
+ if valid_n <= 0:
+ return {
+ "valid_pixels": 0,
+ "wrong_pixels": 0,
+ "highconf_wrong_pixels": 0,
+ "uncertain_pixels": 0,
+ "gt_chao_pixels": 0,
+ "gt_cana_pixels": 0,
+ "gt_erva_pixels": 0,
+ "hc_gt_chao_pred_cana_pixels": 0,
+ "hc_gt_chao_pred_erva_pixels": 0,
+ "hc_gt_cana_pred_erva_pixels": 0,
+ "hc_gt_erva_pred_cana_pixels": 0,
+ "semantic_disagree_frac": 0.0,
+ "highconf_disagree_frac": 0.0,
+ "uncertain_frac": 0.0,
+ "mean_conf_correct": 0.0,
+ "mean_conf_wrong": 0.0,
+ "gt_chao_pred_cana_frac": 0.0,
+ "gt_chao_pred_erva_frac": 0.0,
+ "gt_cana_pred_erva_frac": 0.0,
+ "gt_erva_pred_cana_frac": 0.0,
+ "hc_gt_chao_pred_cana_frac": 0.0,
+ "hc_gt_chao_pred_erva_frac": 0.0,
+ "hc_gt_cana_pred_erva_frac": 0.0,
+ "hc_gt_erva_pred_cana_frac": 0.0,
+ }
+
+ top1 = np.max(probs, axis=0)
+ if probs.shape[0] >= 2:
+ part = np.partition(probs, kth=probs.shape[0] - 2, axis=0)
+ top2 = part[-2]
+ else:
+ top2 = np.zeros_like(top1)
+ margin = top1 - top2
+
+ wrong = valid & (pred != gt)
+ correct = valid & (pred == gt)
+ highconf_wrong = wrong & (top1 >= high_confidence)
+ uncertain = valid & (margin <= uncertainty_margin)
+
+ def frac(mask: np.ndarray) -> float:
+ return float(mask.sum() / valid_n)
+
+ def conf_mean(mask: np.ndarray) -> float:
+ n = int(mask.sum())
+ return float(top1[mask].mean()) if n else 0.0
+
+ g0p1 = valid & (gt == 0) & (pred == 1)
+ g0p2 = valid & (gt == 0) & (pred == 2)
+ g1p2 = valid & (gt == 1) & (pred == 2)
+ g2p1 = valid & (gt == 2) & (pred == 1)
+
+ return {
+ "valid_pixels": valid_n,
+ "wrong_pixels": int(wrong.sum()),
+ "highconf_wrong_pixels": int(highconf_wrong.sum()),
+ "uncertain_pixels": int(uncertain.sum()),
+ "gt_chao_pixels": int((valid & (gt == 0)).sum()),
+ "gt_cana_pixels": int((valid & (gt == 1)).sum()),
+ "gt_erva_pixels": int((valid & (gt == 2)).sum()),
+ "hc_gt_chao_pred_cana_pixels": int((g0p1 & (top1 >= high_confidence)).sum()),
+ "hc_gt_chao_pred_erva_pixels": int((g0p2 & (top1 >= high_confidence)).sum()),
+ "hc_gt_cana_pred_erva_pixels": int((g1p2 & (top1 >= high_confidence)).sum()),
+ "hc_gt_erva_pred_cana_pixels": int((g2p1 & (top1 >= high_confidence)).sum()),
+ "semantic_disagree_frac": frac(wrong),
+ "highconf_disagree_frac": frac(highconf_wrong),
+ "uncertain_frac": frac(uncertain),
+ "mean_conf_correct": conf_mean(correct),
+ "mean_conf_wrong": conf_mean(wrong),
+ "gt_chao_pred_cana_frac": frac(g0p1),
+ "gt_chao_pred_erva_frac": frac(g0p2),
+ "gt_cana_pred_erva_frac": frac(g1p2),
+ "gt_erva_pred_cana_frac": frac(g2p1),
+ "hc_gt_chao_pred_cana_frac": frac(g0p1 & (top1 >= high_confidence)),
+ "hc_gt_chao_pred_erva_frac": frac(g0p2 & (top1 >= high_confidence)),
+ "hc_gt_cana_pred_erva_frac": frac(g1p2 & (top1 >= high_confidence)),
+ "hc_gt_erva_pred_cana_frac": frac(g2p1 & (top1 >= high_confidence)),
+ }
+
+
+def review_score(signals: dict, target_disagree_frac: float) -> float:
+ """
+ Score apenas para RANKING, não é probabilidade de máscara errada.
+
+ Dá mais peso para conflito de alta confiança que para simples incerteza.
+ Cada termo é saturado numa escala prática para evitar que um único tipo de
+ erro domine completamente a ordenação.
+ """
+ sem = min(safe_float(signals.get("semantic_disagree_frac")) / 0.20, 1.0)
+ hc = min(safe_float(signals.get("highconf_disagree_frac")) / 0.05, 1.0)
+ cross = min(
+ (
+ safe_float(signals.get("hc_gt_cana_pred_erva_frac"))
+ + safe_float(signals.get("hc_gt_erva_pred_cana_frac"))
+ ) / 0.02,
+ 1.0,
+ )
+ tgt = min(safe_float(target_disagree_frac) / 0.15, 1.0)
+ unc = min(safe_float(signals.get("uncertain_frac")) / 0.25, 1.0)
+ return float(0.30 * sem + 0.40 * hc + 0.15 * cross + 0.10 * tgt + 0.05 * unc)
+
+
+def build_reasons(
+ row: dict,
+ min_disagree_frac: float,
+ min_highconf_disagree_frac: float,
+ min_cross_frac: float,
+ min_target_disagree_frac: float,
+ min_conflict_pixels: int,
+) -> List[str]:
+ reasons: List[str] = []
+ valid_pixels = max(int(row.get("valid_pixels", 0)), 1)
+ hc_pixels = int(round(float(row.get("highconf_disagree_frac", 0.0)) * valid_pixels))
+
+ if float(row.get("semantic_disagree_frac", 0.0)) >= min_disagree_frac:
+ reasons.append("SEMANTIC_DISAGREE")
+
+ if (
+ float(row.get("highconf_disagree_frac", 0.0)) >= min_highconf_disagree_frac
+ and hc_pixels >= min_conflict_pixels
+ ):
+ reasons.append("HIGH_CONF_GT_CONFLICT")
+
+ if (
+ float(row.get("hc_gt_chao_pred_cana_frac", 0.0))
+ + float(row.get("hc_gt_chao_pred_erva_frac", 0.0))
+ >= min_highconf_disagree_frac
+ ):
+ reasons.append("GT_CHAO_PRED_VEGETATION")
+
+ if (
+ float(row.get("hc_gt_cana_pred_erva_frac", 0.0))
+ + float(row.get("hc_gt_erva_pred_cana_frac", 0.0))
+ >= min_cross_frac
+ ):
+ reasons.append("CANA_ERVA_CROSS")
+
+ if float(row.get("target_disagree_frac", 0.0)) >= min_target_disagree_frac:
+ reasons.append("TARGET_DISAGREE")
+
+ return reasons
+
+
+# ============================================================
+# Visualização/copiar casos de revisão
+# ============================================================
+
+
+def load_preview_rgb(sample, chw: np.ndarray, base_module, input_channel_names: Sequence[str]) -> np.ndarray:
+ if getattr(sample, "preview_path", None) is not None and sample.preview_path.is_file():
+ bgr = cv2.imread(str(sample.preview_path), cv2.IMREAD_COLOR)
+ if bgr is not None:
+ return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
+ return base_module.tensor_to_preview_rgb(chw, input_channel_names)
+
+
+def save_review_item(
+ row: dict,
+ sample,
+ chw: np.ndarray,
+ gt_sem: np.ndarray,
+ pred_sem: np.ndarray,
+ review_group_root: Path,
+ base_module,
+ semantic_cmap: Dict[int, Tuple[int, int, int]],
+ ignore_id: int,
+ input_channel_names: Sequence[str],
+ save_panels: bool,
+) -> None:
+ group_dir = review_group_root / sample.group
+ previews_dir = group_dir / "previews"
+ masks_dir = group_dir / "masks"
+ predictions_dir = group_dir / "predictions"
+ panels_dir = group_dir / "panels"
+
+ ensure_dir(previews_dir)
+ ensure_dir(masks_dir)
+ ensure_dir(predictions_dir)
+ if save_panels:
+ ensure_dir(panels_dir)
+
+ # Revisão humana usa somente PNG. Mesmo se o preview fonte for JPG,
+ # a saída é normalizada para .png.
+ preview_rgb = load_preview_rgb(sample, chw, base_module, input_channel_names)
+ cv2.imwrite(
+ str(previews_dir / f"{sample.base}.png"),
+ cv2.cvtColor(preview_rgb, cv2.COLOR_RGB2BGR),
+ )
+
+ gt_rgb = base_module.ids_to_rgb(gt_sem, semantic_cmap, ignore_id)
+ pred_rgb = base_module.ids_to_rgb(pred_sem, semantic_cmap, ignore_id)
+
+ cv2.imwrite(
+ str(masks_dir / f"{sample.base}.png"),
+ cv2.cvtColor(gt_rgb, cv2.COLOR_RGB2BGR),
+ )
+ cv2.imwrite(
+ str(predictions_dir / f"{sample.base}.png"),
+ cv2.cvtColor(pred_rgb, cv2.COLOR_RGB2BGR),
+ )
+
+ if save_panels:
+ # Normaliza tamanho do preview para GT/pred.
+ h, w = gt_sem.shape[:2]
+ if preview_rgb.shape[:2] != (h, w):
+ preview_rgb = cv2.resize(preview_rgb, (w, h), interpolation=cv2.INTER_AREA)
+
+ diff = np.zeros_like(gt_rgb)
+ valid = gt_sem != ignore_id
+ bad = valid & (gt_sem != pred_sem)
+ diff[bad] = (255, 40, 40)
+
+ overlay_gt = base_module.overlay_rgb(preview_rgb, gt_rgb, 0.45)
+ overlay_pred = base_module.overlay_rgb(preview_rgb, pred_rgb, 0.45)
+ panels = [
+ ("Preview", preview_rgb, sample.base),
+ ("GT semantic", overlay_gt, f"grupo={sample.group}"),
+ ("Pred semantic", overlay_pred, f"score={float(row['review_score']):.3f}"),
+ ("GT != Pred", diff, str(row.get("review_reasons", ""))),
+ ]
+ panel = base_module.compose_grid(panels, cols=2, max_width=1600)
+ cv2.imwrite(str(panels_dir / f"{sample.base}.png"), cv2.cvtColor(panel, cv2.COLOR_RGB2BGR))
+
+
+# ============================================================
+# Main
+# ============================================================
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Audita masks usando um checkpoint multi-head e separa casos suspeitos para revisão humana."
+ )
+ parser.add_argument("--config", default="config.json")
+ parser.add_argument("--root", default=None, help="Default: dataset//group conforme config.resolucao")
+ parser.add_argument("--ckpt", default=None, help="Checkpoint .pt. Se --onnx for usado, não informe --ckpt.")
+ parser.add_argument("--onnx", default=None, help="Modelo ONNX alternativo ao .pt para auditoria acelerada.")
+ parser.add_argument(
+ "--onnx_provider",
+ default="tensorrt",
+ choices=["tensorrt", "cuda", "cpu"],
+ help="Provider do ONNX Runtime. Default: tensorrt.",
+ )
+ parser.add_argument(
+ "--onnx_norm",
+ default="auto",
+ choices=["auto", "external", "embedded"],
+ help="auto usa .export_meta.json; external aplica norm_stats fora; embedded assume normalização dentro do ONNX.",
+ )
+ parser.add_argument("--trt_home", default=None, help="Pasta do TensorRT. Opcional; usa TRT_HOME/default do test script.")
+ parser.add_argument("--trt_no_fp16", action="store_true", help="Desliga FP16 no TensorRT.")
+ parser.add_argument("--onnx_warmup", type=int, default=3, help="Warmups antes de medir auditoria ONNX/TRT. Default=3.")
+ parser.add_argument("--norm_stats", default=None)
+ parser.add_argument("--channels", type=int, default=None)
+ parser.add_argument("--resize_w", type=int, default=None)
+ parser.add_argument("--resize_h", type=int, default=None)
+ parser.add_argument("--ignore_id", type=int, default=None)
+ parser.add_argument("--no_amp", action="store_true")
+ parser.add_argument("--target_threshold", default="auto", help="Número ou auto")
+ parser.add_argument("--test_script", default=None, help="Default: _9_test_multihead_v2.py ao lado deste script")
+
+ parser.add_argument("--out_root", default="dataset/revisao")
+ parser.add_argument("--run_name", default=None, help="Nome do relatório. Default: stem do checkpoint")
+ parser.add_argument("--report_only", action="store_true", help="Não copia casos para dataset/revisao/group")
+ parser.add_argument(
+ "--export_mode",
+ choices=["flagged", "score", "all"],
+ default="flagged",
+ help="flagged=critérios técnicos; score=score >= min_suspicion_pct; all=todas.",
+ )
+ parser.add_argument(
+ "--min_suspicion_pct",
+ type=float,
+ default=80.0,
+ help="Usado com --export_mode score. Escala heurística 0..100, não probabilidade.",
+ )
+ parser.add_argument(
+ "--clear_review",
+ action="store_true",
+ help="Limpa previews/masks/predictions/panels e PRESERVA final_masks.",
+ )
+ parser.add_argument(
+ "--clear_review_all",
+ action="store_true",
+ help="PERIGOSO: apaga toda a árvore group, inclusive final_masks.",
+ )
+ parser.add_argument("--save_panels", action="store_true", help="Salva painel preview/GT/pred/diff dos candidatos")
+
+ # Critérios de revisão. São deliberadamente conservadores.
+ parser.add_argument("--high_confidence", type=float, default=0.85)
+ parser.add_argument("--uncertainty_margin", type=float, default=0.12)
+ parser.add_argument("--min_disagree_frac", type=float, default=0.05,
+ help="Flag se >=5%% dos pixels semantic discordarem do GT")
+ parser.add_argument("--min_highconf_disagree_frac", type=float, default=0.002,
+ help="Flag se >=0.2%% dos pixels divergirem com alta confiança")
+ parser.add_argument("--min_cross_frac", type=float, default=0.001,
+ help="Flag para cana<->erva de alta confiança")
+ parser.add_argument("--min_target_disagree_frac", type=float, default=0.05)
+ parser.add_argument("--min_conflict_pixels", type=int, default=200)
+ parser.add_argument("--min_review_score", type=float, default=0.0,
+ help="Opcional: também seleciona por score de ranking. 0 desliga.")
+ parser.add_argument("--top_k_per_group", type=int, default=0,
+ help="Se >0, ignora flags como filtro final e pega os K scores maiores de cada grupo")
+ parser.add_argument("--max_review_per_group", type=int, default=0,
+ help="Cap dos candidatos por grupo após filtros. 0=sem limite")
+ parser.add_argument("--max_samples", type=int, default=0, help="Debug. 0=todos")
+ args = parser.parse_args()
+
+ if not (0.0 <= float(args.min_suspicion_pct) <= 100.0):
+ parser.error("--min_suspicion_pct deve ficar entre 0 e 100.")
+ if args.clear_review and args.clear_review_all:
+ parser.error("Use apenas um entre --clear_review e --clear_review_all.")
+ if args.ckpt and args.onnx:
+ parser.error("Use apenas um backend por execução: --ckpt OU --onnx.")
+ if args.onnx_warmup < 0:
+ parser.error("--onnx_warmup deve ser >= 0.")
+
+ script_here = Path(__file__).resolve()
+ test_script = resolve_path(args.test_script, Path.cwd()) if args.test_script else script_here.with_name("_9_test_multihead_v2.py")
+ base = load_test_module(test_script)
+
+ config_path = resolve_path(args.config, Path.cwd())
+ if config_path is None or not config_path.is_file():
+ raise FileNotFoundError(f"Config não encontrado: {config_path}")
+ config_dir = config_path.parent
+ config = load_json(config_path)
+ config["runtime_mode"] = "all"
+
+ input_channel_names = base.get_input_channel_names(config, args.channels)
+ channels = len(input_channel_names)
+
+ res = config.get("resolucao", [960, 600])
+ default_w, default_h = int(res[0]), int(res[1])
+ target_w = int(args.resize_w or default_w)
+ target_h = int(args.resize_h or default_h)
+
+ dataset_path = config_dir / "dataset"
+ labelmap_path = dataset_path / "labelmap.txt"
+ semantic_id2label, semantic_label2id, labelmap_ignore_id, loaded_cmap = base.load_labelmap(labelmap_path)
+ ignore_id = int(args.ignore_id if args.ignore_id is not None else labelmap_ignore_id)
+
+ heads_config = base.build_heads_config(config, ignore_index=ignore_id)
+ heads_config["semantic"]["num_classes"] = int(len(semantic_id2label))
+ heads_config["semantic"]["ignore_index"] = ignore_id
+
+ semantic_cmap = dict(base.SEMANTIC_COLORS_RGB)
+ semantic_cmap.update({int(k): tuple(map(int, v)) for k, v in loaded_cmap.items()})
+
+ save_dir = base.infer_save_dir(config, config_dir, channels)
+
+ backend = "onnx" if args.onnx else "pytorch"
+ ckpt_path: Optional[Path] = None
+ onnx_path: Optional[Path] = None
+ onnx_meta: dict = {}
+ onnx_meta_path: Optional[Path] = None
+
+ if backend == "onnx":
+ onnx_path = resolve_path(args.onnx, Path.cwd())
+ if onnx_path is None or not onnx_path.is_file():
+ raise FileNotFoundError(f"ONNX não encontrado: {onnx_path}")
+ onnx_meta, onnx_meta_path = load_onnx_export_meta(onnx_path)
+ model_path = onnx_path
+ else:
+ ckpt_path = base.find_checkpoint(save_dir, args.ckpt)
+ model_path = ckpt_path
+
+ if args.norm_stats:
+ norm_stats_path = resolve_path(args.norm_stats, Path.cwd())
+ else:
+ candidates = [
+ save_dir / "norm_stats.json",
+ dataset_path / f"{default_w}x{default_h}" / "group" / "norm_stats.json",
+ ]
+ norm_stats_path = next((p for p in candidates if p.is_file()), candidates[0])
+
+ mean, std = base.load_norm_stats(norm_stats_path, channel_names=input_channel_names)
+ if mean is None or std is None:
+ raise FileNotFoundError(
+ f"norm_stats obrigatório para auditoria comparável ao treino: {norm_stats_path}"
+ )
+
+ root = resolve_path(args.root, Path.cwd()) if args.root else (dataset_path / f"{default_w}x{default_h}" / "group").resolve()
+ if root is None or not root.is_dir():
+ raise FileNotFoundError(f"Root não encontrado: {root}")
+
+ samples, has_gt = base.collect_samples(root, heads_config=heads_config, require_masks=True)
+ if not has_gt:
+ raise RuntimeError("Dataset não possui GT. Este script precisa das masks para medir conflito.")
+ if args.max_samples > 0:
+ samples = samples[: args.max_samples]
+
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+ threshold_from_model: Optional[float] = None
+ threshold_source = "none"
+ onnx_norm_embedded = False
+
+ if backend == "onnx":
+ if not hasattr(base, "OnnxMultiHeadTester"):
+ raise RuntimeError(
+ "Seu _9_test_multihead_v2.py não possui OnnxMultiHeadTester. "
+ "Use a versão atualizada do script de teste."
+ )
+
+ onnx_norm_embedded = onnx_normalization_embedded(onnx_meta, args.onnx_norm)
+ if args.onnx_norm == "auto" and not onnx_meta:
+ print(
+ "[ONNX][WARN] .export_meta.json não encontrado. "
+ "Assumindo normalização EXTERNA via norm_stats. "
+ "Use --onnx_norm embedded se o grafo já incluir normalização."
+ )
+
+ tester = base.OnnxMultiHeadTester(
+ config=config,
+ onnx_path=onnx_path,
+ provider=args.onnx_provider,
+ input_channel_names=input_channel_names,
+ heads_config=heads_config,
+ mean=None if onnx_norm_embedded else mean,
+ std=None if onnx_norm_embedded else std,
+ trt_home=args.trt_home,
+ trt_fp16=not args.trt_no_fp16,
+ )
+ validate_onnx_for_audit(tester, onnx_meta, onnx_path)
+ threshold_from_model, threshold_source = resolve_onnx_operational_threshold(
+ onnx_meta, onnx_meta_path, onnx_path
+ )
+ else:
+ tester = base.MultiHeadTester(
+ config=config,
+ ckpt_path=ckpt_path,
+ device=device,
+ input_channel_names=input_channel_names,
+ heads_config=heads_config,
+ semantic_id2label=semantic_id2label,
+ semantic_label2id=semantic_label2id,
+ mean=mean,
+ std=std,
+ use_amp=not args.no_amp,
+ )
+ threshold_from_model = getattr(tester, "ckpt_operational_threshold", None)
+ threshold_source = "checkpoint" if threshold_from_model is not None else "none"
+
+ threshold_arg = str(args.target_threshold).strip().lower()
+ if threshold_arg == "auto":
+ if threshold_from_model is not None:
+ tester.target_threshold = float(threshold_from_model)
+ print(f"[TARGET] auto -> {tester.target_threshold:.3f} ({threshold_source})")
+ else:
+ tester.target_threshold = 0.5
+ print("[TARGET][WARN] threshold operacional não encontrado; usando 0.5")
+ else:
+ tester.target_threshold = float(threshold_arg)
+ threshold_source = "cli"
+ if not 0.0 <= tester.target_threshold <= 1.0:
+ raise RuntimeError("--target_threshold deve ficar entre 0 e 1")
+
+ module_params_config = config.get("module_params_json")
+ if module_params_config:
+ p = Path(str(module_params_config))
+ if not p.is_absolute():
+ p = config_dir / p
+ module_params_config = str(p.resolve())
+
+ out_root = resolve_path(args.out_root, Path.cwd())
+ if out_root is None:
+ raise RuntimeError("out_root inválido")
+ review_group_root = out_root / "group"
+ run_name = args.run_name or (model_path.stem + (f"_{args.onnx_provider}" if backend == "onnx" else ""))
+ report_dir = out_root / "reports" / run_name
+ ensure_dir(report_dir)
+
+ if not args.report_only:
+ if args.clear_review_all:
+ print(f"[WARN] Limpando revisão COMPLETA, inclusive final_masks: {review_group_root}")
+ clear_dir(review_group_root)
+ elif args.clear_review:
+ print(f"[INFO] Limpando artefatos regeneráveis e PRESERVANDO final_masks: {review_group_root}")
+ clear_generated_review(review_group_root)
+
+ # Confusion matrices globais.
+ cms_total = {
+ name: np.zeros((int(hcfg["num_classes"]), int(hcfg["num_classes"])), dtype=np.int64)
+ for name, hcfg in heads_config.items()
+ }
+
+ rows: List[dict] = []
+ cache_for_copy: Dict[Tuple[str, str], tuple] = {}
+
+ n = len(samples)
+ print("=" * 72)
+ print("DATASET REVIEW / MODEL MINING")
+ print(f"Root : {root}")
+ print(f"Backend : {backend}")
+ print(f"Model : {model_path}")
+ if backend == "onnx":
+ print(f"Provider : {args.onnx_provider}")
+ print(f"Norm ONNX : {'embedded' if onnx_norm_embedded else 'external'}")
+ print(f"Export meta : {onnx_meta_path}")
+ print(f"Outputs : {getattr(tester, 'output_names', [])}")
+ print(f"Samples : {n}")
+ print(f"Resolution : {target_w}x{target_h}")
+ print(f"Threshold : {tester.target_threshold:.3f}")
+ print(f"High conf : {args.high_confidence:.3f}")
+ print(f"Report only : {args.report_only}")
+ print(f"Export mode : {args.export_mode}")
+ if args.export_mode == "score":
+ print(f"Suspicion >=: {args.min_suspicion_pct:.1f}/100")
+ print("=" * 72)
+
+ warmup_seconds = 0.0
+ if backend == "onnx" and args.onnx_warmup > 0 and samples:
+ print(f"[ONNX] warmup={args.onnx_warmup} (fora das métricas de tempo)")
+ warm_sample = samples[0]
+ warm_chw = base.load_sample_tensor(
+ warm_sample,
+ requested_channels=input_channel_names,
+ derived_config=config.get("derived_channels", {}) or {},
+ target_size=(target_w, target_h),
+ module_params_path=module_params_config,
+ )
+ if (warm_chw.shape[2], warm_chw.shape[1]) != (target_w, target_h):
+ hwc = np.transpose(warm_chw, (1, 2, 0))
+ hwc = cv2.resize(hwc, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
+ warm_chw = np.transpose(hwc, (2, 0, 1)).astype(np.float32)
+ tw = time.perf_counter()
+ for _ in range(int(args.onnx_warmup)):
+ tester.infer(warm_chw)
+ warmup_seconds = time.perf_counter() - tw
+ print(f"[ONNX] warmup concluído em {warmup_seconds:.2f}s")
+
+ t0_all = time.perf_counter()
+ infer_ms_sum = 0.0
+ infer_total_ms_sum = 0.0
+
+ for i, sample in enumerate(samples, 1):
+ chw = base.load_sample_tensor(
+ sample,
+ requested_channels=input_channel_names,
+ derived_config=config.get("derived_channels", {}) or {},
+ target_size=(target_w, target_h),
+ module_params_path=module_params_config,
+ )
+
+ if (chw.shape[2], chw.shape[1]) != (target_w, target_h):
+ hwc = np.transpose(chw, (1, 2, 0))
+ hwc = cv2.resize(hwc, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
+ chw = np.transpose(hwc, (2, 0, 1)).astype(np.float32)
+
+ t_infer_wall0 = time.perf_counter()
+ preds, probs, t_inf = tester.infer(chw)
+ t_inf_total = (time.perf_counter() - t_infer_wall0) * 1000.0
+ infer_ms_sum += t_inf
+ infer_total_ms_sum += t_inf_total
+
+ gt_masks = {name: base.load_mask(path) for name, path in sample.masks.items()}
+ pred_sem = preds.get("semantic")
+ prob_sem = probs.get("semantic")
+ gt_sem = gt_masks.get("semantic")
+ if pred_sem is None or prob_sem is None or gt_sem is None:
+ raise RuntimeError(f"Amostra sem semantic necessário: {sample.base}")
+
+ gt_sem = resize_ids(gt_sem, pred_sem.shape[:2])
+ gt_masks["semantic"] = gt_sem
+
+ for head_name, pred in preds.items():
+ gt = gt_masks.get(head_name)
+ if gt is None:
+ continue
+ gt = resize_ids(gt, pred.shape[:2])
+ gt_masks[head_name] = gt
+ cms_total[head_name] += base.confusion_matrix_np(
+ pred,
+ gt,
+ int(heads_config[head_name]["num_classes"]),
+ ignore_id,
+ )
+
+ # GT target derivado, exatamente como no teste/treino.
+ gt_target = gt_masks.get("target")
+ if gt_target is None and gt_masks.get("vegetation") is not None and gt_masks.get("cana") is not None:
+ gt_v = resize_ids(gt_masks["vegetation"], pred_sem.shape[:2])
+ gt_c = resize_ids(gt_masks["cana"], pred_sem.shape[:2])
+ gt_target = base.operational_target_mask(gt_v, gt_c, ignore_id=ignore_id)
+
+ pred_target = preds.get("target")
+ if pred_target is None and preds.get("vegetation") is not None and preds.get("cana") is not None:
+ pred_target = base.operational_target_mask(
+ preds["vegetation"], preds["cana"], ignore_id=ignore_id
+ )
+
+ target_stats = None
+ target_disagree_frac = 0.0
+ if gt_target is not None and pred_target is not None:
+ gt_target = resize_ids(gt_target, pred_target.shape[:2])
+ target_stats = binary_target_stats(pred_target, gt_target, ignore_id)
+ valid_t = gt_target != ignore_id
+ den_t = int(valid_t.sum())
+ if den_t > 0:
+ target_disagree_frac = float(((pred_target != gt_target) & valid_t).sum() / den_t)
+ if "target" in cms_total:
+ cms_total["target"] += base.confusion_matrix_np(
+ pred_target, gt_target, 2, ignore_id
+ )
+
+ signals = semantic_sample_signals(
+ pred=pred_sem,
+ probs=prob_sem,
+ gt=gt_sem,
+ ignore_id=ignore_id,
+ high_confidence=args.high_confidence,
+ uncertainty_margin=args.uncertainty_margin,
+ )
+
+ cm_sem_sample = base.confusion_matrix_np(pred_sem, gt_sem, len(semantic_id2label), ignore_id)
+ iou_sem, miou_sem, acc_sem = base.metrics_from_cm(cm_sem_sample)
+
+ score = review_score(signals, target_disagree_frac)
+
+ row = {
+ "index": i - 1,
+ "group": sample.group,
+ "base": sample.base,
+ "tensor_path": str(sample.tensor_path or ""),
+ "preview_path": str(sample.preview_path or ""),
+ "semantic_mask_path": str(sample.masks.get("semantic") or ""),
+ "inference_backend": backend,
+ "inference_provider": args.onnx_provider if backend == "onnx" else str(device),
+ "inference_ms": float(t_inf),
+ "inference_total_ms": float(t_inf_total),
+ "target_threshold": float(tester.target_threshold),
+ "review_score": score,
+ "suspicion_pct": float(score * 100.0),
+ "semantic_miou": float(miou_sem),
+ "semantic_acc": float(acc_sem),
+ "iou_chao": float(iou_sem[0]) if len(iou_sem) > 0 else 0.0,
+ "iou_cana": float(iou_sem[1]) if len(iou_sem) > 1 else 0.0,
+ "iou_erva": float(iou_sem[2]) if len(iou_sem) > 2 else 0.0,
+ **signals,
+ "target_disagree_frac": float(target_disagree_frac),
+ "target_iou": float(target_stats["iou"]) if target_stats else 0.0,
+ "target_f1": float(target_stats["f1"]) if target_stats else 0.0,
+ "target_precision": float(target_stats["precision"]) if target_stats else 0.0,
+ "target_recall": float(target_stats["recall"]) if target_stats else 0.0,
+ }
+
+ reasons = build_reasons(
+ row,
+ min_disagree_frac=args.min_disagree_frac,
+ min_highconf_disagree_frac=args.min_highconf_disagree_frac,
+ min_cross_frac=args.min_cross_frac,
+ min_target_disagree_frac=args.min_target_disagree_frac,
+ min_conflict_pixels=args.min_conflict_pixels,
+ )
+ if args.min_review_score > 0 and score >= args.min_review_score:
+ reasons.append("REVIEW_SCORE")
+ row["review_reasons"] = ";".join(dict.fromkeys(reasons))
+ row["flagged"] = int(bool(reasons))
+ rows.append(row)
+
+ # Cache somente do que já pode ser exportado. Em --export_mode all,
+ # não guardamos o dataset inteiro em RAM; a cópia refaz a inferência.
+ cache_now = False
+ if not args.report_only and args.top_k_per_group <= 0:
+ if args.export_mode == "flagged":
+ cache_now = bool(reasons)
+ elif args.export_mode == "score":
+ cache_now = (score * 100.0) >= float(args.min_suspicion_pct)
+
+ if cache_now:
+ cache_for_copy[(sample.group, sample.base)] = (sample, chw, gt_sem, pred_sem)
+
+ if i % 50 == 0 or i == n:
+ flagged_now = sum(int(r["flagged"]) for r in rows)
+ print(
+ f"[{i:5d}/{n}] flagged={flagged_now:4d} "
+ f"avg_inf={infer_ms_sum / i:.1f}ms "
+ f"avg_infer_total={infer_total_ms_sum / i:.1f}ms "
+ f"last_score={score:.3f} {sample.group}/{sample.base}"
+ )
+
+ elapsed = time.perf_counter() - t0_all
+
+ # ========================================================
+ # Seleção final para revisão física
+ # ========================================================
+ by_group: Dict[str, List[dict]] = defaultdict(list)
+ for row in rows:
+ by_group[str(row["group"])].append(row)
+
+ selected_rows: List[dict] = []
+ for group, grows in sorted(by_group.items()):
+ ordered = sorted(grows, key=lambda r: float(r["review_score"]), reverse=True)
+
+ if args.top_k_per_group > 0:
+ # top_k tem precedência sobre export_mode.
+ chosen = ordered[: args.top_k_per_group]
+ for r in chosen:
+ if not r["review_reasons"]:
+ r["review_reasons"] = "TOP_K"
+ r["flagged"] = 1
+ elif args.export_mode == "all":
+ chosen = ordered
+ for r in chosen:
+ if not r["review_reasons"]:
+ r["review_reasons"] = "EXPORT_ALL"
+ elif args.export_mode == "score":
+ threshold_score = float(args.min_suspicion_pct) / 100.0
+ chosen = [r for r in ordered if float(r["review_score"]) >= threshold_score]
+ for r in chosen:
+ if not r["review_reasons"]:
+ r["review_reasons"] = "SUSPICION_SCORE"
+ else:
+ chosen = [r for r in ordered if int(r["flagged"]) == 1]
+
+ if args.max_review_per_group > 0:
+ chosen = chosen[: args.max_review_per_group]
+
+ for rank, r in enumerate(chosen, 1):
+ r["review_rank_group"] = rank
+ selected_rows.extend(chosen)
+
+ selected_keys = {(r["group"], r["base"]) for r in selected_rows}
+
+ # Copia apenas depois de finalizar ranking/caps.
+ if not args.report_only:
+ print(f"\n[COPY] candidatos finais: {len(selected_rows)}")
+ sample_by_key = {(s.group, s.base): s for s in samples}
+
+ for j, row in enumerate(selected_rows, 1):
+ key = (str(row["group"]), str(row["base"]))
+ sample = sample_by_key[key]
+
+ cached = cache_for_copy.get((sample.group, sample.base))
+ if cached is not None and cached[0].group == sample.group:
+ _, chw, gt_sem, pred_sem = cached
+ else:
+ chw = base.load_sample_tensor(
+ sample,
+ requested_channels=input_channel_names,
+ derived_config=config.get("derived_channels", {}) or {},
+ target_size=(target_w, target_h),
+ module_params_path=module_params_config,
+ )
+ if (chw.shape[2], chw.shape[1]) != (target_w, target_h):
+ hwc = np.transpose(chw, (1, 2, 0))
+ hwc = cv2.resize(hwc, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
+ chw = np.transpose(hwc, (2, 0, 1)).astype(np.float32)
+ preds, _, _ = tester.infer(chw)
+ pred_sem = preds["semantic"]
+ gt_sem = resize_ids(base.load_mask(sample.masks["semantic"]), pred_sem.shape[:2])
+
+ save_review_item(
+ row=row,
+ sample=sample,
+ chw=chw,
+ gt_sem=gt_sem,
+ pred_sem=pred_sem,
+ review_group_root=review_group_root,
+ base_module=base,
+ semantic_cmap=semantic_cmap,
+ ignore_id=ignore_id,
+ input_channel_names=input_channel_names,
+ save_panels=args.save_panels,
+ )
+
+ if j % 50 == 0 or j == len(selected_rows):
+ print(f" copiados {j}/{len(selected_rows)}")
+
+ # Ordem de revisão por grupo.
+ selected_by_group: Dict[str, List[dict]] = defaultdict(list)
+ for r in selected_rows:
+ selected_by_group[str(r["group"])].append(r)
+ for group, grows in selected_by_group.items():
+ order_path = review_group_root / group / "review_order.csv"
+ write_csv(
+ order_path,
+ sorted(grows, key=lambda r: int(r.get("review_rank_group", 999999))),
+ )
+
+ # ========================================================
+ # Relatórios globais
+ # ========================================================
+ report_path = report_dir / "review_report.csv"
+ candidates_path = report_dir / "review_candidates.csv"
+ write_csv(report_path, rows)
+ write_csv(candidates_path, selected_rows)
+
+ semantic_labels = [semantic_id2label[i] for i in sorted(semantic_id2label)]
+ head_labels = {
+ "semantic": semantic_labels,
+ "vegetation": ["background", "vegetation"],
+ "cana": ["not_cana", "cana"],
+ "target": ["background", "target"],
+ }
+
+ metrics_global = {}
+ for head_name, cm in cms_total.items():
+ if cm.sum() <= 0:
+ continue
+ labels = head_labels.get(head_name, [str(i) for i in range(cm.shape[0])])
+ metrics_global[head_name] = class_metrics_from_cm(cm, labels)
+ write_cm_csv(report_dir / f"confusion_{head_name}.csv", cm, labels)
+
+ cm_sem = cms_total.get("semantic")
+ directional = {}
+ if cm_sem is not None and cm_sem.shape[0] >= 3 and cm_sem.sum() > 0:
+ directional = {
+ "chao_to_cana": directional_rate(cm_sem, 0, 1),
+ "chao_to_erva": directional_rate(cm_sem, 0, 2),
+ "cana_to_chao": directional_rate(cm_sem, 1, 0),
+ "cana_to_erva": directional_rate(cm_sem, 1, 2),
+ "erva_to_chao": directional_rate(cm_sem, 2, 0),
+ "erva_to_cana": directional_rate(cm_sem, 2, 1),
+ }
+
+ # Estatísticas por grupo.
+ group_summary = {}
+ selected_set = {(str(r["group"]), str(r["base"])) for r in selected_rows}
+ for group, grows in sorted(by_group.items()):
+ scores = np.array([float(r["review_score"]) for r in grows], dtype=np.float64)
+ sem_dis = np.array([float(r["semantic_disagree_frac"]) for r in grows], dtype=np.float64)
+ hc_dis = np.array([float(r["highconf_disagree_frac"]) for r in grows], dtype=np.float64)
+ tgt_dis = np.array([float(r["target_disagree_frac"]) for r in grows], dtype=np.float64)
+ selected_n = sum((group, str(r["base"])) in selected_set for r in grows)
+ group_summary[group] = {
+ "samples": len(grows),
+ "selected_for_review": int(selected_n),
+ "selected_pct": float(100.0 * selected_n / max(len(grows), 1)),
+ "review_score_mean": float(scores.mean()) if len(scores) else 0.0,
+ "review_score_p95": float(np.percentile(scores, 95)) if len(scores) else 0.0,
+ "semantic_disagree_mean": float(sem_dis.mean()) if len(sem_dis) else 0.0,
+ "highconf_disagree_mean": float(hc_dis.mean()) if len(hc_dis) else 0.0,
+ "target_disagree_mean": float(tgt_dis.mean()) if len(tgt_dis) else 0.0,
+ }
+
+ all_scores = np.array([float(r["review_score"]) for r in rows], dtype=np.float64)
+ all_sem_dis = np.array([float(r["semantic_disagree_frac"]) for r in rows], dtype=np.float64)
+ all_hc = np.array([float(r["highconf_disagree_frac"]) for r in rows], dtype=np.float64)
+ all_unc = np.array([float(r["uncertain_frac"]) for r in rows], dtype=np.float64)
+
+ correct_pixels_total = sum(max(int(r.get("valid_pixels", 0)) - int(r.get("wrong_pixels", 0)), 0) for r in rows)
+ wrong_pixels_total = sum(int(r.get("wrong_pixels", 0)) for r in rows)
+ mean_conf_correct_global = (
+ sum(float(r.get("mean_conf_correct", 0.0)) * max(int(r.get("valid_pixels", 0)) - int(r.get("wrong_pixels", 0)), 0) for r in rows)
+ / max(correct_pixels_total, 1)
+ )
+ mean_conf_wrong_global = (
+ sum(float(r.get("mean_conf_wrong", 0.0)) * int(r.get("wrong_pixels", 0)) for r in rows)
+ / max(wrong_pixels_total, 1)
+ )
+
+ gt_chao_total = sum(int(r.get("gt_chao_pixels", 0)) for r in rows)
+ gt_cana_total = sum(int(r.get("gt_cana_pixels", 0)) for r in rows)
+ gt_erva_total = sum(int(r.get("gt_erva_pixels", 0)) for r in rows)
+ highconf_directional = {
+ "chao_to_cana": sum(int(r.get("hc_gt_chao_pred_cana_pixels", 0)) for r in rows) / max(gt_chao_total, 1),
+ "chao_to_erva": sum(int(r.get("hc_gt_chao_pred_erva_pixels", 0)) for r in rows) / max(gt_chao_total, 1),
+ "cana_to_erva": sum(int(r.get("hc_gt_cana_pred_erva_pixels", 0)) for r in rows) / max(gt_cana_total, 1),
+ "erva_to_cana": sum(int(r.get("hc_gt_erva_pred_cana_pixels", 0)) for r in rows) / max(gt_erva_total, 1),
+ }
+
+ summary = {
+ "schema": "oak_fcc3_dataset_review_v1",
+ "note": (
+ "Model-vs-GT disagreement is a review signal, not automatic proof of model error. "
+ "High-confidence disagreement can indicate label noise or a systematic model mistake."
+ ),
+ "run_name": run_name,
+ "config": str(config_path),
+ "root": str(root),
+ "backend": backend,
+ "model_path": str(model_path),
+ "checkpoint": str(ckpt_path) if ckpt_path is not None else None,
+ "onnx": str(onnx_path) if onnx_path is not None else None,
+ "onnx_provider": args.onnx_provider if backend == "onnx" else None,
+ "onnx_active_providers": list(getattr(getattr(tester, "session", None), "get_providers", lambda: [])()) if backend == "onnx" else None,
+ "onnx_export_meta": str(onnx_meta_path) if onnx_meta_path is not None else None,
+ "onnx_normalization_embedded": bool(onnx_norm_embedded) if backend == "onnx" else None,
+ "target_threshold_source": threshold_source,
+ "norm_stats": str(norm_stats_path),
+ "resolution": [target_w, target_h],
+ "input_channels": list(input_channel_names),
+ "target_threshold": float(tester.target_threshold),
+ "samples": len(rows),
+ "selected_for_review": len(selected_rows),
+ "selected_pct": float(100.0 * len(selected_rows) / max(len(rows), 1)),
+ "export": {
+ "mode": str(args.export_mode),
+ "min_suspicion_pct": float(args.min_suspicion_pct),
+ "report_only": bool(args.report_only),
+ "top_k_per_group": int(args.top_k_per_group),
+ "max_review_per_group": int(args.max_review_per_group),
+ "png_only": True,
+ },
+ "elapsed_seconds": float(elapsed),
+ "warmup_seconds_excluded": float(warmup_seconds),
+ "mean_inference_ms": float(infer_ms_sum / max(len(rows), 1)),
+ "mean_inference_total_ms": float(infer_total_ms_sum / max(len(rows), 1)),
+ "inference_timing_scope": (
+ "onnx_session_run" if backend == "onnx" else "pytorch_tester_reported"
+ ),
+ "review_thresholds": {
+ "high_confidence": args.high_confidence,
+ "uncertainty_margin": args.uncertainty_margin,
+ "min_disagree_frac": args.min_disagree_frac,
+ "min_highconf_disagree_frac": args.min_highconf_disagree_frac,
+ "min_cross_frac": args.min_cross_frac,
+ "min_target_disagree_frac": args.min_target_disagree_frac,
+ "min_conflict_pixels": args.min_conflict_pixels,
+ "min_review_score": args.min_review_score,
+ "top_k_per_group": args.top_k_per_group,
+ "max_review_per_group": args.max_review_per_group,
+ },
+ "review_score": {
+ "mean": float(all_scores.mean()) if len(all_scores) else 0.0,
+ "median": float(np.median(all_scores)) if len(all_scores) else 0.0,
+ "p95": float(np.percentile(all_scores, 95)) if len(all_scores) else 0.0,
+ "max": float(all_scores.max()) if len(all_scores) else 0.0,
+ },
+ "annotation_health_signals": {
+ "semantic_disagree_mean": float(all_sem_dis.mean()) if len(all_sem_dis) else 0.0,
+ "semantic_disagree_p95": float(np.percentile(all_sem_dis, 95)) if len(all_sem_dis) else 0.0,
+ "highconf_disagree_mean": float(all_hc.mean()) if len(all_hc) else 0.0,
+ "highconf_disagree_p95": float(np.percentile(all_hc, 95)) if len(all_hc) else 0.0,
+ "uncertain_mean": float(all_unc.mean()) if len(all_unc) else 0.0,
+ "mean_model_confidence_on_gt_agreement": float(mean_conf_correct_global),
+ "mean_model_confidence_on_gt_disagreement": float(mean_conf_wrong_global),
+ },
+ "directional_semantic_confusion": directional,
+ "high_confidence_directional_conflict": highconf_directional,
+ "metrics_global": metrics_global,
+ "groups": group_summary,
+ "paths": {
+ "review_report": str(report_path),
+ "review_candidates": str(candidates_path),
+ "review_group_root": str(review_group_root),
+ },
+ }
+
+ summary_path = report_dir / "review_summary.json"
+ with summary_path.open("w", encoding="utf-8") as f:
+ json.dump(summary, f, ensure_ascii=False, indent=2)
+
+ # README curto para não esquecer o significado do score.
+ readme = report_dir / "README_REVIEW.txt"
+ readme.write_text(
+ "\n".join([
+ "DATASET REVIEW / MODEL MINING",
+ "",
+ "IMPORTANTE:",
+ "- Discordancia modelo x GT nao prova que o modelo errou.",
+ "- Conflito de alta confianca e um candidato forte para revisao humana.",
+ "- review_score serve apenas para RANKING de suspeitos.",
+ "- suspicion_pct = review_score*100 e NAO e probabilidade de mascara errada.",
+ f"- export_mode={args.export_mode} min_suspicion_pct={args.min_suspicion_pct:.1f}",
+ "- Exportacao fisica usa somente PNG.",
+ "- --clear_review PRESERVA final_masks; --clear_review_all apaga tudo.",
+ "- Para comparar checkpoints, use --report_only e run_name diferentes.",
+ "",
+ f"Backend: {backend}",
+ f"Model: {model_path}",
+ f"Provider: {args.onnx_provider if backend == 'onnx' else device}",
+ f"Root: {root}",
+ f"Samples: {len(rows)}",
+ f"Selecionados: {len(selected_rows)}",
+ ]),
+ encoding="utf-8",
+ )
+
+ print("\n" + "=" * 72)
+ print("REVISÃO CONCLUÍDA")
+ print(f"Samples : {len(rows)}")
+ print(f"Selecionados p/ revisão : {len(selected_rows)} ({100.0 * len(selected_rows) / max(len(rows), 1):.2f}%)")
+ print(f"Export mode : {args.export_mode}")
+ if args.export_mode == "score":
+ print(f"Suspicion mínima : {args.min_suspicion_pct:.1f}/100")
+ print(f"Tempo total : {elapsed:.1f}s")
+ print(f"Backend : {backend}")
+ print(f"Inferência backend média : {infer_ms_sum / max(len(rows), 1):.1f}ms")
+ print(f"Inferência total média : {infer_total_ms_sum / max(len(rows), 1):.1f}ms")
+ if backend == "onnx":
+ print(f"ONNX provider : {args.onnx_provider}")
+ print(f"Warmup excluído : {warmup_seconds:.2f}s")
+ print(f"Report : {report_path}")
+ print(f"Candidates : {candidates_path}")
+ print(f"Summary : {summary_path}")
+ if not args.report_only:
+ print(f"Revisão física : {review_group_root}")
+
+ if directional:
+ print("\nConfusão semântica direcional global:")
+ print(f" chão -> cana : {100.0 * directional['chao_to_cana']:.3f}%")
+ print(f" chão -> erva : {100.0 * directional['chao_to_erva']:.3f}%")
+ print(f" cana -> chão : {100.0 * directional['cana_to_chao']:.3f}%")
+ print(f" cana -> erva : {100.0 * directional['cana_to_erva']:.3f}%")
+ print(f" erva -> chão : {100.0 * directional['erva_to_chao']:.3f}%")
+ print(f" erva -> cana : {100.0 * directional['erva_to_cana']:.3f}%")
+
+ sem_global = metrics_global.get("semantic")
+ if sem_global:
+ print(f"\nSemantic global: acc={sem_global['accuracy']:.4f} mIoU={sem_global['miou']:.4f}")
+ target_global = metrics_global.get("target")
+ if target_global:
+ print(f"Target global : acc={target_global['accuracy']:.4f} mIoU={target_global['miou']:.4f}")
+
+ print("=" * 72)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/_9_test_multihead.py b/Python/OAK/datasets/oak-fcc-3/_9_test_multihead.py
index 4ad0a6fc3..8927b1bc7 100644
--- a/Python/OAK/datasets/oak-fcc-3/_9_test_multihead.py
+++ b/Python/OAK/datasets/oak-fcc-3/_9_test_multihead.py
@@ -25,10 +25,7 @@ Mostra:
Exemplo:
-python .\\_9_test_infer_multihead.py ^
- --config config.json ^
- --split_folder val ^
- --ckpt backup\segformer_b1\test_multi\stacked_raw5_multihead\best_score.pt
+python .\\_9_test_multihead.py --config config.json --split_folder val --ckpt backup\segformer_b1\test_multi\stacked_raw5_multihead\best_score.pt
Controles:
D / seta direita : próxima amostra
@@ -634,7 +631,20 @@ def collect_samples(root: Path, heads_config: Dict[str, dict], require_masks: bo
missing = []
for head_name, hcfg in heads_config.items():
- mask_dir = group_dir / str(hcfg.get("mask_dir", "masks"))
+ mask_dir_name = str(hcfg.get("mask_dir", "masks"))
+ is_derived = (
+ bool(hcfg.get("derived", False))
+ or mask_dir_name == "__derived_target__"
+ )
+
+ # Heads derivadas não possuem máscara física.
+ # O GT target será construído posteriormente a partir de:
+ # vegetation == 1 AND cana == 0.
+ if is_derived:
+ masks[head_name] = None
+ continue
+
+ mask_dir = group_dir / mask_dir_name
mask_path = mask_dir / f"{base}.npy"
if mask_path.exists():
@@ -2002,14 +2012,25 @@ def main():
sample_metrics[head_name] = {"iou": iou, "miou": miou, "acc": acc}
metric_lines.append(f"{head_name}: mIoU={miou:.3f} acc={acc:.3f}")
- if gt_target is not None:
- cm_t = confusion_matrix_np(pred_target, gt_target, 2, ignore_id)
+ if gt_target is not None and pred_target_op is not None:
+ cm_t = confusion_matrix_np(pred_target_op, gt_target, 2, ignore_id)
iou_t, miou_t, acc_t = metrics_from_cm(cm_t)
- sample_metrics["target_op"] = {"iou": iou_t, "miou": miou_t, "acc": acc_t}
- metric_lines.append(f"target_op: IoU_alvo={iou_t[1]:.3f} acc={acc_t:.3f}")
+
+ sample_metrics["target_op"] = {
+ "iou": iou_t,
+ "miou": miou_t,
+ "acc": acc_t,
+ }
+
+ metric_lines.append(
+ f"target_op: IoU_alvo={iou_t[1]:.3f} acc={acc_t:.3f}"
+ )
+
if "target" in sample_metrics:
iou_head = sample_metrics["target"]["iou"]
- metric_lines.append(f"target_head: IoU_alvo={iou_head[1]:.3f}")
+ metric_lines.append(
+ f"target_head: IoU_alvo={iou_head[1]:.3f}"
+ )
if idx not in visited:
for head_name, pred in preds.items():
diff --git a/Python/OAK/datasets/oak-fcc-3/_9_test_multihead_v2.py b/Python/OAK/datasets/oak-fcc-3/_9_test_multihead_v2.py
new file mode 100644
index 000000000..2f1a526d5
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/_9_test_multihead_v2.py
@@ -0,0 +1,2406 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""
+_9_test_multihead_v2_1.py
+
+Teste/visualização do SegFormer OAK-FCC-3 Multi-Head.
+
+Contrato esperado após normalize + split:
+
+ dataset/split/val/group//
+ tensors/.npy # CHW float32 conforme meta/config
+ masks/.npy # semantic: 0=chao, 1=cana, 2=erva, 255=ignore
+ masks_vegetation/.npy # vegetation: 0=background, 1=vegetation, 255=ignore
+ masks_cana/.npy # cana: 0=not_cana, 1=cana, 255=ignore
+ metas/.json
+ previews/.png
+
+Mostra:
+ - RGB preview do tensor
+ - GT/pred/overlay semantic
+ - GT/pred/overlay vegetation
+ - GT/pred/overlay cana
+ - alvo operacional = vegetation == 1 AND cana == 0
+ - mapas de confiança das heads binárias
+
+Exemplo:
+
+python .\\_9_test_multihead.py --config config.json --split_folder val --ckpt backup\segformer_b1\test_multi\stacked_raw5_multihead\best_score.pt
+
+Controles:
+ D / seta direita : próxima amostra
+ A / seta esquerda: amostra anterior
+ S : salvar painel atual em --out_dir
+ SPACE : alterna modo compacto/detalhado
+ Q / ESC : sair
+"""
+
+from __future__ import annotations
+
+import argparse
+import copy
+import json
+import time
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Optional, Sequence, Tuple
+
+import cv2
+import numpy as np
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from transformers import SegformerForSemanticSegmentation
+
+from core.raw_processor_core import RawProcessorCore
+
+
+# ============================================================
+# Config default
+# ============================================================
+
+DEFAULT_HEADS = {
+ "semantic": {
+ "enabled": True,
+ "type": "multiclass",
+ "num_classes": 3,
+ "mask_dir": "masks",
+ "classes": {"chao": 0, "cana": 1, "erva": 2},
+ "ignore_index": 255,
+ },
+ "vegetation": {
+ "enabled": True,
+ "type": "binary",
+ "num_classes": 2,
+ "mask_dir": "masks_vegetation",
+ "classes": {"background": 0, "vegetation": 1},
+ "ignore_index": 255,
+ },
+ "cana": {
+ "enabled": True,
+ "type": "binary",
+ "num_classes": 2,
+ "mask_dir": "masks_cana",
+ "classes": {"not_cana": 0, "cana": 1},
+ "ignore_index": 255,
+ },
+ "target": {
+ "enabled": True,
+ "type": "binary",
+ "num_classes": 2,
+ "mask_dir": "__derived_target__",
+ "classes": {
+ "background": 0,
+ "target": 1,
+ },
+ "ignore_index": 255,
+ "derived_from": ["vegetation", "cana"],
+ },
+}
+
+SEMANTIC_COLORS_RGB = {
+ 0: (85, 85, 85), # chao
+ 1: (0, 190, 0), # cana
+ 2: (230, 55, 55), # erva
+}
+
+BINARY_COLORS_RGB = {
+ 0: (30, 30, 30),
+ 1: (0, 220, 80),
+}
+
+CANA_COLORS_RGB = {
+ 0: (30, 30, 30),
+ 1: (40, 210, 255),
+}
+
+TARGET_COLORS_RGB = {
+ 0: (30, 30, 30),
+ 1: (255, 70, 30),
+}
+
+
+@dataclass
+class SampleItem:
+ group: str
+ base: str
+ tensor_path: Optional[Path]
+ masks: Dict[str, Optional[Path]]
+ meta_path: Optional[Path] = None
+ preview_path: Optional[Path] = None
+
+ # novo
+ source_kind: str = "tensor" # "tensor" ou "raw_native_multi"
+ raw_group: Optional[dict] = None
+
+
+# ============================================================
+# Util
+# ============================================================
+
+def load_json(path: str | Path) -> dict:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def ensure_dir(path: Path):
+ path.mkdir(parents=True, exist_ok=True)
+
+
+def resolve_path(path_like: Optional[str], base: Optional[Path] = None) -> Optional[Path]:
+ if path_like is None:
+ return None
+ p = Path(path_like)
+ if p.is_absolute():
+ return p
+ if base is None:
+ base = Path.cwd()
+ return (base / p).resolve()
+
+
+def merge_dict(dst: dict, src: dict) -> dict:
+ out = copy.deepcopy(dst)
+
+ def rec(a, b):
+ for k, v in b.items():
+ if isinstance(v, dict) and isinstance(a.get(k), dict):
+ rec(a[k], v)
+ else:
+ a[k] = v
+
+ if isinstance(src, dict):
+ rec(out, src)
+ return out
+
+
+def build_heads_config(config: dict, ignore_index: int) -> Dict[str, dict]:
+ cfg = merge_dict(DEFAULT_HEADS, config.get("heads", {}) or {})
+ active = {}
+ for name, hcfg in cfg.items():
+ if not bool(hcfg.get("enabled", True)):
+ continue
+ hcfg.setdefault("ignore_index", ignore_index)
+ hcfg["ignore_index"] = int(hcfg.get("ignore_index", ignore_index))
+ hcfg["num_classes"] = int(hcfg.get("num_classes", 2))
+ hcfg["mask_dir"] = str(hcfg.get("mask_dir", "masks"))
+ active[name] = hcfg
+
+ for required in ("semantic", "vegetation", "cana"):
+ if required not in active:
+ raise RuntimeError(f"Head obrigatória ausente no config: {required}")
+
+ return active
+
+
+def load_labelmap(labelmap_path: Path) -> Tuple[Dict[int, str], Dict[str, int], int, Dict[int, Tuple[int, int, int]]]:
+ try:
+ from helpers import carregar_labelmap_completo, _infer_ignore_id
+
+ _cor_para_id, _colormap_rgb, id_para_nome, ignore_rgb = carregar_labelmap_completo(str(labelmap_path))
+ ignore_id = int(_infer_ignore_id(ignore_rgb, 255))
+
+ id2label = {
+ int(k): str(v)
+ for k, v in id_para_nome.items()
+ if str(v).lower() not in ("ignore", "void", "background_ignore")
+ }
+ label2id = {v.lower(): k for k, v in id2label.items()}
+
+ colormap_rgb = {}
+ if isinstance(_colormap_rgb, dict):
+ for k, v in _colormap_rgb.items():
+ ik = int(k)
+ if ik in id2label:
+ colormap_rgb[ik] = tuple(map(int, v[:3]))
+ elif isinstance(_colormap_rgb, (list, tuple)):
+ for ik, v in enumerate(_colormap_rgb):
+ if ik in id2label:
+ colormap_rgb[ik] = tuple(map(int, v[:3]))
+
+ return id2label, label2id, ignore_id, colormap_rgb
+
+ except Exception as e:
+ print(f"[WARN] Não consegui usar helpers.carregar_labelmap_completo: {e}")
+ print("[WARN] Usando parser simples do labelmap.")
+
+ id2label: Dict[int, str] = {}
+ colormap_rgb: Dict[int, Tuple[int, int, int]] = {}
+ ignore_id = 255
+ next_id = 0
+
+ with labelmap_path.open("r", encoding="utf-8") as f:
+ for raw_line in f:
+ s = raw_line.strip()
+ if not s or s.startswith("#"):
+ continue
+
+ name = None
+ color = None
+ cid = None
+
+ if ":" in s and not s.split(":", 1)[0].strip().isdigit():
+ name_part, rest = s.split(":", 1)
+ name = name_part.strip()
+ color_txt = rest.split("::", 1)[0].strip().strip(":")
+ rgb_parts = [p.strip() for p in color_txt.split(",") if p.strip()]
+ if len(rgb_parts) >= 3:
+ color = tuple(int(float(p)) for p in rgb_parts[:3])
+ else:
+ parts = s.replace(",", " ").replace(":", " ").split()
+ if len(parts) >= 2 and parts[0].isdigit():
+ cid = int(parts[0])
+ name = parts[1]
+ if len(parts) >= 5:
+ color = tuple(int(float(p)) for p in parts[2:5])
+ elif len(parts) >= 1:
+ name = parts[0]
+
+ if not name:
+ continue
+
+ if name.lower() in ("ignore", "void", "background_ignore"):
+ ignore_id = 255
+ continue
+
+ if cid is None:
+ cid = next_id
+ next_id = max(next_id, cid + 1)
+
+ id2label[int(cid)] = str(name)
+ if color is not None:
+ colormap_rgb[int(cid)] = color
+
+ if not id2label:
+ id2label = {0: "chao", 1: "cana", 2: "erva"}
+
+ label2id = {v.lower(): k for k, v in id2label.items()}
+ for cid in id2label:
+ colormap_rgb.setdefault(cid, SEMANTIC_COLORS_RGB.get(cid, (255, 255, 255)))
+
+ return id2label, label2id, int(ignore_id), colormap_rgb
+
+
+def find_raw_dataset_layout_root(path: Path) -> Optional[Path]:
+ """
+ Detecta layout bruto:
+ root/metas
+ root/previews
+ root/bins
+ root/masks opcional
+
+ Aceita root, root/metas, root/bins, root/previews ou arquivo dentro deles.
+ """
+ p = path.resolve()
+ candidates = []
+
+ if p.is_file():
+ candidates.append(p.parent)
+ candidates.append(p.parent.parent)
+ else:
+ candidates.append(p)
+ candidates.append(p.parent)
+
+ for c in candidates:
+ if not c:
+ continue
+
+ if c.name.lower() in ("metas", "metadata", "jsons", "previews", "bins", "masks"):
+ root = c.parent
+ else:
+ root = c
+
+ if (root / "metas").is_dir() and (root / "previews").is_dir() and (root / "bins").is_dir():
+ return root
+
+ return None
+
+
+def resolve_raw_sibling_file(root: Path, subdir: str, stem: str, exts: Tuple[str, ...]) -> Optional[Path]:
+ folder = root / subdir
+ if not folder.is_dir():
+ return None
+
+ for ext in exts:
+ p = folder / f"{stem}{ext}"
+ if p.exists():
+ return p
+
+ return None
+
+
+def resolve_raw_capture_group_from_json(json_path: Path, dataset_root: Path) -> dict:
+ """
+ Resolve uma captura bruta:
+ metas/.json
+ previews/.png
+ bins/
+ masks/ opcional
+ """
+ json_path = json_path.resolve()
+ meta = load_json(json_path)
+ base_name = json_path.stem
+ bins_dir = dataset_root / "bins"
+
+ preview_path = resolve_raw_sibling_file(
+ dataset_root,
+ "previews",
+ base_name,
+ (".png", ".jpg", ".jpeg"),
+ )
+
+ mask_path = resolve_raw_sibling_file(
+ dataset_root,
+ "masks",
+ base_name,
+ (".npy", ".png", ".tif", ".tiff"),
+ )
+
+ group = {
+ "json": json_path,
+ "png": preview_path,
+ "mask": mask_path,
+ "final_raw": None,
+ "cameras": {},
+ "dataset_root": dataset_root,
+ }
+
+ if "saved_payload_paths" in meta:
+ for cam_id, fname in meta["saved_payload_paths"].items():
+ fname_path = Path(fname)
+
+ candidates = [
+ bins_dir / fname_path.name,
+ json_path.parent / fname,
+ dataset_root / fname,
+ bins_dir / f"{base_name}_{cam_id}.bin",
+ bins_dir / f"{base_name}_{cam_id}.raw",
+ bins_dir / f"{base_name}_{cam_id.lower()}.bin",
+ bins_dir / f"{base_name}_{cam_id.lower()}.raw",
+ ]
+
+ found = next((c for c in candidates if c.exists()), None)
+ if found is not None:
+ group["cameras"][cam_id] = found
+ else:
+ print(f"[WARN] bin não encontrado para {cam_id}: {fname}")
+
+ return group
+
+
+def build_multispec_from_raw_native_multi_for_infer(
+ group: dict,
+ meta: dict,
+ target_size: Optional[Tuple[int, int]] = None,
+ module_params_path: Optional[str] = None,
+) -> Tuple[np.ndarray, dict]:
+ """
+ Gera tensor MULTISPEC [R,G,B,RE,NIR] em CHW float32 0..1
+ a partir dos bins RAW_BRUTO salvos.
+ """
+ if meta.get("saved_payload_type") != "raw_native_multi":
+ raise RuntimeError(
+ f"Captura bruta não suportada aqui: saved_payload_type={meta.get('saved_payload_type')}"
+ )
+
+ stream_meta = meta.get("stream_meta", {}) or {}
+ saved_dtypes = meta.get("saved_payload_dtypes", {}) or {}
+ saved_shapes = meta.get("saved_payload_shapes", {}) or {}
+
+ frame = {}
+
+ for cam_id, path in group["cameras"].items():
+ saved_dtype = saved_dtypes.get(cam_id)
+ saved_shape = saved_shapes.get(cam_id)
+
+ if saved_dtype is None or saved_shape is None:
+ raise RuntimeError(f"Faltam dtype/shape para {cam_id} no JSON: {group['json']}")
+
+ arr = np.fromfile(str(path), dtype=np.dtype(saved_dtype)).reshape(tuple(saved_shape))
+ frame[cam_id] = arr
+
+ if not frame:
+ raise RuntimeError(f"Nenhum bin de câmera encontrado para: {group['json']}")
+
+ sensor_width = int(meta.get("sensor_width", 1280))
+ sensor_height = int(meta.get("sensor_height", 800))
+ bayer = meta.get("bayer_pattern", "BGGR")
+
+ json_dir = Path(group["json"]).parent
+ calib_path = None
+ for candidate in (
+ module_params_path,
+ meta.get("camera_params_json"),
+ "calibration/module_params.json",
+ ):
+ if not candidate:
+ continue
+ p = Path(str(candidate))
+ for resolved in (p, Path.cwd() / p, json_dir / p):
+ if resolved.is_file():
+ calib_path = str(resolved.resolve())
+ break
+ if calib_path is not None:
+ break
+
+ if calib_path is None:
+ raise RuntimeError(
+ "module_params.json não encontrado. A inferência RAW deve usar a mesma "
+ "calibração do normalize; confira config.module_params_json."
+ )
+
+ core = RawProcessorCore(
+ sensor_width=sensor_width,
+ sensor_height=sensor_height,
+ bayer_pattern=bayer,
+ calibration_json_path=calib_path,
+ )
+
+ processing_meta = dict(stream_meta)
+ processing_meta["frame_type"] = "RAW_BRUTO"
+
+ if meta.get("actual_camera_controls") is not None:
+ processing_meta["actual_camera_controls"] = meta.get("actual_camera_controls")
+
+ if meta.get("startup_camera_controls") is not None:
+ processing_meta["startup_camera_controls"] = meta.get("startup_camera_controls")
+
+ tensor = core.build_infer_tensor_from_stream(
+ frame=frame,
+ meta=processing_meta,
+ channels_expected=5,
+ target_size=target_size,
+ )
+
+ if tensor is None:
+ raise RuntimeError(f"RawProcessorCore retornou tensor None para: {group['json']}")
+
+ tensor = np.asarray(tensor, dtype=np.float32)
+ tensor = np.nan_to_num(tensor, nan=0.0, posinf=1.0, neginf=0.0)
+
+ processing_info = {
+ "fusion_result": getattr(core, "last_fusion_result", None),
+ "fusion_config_used": getattr(core, "fusion_config", None),
+ "radiometric_normalization_result": getattr(core, "last_radiometric_normalization_result", None),
+ "patch_normalization_result": getattr(core, "last_patch_normalization_result", None),
+ "frame_quality": getattr(core, "last_frame_quality_result", None),
+ }
+
+ return tensor, processing_info
+
+
+SOURCE_CHANNEL_ORDER = ["R", "G", "B", "RE", "NIR"]
+DERIVED_CHANNEL_ORDER = ["NDVI", "NDRE"]
+SUPPORTED_INPUT_CHANNELS = SOURCE_CHANNEL_ORDER + DERIVED_CHANNEL_ORDER
+
+def get_input_channel_names(config: dict, channels_override: Optional[int] = None) -> List[str]:
+ if "input_channels" in config:
+ configured = config["input_channels"]
+ if isinstance(configured, str):
+ names = [str(c).strip().upper() for c in configured.split(",") if str(c).strip()]
+ else:
+ names = [str(c).strip().upper() for c in configured if str(c).strip()]
+ else:
+ n = int(channels_override or config.get("channels", 5))
+ if n < 1 or n > len(SOURCE_CHANNEL_ORDER):
+ raise RuntimeError(
+ "Config sem input_channels só suporta channels entre 1 e 5. "
+ "Para NDVI/NDRE, declare input_channels explicitamente."
+ )
+ names = SOURCE_CHANNEL_ORDER[:n]
+
+ if not names:
+ raise RuntimeError("input_channels não pode ser vazio.")
+
+ duplicates = sorted({name for name in names if names.count(name) > 1})
+ if duplicates:
+ raise RuntimeError(f"Canais duplicados em input_channels: {duplicates}")
+
+ invalid = [c for c in names if c not in SUPPORTED_INPUT_CHANNELS]
+ if invalid:
+ raise RuntimeError(
+ f"Canais inválidos em input_channels: {invalid}. "
+ f"Suportados: {SUPPORTED_INPUT_CHANNELS}"
+ )
+
+ expected_count = int(channels_override) if channels_override is not None else config.get("channels")
+ if expected_count is not None and int(expected_count) != len(names):
+ raise RuntimeError(
+ "channels incompatível com input_channels: "
+ f"channels={expected_count}, input_channels={names} ({len(names)} canais)."
+ )
+
+ return names
+
+
+def get_input_channel_indices(config: dict, channels_override: Optional[int] = None) -> List[int]:
+ """Indices do tensor normalizado v2, já salvo na ordem final."""
+ return list(range(len(get_input_channel_names(config, channels_override))))
+
+
+def build_model_input_tensor(
+ source_tensor: np.ndarray,
+ requested_channels: Sequence[str],
+ derived_config: Optional[dict] = None,
+) -> np.ndarray:
+ """Replica exatamente a derivação de canais usada pelo normalize."""
+ derived_config = derived_config or {}
+ eps = float(derived_config.get("epsilon", 1e-6))
+ clip_min = float(derived_config.get("clip_min", -1.0))
+ clip_max = float(derived_config.get("clip_max", 1.0))
+
+ if not np.isfinite(eps) or eps <= 0.0:
+ raise RuntimeError(f"derived_channels.epsilon inválido: {eps}")
+ if not np.isfinite(clip_min) or not np.isfinite(clip_max) or clip_min >= clip_max:
+ raise RuntimeError(
+ f"Faixa derived_channels inválida: clip_min={clip_min}, clip_max={clip_max}"
+ )
+
+ source = np.nan_to_num(
+ np.asarray(source_tensor, dtype=np.float32),
+ nan=0.0,
+ posinf=1.0,
+ neginf=0.0,
+ )
+ if source.ndim != 3 or source.shape[0] != len(SOURCE_CHANNEL_ORDER):
+ raise RuntimeError(
+ f"Tensor fonte inválido: esperado {SOURCE_CHANNEL_ORDER}, shape={source.shape}"
+ )
+
+ available = {
+ name: source[index]
+ for index, name in enumerate(SOURCE_CHANNEL_ORDER)
+ }
+
+ def normalized_difference(a: np.ndarray, b: np.ndarray) -> np.ndarray:
+ denominator = a + b
+ result = np.zeros_like(a, dtype=np.float32)
+ np.divide(
+ a - b,
+ denominator,
+ out=result,
+ where=np.abs(denominator) > eps,
+ )
+ return np.clip(result, clip_min, clip_max).astype(np.float32, copy=False)
+
+ if "NDVI" in requested_channels:
+ available["NDVI"] = normalized_difference(available["NIR"], available["R"])
+ if "NDRE" in requested_channels:
+ available["NDRE"] = normalized_difference(available["NIR"], available["RE"])
+
+ return np.ascontiguousarray(
+ np.stack([available[str(name).upper()] for name in requested_channels], axis=0),
+ dtype=np.float32,
+ )
+
+
+# ============================================================
+# Dataset
+# ============================================================
+
+def collect_samples(root: Path, heads_config: Dict[str, dict], require_masks: bool = False) -> Tuple[List[SampleItem], bool]:
+ tensor_paths: List[Path] = []
+
+ direct = root / "tensors"
+ if direct.is_dir():
+ tensor_paths.extend(sorted(direct.glob("*.npy")))
+
+ group_root = root / "group"
+ if group_root.is_dir():
+ for gdir in sorted(group_root.iterdir()):
+ tdir = gdir / "tensors"
+ if tdir.is_dir():
+ tensor_paths.extend(sorted(tdir.glob("*.npy")))
+
+ if not tensor_paths:
+ tensor_paths.extend(sorted(root.glob("**/tensors/*.npy")))
+
+ # ============================================================
+ # MODO 1: dataset já normalizado com tensors/*.npy
+ # ============================================================
+ if tensor_paths:
+ samples: List[SampleItem] = []
+ has_any_gt = False
+
+ for tp in tensor_paths:
+ base = tp.stem
+ group_name = tp.parent.parent.name if tp.parent.name == "tensors" else "default"
+ group_dir = tp.parent.parent if tp.parent.name == "tensors" else tp.parent
+
+ masks: Dict[str, Optional[Path]] = {}
+ missing = []
+
+ for head_name, hcfg in heads_config.items():
+ mask_dir_name = str(hcfg.get("mask_dir", "masks"))
+ is_derived = (
+ bool(hcfg.get("derived", False))
+ or mask_dir_name == "__derived_target__"
+ )
+
+ # Heads derivadas não possuem máscara física.
+ # O GT target será construído posteriormente a partir de:
+ # vegetation == 1 AND cana == 0.
+ if is_derived:
+ masks[head_name] = None
+ continue
+
+ mask_dir = group_dir / mask_dir_name
+ mask_path = mask_dir / f"{base}.npy"
+
+ if mask_path.exists():
+ masks[head_name] = mask_path
+ has_any_gt = True
+ else:
+ masks[head_name] = None
+ missing.append(f"{head_name}:{mask_path}")
+
+ if require_masks and missing:
+ raise RuntimeError(f"Masks ausentes para {tp}: {missing}")
+
+ meta_path = group_dir / "metas" / f"{base}.json"
+ preview_path = group_dir / "previews" / f"{base}.png"
+
+ samples.append(SampleItem(
+ group=group_name,
+ base=base,
+ tensor_path=tp,
+ masks=masks,
+ meta_path=meta_path if meta_path.exists() else None,
+ preview_path=preview_path if preview_path.exists() else None,
+ source_kind="tensor",
+ raw_group=None,
+ ))
+
+ return samples, has_any_gt
+
+ # ============================================================
+ # MODO 2: dataset bruto com bins/metas/previews/masks
+ # ============================================================
+ raw_root = find_raw_dataset_layout_root(root)
+
+ if raw_root is None:
+ raise RuntimeError(
+ f"Nenhum tensor .npy encontrado e também não detectei layout bruto "
+ f"com bins/metas/previews em: {root}"
+ )
+
+ meta_paths = sorted((raw_root / "metas").glob("*.json"))
+ if not meta_paths:
+ raise RuntimeError(f"Nenhum meta .json encontrado em: {raw_root / 'metas'}")
+
+ samples: List[SampleItem] = []
+ has_any_gt = False
+
+ for mp in meta_paths:
+ base = mp.stem
+ meta = load_json(mp)
+
+ if meta.get("saved_payload_type") != "raw_native_multi":
+ print(f"[WARN] pulando {mp.name}: saved_payload_type={meta.get('saved_payload_type')}")
+ continue
+
+ raw_group = resolve_raw_capture_group_from_json(mp, raw_root)
+
+ masks: Dict[str, Optional[Path]] = {}
+
+ # Tenta masks específicas multi-head primeiro.
+ for head_name, hcfg in heads_config.items():
+ mask_dir = raw_root / str(hcfg.get("mask_dir", "masks"))
+ mask_path = None
+
+ mask_dir_name = str(hcfg.get("mask_dir", "masks"))
+
+ if mask_dir_name == "__derived_target__" or bool(hcfg.get("derived", False)):
+ masks[head_name] = None
+ continue
+
+ for ext in (".npy", ".png", ".tif", ".tiff"):
+ p = mask_dir / f"{base}{ext}"
+ if p.exists():
+ mask_path = p
+ break
+
+ masks[head_name] = mask_path
+ if mask_path is not None:
+ has_any_gt = True
+
+ # Se só existir masks/.png ou .npy semantic, conecta na head semantic.
+ if masks.get("semantic") is None:
+ semantic_mask = resolve_raw_sibling_file(
+ raw_root,
+ "masks",
+ base,
+ (".npy", ".png", ".tif", ".tiff"),
+ )
+ if semantic_mask is not None:
+ masks["semantic"] = semantic_mask
+ has_any_gt = True
+
+ if require_masks and not any(p is not None for p in masks.values()):
+ raise RuntimeError(f"Mask ausente para captura bruta: {mp}")
+
+ preview_path = resolve_raw_sibling_file(
+ raw_root,
+ "previews",
+ base,
+ (".png", ".jpg", ".jpeg"),
+ )
+
+ samples.append(SampleItem(
+ group=raw_root.name,
+ base=base,
+ tensor_path=None,
+ masks=masks,
+ meta_path=mp,
+ preview_path=preview_path,
+ source_kind="raw_native_multi",
+ raw_group=raw_group,
+ ))
+
+ if not samples:
+ raise RuntimeError(f"Nenhuma captura raw_native_multi válida encontrada em: {raw_root}")
+
+ return samples, has_any_gt
+
+
+def load_tensor(
+ path: Path,
+ requested_channels: Sequence[str],
+ saved_channel_names: Optional[Sequence[str]] = None,
+) -> np.ndarray:
+ arr = np.load(str(path)).astype(np.float32)
+
+ if arr.ndim != 3:
+ raise RuntimeError(f"Tensor inválido {path}: shape={arr.shape}, esperado 3D")
+
+ # Normaliza para CHW. Os tensores atuais podem ter Raw5 + NDVI + NDRE.
+ if 1 <= arr.shape[0] <= len(SUPPORTED_INPUT_CHANNELS) and arr.shape[1] > 8 and arr.shape[2] > 8:
+ chw = arr
+ elif 1 <= arr.shape[-1] <= len(SUPPORTED_INPUT_CHANNELS) and arr.shape[0] > 8 and arr.shape[1] > 8:
+ chw = np.transpose(arr, (2, 0, 1))
+ else:
+ raise RuntimeError(f"Tensor com layout inesperado: {path} shape={arr.shape}")
+
+ requested = [str(x).strip().upper() for x in requested_channels]
+ saved = [str(x).strip().upper() for x in (saved_channel_names or [])]
+
+ if saved:
+ if len(saved) != chw.shape[0]:
+ raise RuntimeError(
+ f"Meta/tensor incompatíveis em {path}: meta channels={saved}, shape={chw.shape}"
+ )
+ if len(set(saved)) != len(saved):
+ raise RuntimeError(f"Meta possui canais duplicados em {path}: {saved}")
+ missing = [name for name in requested if name not in saved]
+ if missing:
+ raise RuntimeError(
+ f"Tensor {path} não contém canais pedidos {missing}. Disponíveis={saved}"
+ )
+ indices = [saved.index(name) for name in requested]
+ elif chw.shape[0] == len(requested):
+ # Compatibilidade com tensores antigos sem metadata de canais.
+ indices = list(range(len(requested)))
+ print(f"[WARN] {path.name}: sem channels no meta; assumindo ordem do config {requested}")
+ elif chw.shape[0] == len(SOURCE_CHANNEL_ORDER) and all(name in SOURCE_CHANNEL_ORDER for name in requested):
+ indices = [SOURCE_CHANNEL_ORDER.index(name) for name in requested]
+ print(f"[WARN] {path.name}: tensor Raw5 legado; usando ordem física {SOURCE_CHANNEL_ORDER}")
+ else:
+ raise RuntimeError(
+ f"Não é seguro inferir a ordem dos canais de {path}: shape={chw.shape}, "
+ f"pedidos={requested}. Garanta metas/.json com o campo channels."
+ )
+
+ chw = chw[indices, :, :]
+
+ finite = np.isfinite(chw)
+ if finite.any():
+ mx = float(np.nanmax(chw[finite]))
+ if mx > 2.0 and mx <= 255.0:
+ chw = chw / 255.0
+ elif mx > 255.0:
+ chw = chw / 65535.0
+
+ # Não aplicar clip 0..1: NDVI/NDRE possuem faixa nominal -1..1.
+ chw = np.nan_to_num(chw, nan=0.0, posinf=1.0, neginf=-1.0)
+ return np.ascontiguousarray(chw, dtype=np.float32)
+
+
+def load_sample_tensor(
+ sample: SampleItem,
+ requested_channels: Sequence[str],
+ derived_config: Optional[dict] = None,
+ target_size: Optional[Tuple[int, int]] = None,
+ module_params_path: Optional[str] = None,
+) -> np.ndarray:
+ """
+ Carrega tensor de uma amostra.
+ - Se for tensor pronto: lê .npy.
+ - Se for RAW_BRUTO: gera MULTISPEC em tempo real a partir dos bins.
+ """
+ if sample.source_kind == "tensor":
+ if sample.tensor_path is None:
+ raise RuntimeError(f"Sample tensor sem tensor_path: {sample.base}")
+ meta = load_json(sample.meta_path) if sample.meta_path is not None else {}
+ saved_names = meta.get("channels") or meta.get("input_channels") or []
+ return load_tensor(sample.tensor_path, requested_channels, saved_names)
+
+ if sample.source_kind == "raw_native_multi":
+ if sample.raw_group is None or sample.meta_path is None:
+ raise RuntimeError(f"Sample raw sem raw_group/meta_path: {sample.base}")
+
+ meta = load_json(sample.meta_path)
+ tensor, _processing_info = build_multispec_from_raw_native_multi_for_infer(
+ sample.raw_group,
+ meta,
+ target_size=target_size,
+ module_params_path=module_params_path,
+ )
+
+ if tensor.ndim != 3:
+ raise RuntimeError(f"Tensor RAW gerado inválido: {sample.base} shape={tensor.shape}")
+
+ return build_model_input_tensor(tensor, requested_channels, derived_config)
+
+ raise RuntimeError(f"source_kind desconhecido: {sample.source_kind}")
+
+
+def load_mask(path: Optional[Path]) -> Optional[np.ndarray]:
+ if path is None:
+ return None
+ if path.suffix.lower() == ".npy":
+ mask = np.load(str(path))
+ else:
+ mask = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
+ if mask is None:
+ raise RuntimeError(f"Falha ao ler mask: {path}")
+ if mask.ndim == 3:
+ mask = mask[:, :, 0]
+ return mask.astype(np.int64)
+
+
+# ============================================================
+# Normalização
+# ============================================================
+
+def load_norm_stats(
+ path: Optional[Path],
+ channel_names: Sequence[str],
+) -> Tuple[Optional[List[float]], Optional[List[float]]]:
+ if path is None or not path.is_file():
+ if path is not None:
+ print(f"[NORM] não encontrei norm_stats em {path}. Usando tensor 0..1 sem padronização.")
+ return None, None
+
+ js = load_json(path)
+ mean = js.get("mean", None)
+ std = js.get("std", None)
+ names = [str(x).strip().upper() for x in js.get("channels", [])]
+ requested = [str(x).strip().upper() for x in channel_names]
+
+ if mean is None or std is None:
+ raise RuntimeError(f"norm_stats inválido, faltando mean/std: {path}")
+ if len(mean) != len(std):
+ raise RuntimeError(f"norm_stats inválido: mean={len(mean)} std={len(std)} em {path}")
+
+ if names:
+ if len(names) != len(mean):
+ raise RuntimeError(
+ f"norm_stats incompatível: channels={len(names)} mean={len(mean)} em {path}"
+ )
+ if len(set(names)) != len(names):
+ raise RuntimeError(f"norm_stats possui canais duplicados: {names}")
+ missing = [name for name in requested if name not in names]
+ if missing:
+ raise RuntimeError(
+ f"norm_stats não contém canais pedidos {missing}. Disponíveis={names}"
+ )
+ indices = [names.index(name) for name in requested]
+ elif len(mean) == len(requested):
+ indices = list(range(len(requested)))
+ print(f"[NORM][WARN] stats sem nomes; assumindo ordem do config {requested}")
+ elif len(mean) == len(SOURCE_CHANNEL_ORDER) and all(name in SOURCE_CHANNEL_ORDER for name in requested):
+ indices = [SOURCE_CHANNEL_ORDER.index(name) for name in requested]
+ print(f"[NORM][WARN] stats Raw5 legados; usando ordem {SOURCE_CHANNEL_ORDER}")
+ else:
+ raise RuntimeError(
+ f"Não é seguro mapear norm_stats sem nomes: mean={len(mean)}, pedidos={requested}"
+ )
+
+ mean = [float(mean[i]) for i in indices]
+ std = [float(std[i]) for i in indices]
+ if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(std)) or any(x <= 0.0 for x in std):
+ raise RuntimeError(f"norm_stats contém valores inválidos: mean={mean} std={std}")
+
+ print(f"[NORM] usando {path}")
+ print(f"[NORM] channels={requested}")
+ print(f"[NORM] mean={mean}")
+ print(f"[NORM] std ={std}")
+ return mean, std
+
+
+# ============================================================
+# Modelo multi-head, igual ao treino
+# ============================================================
+
+def patch_segformer_encoder_input_channels(
+ segformer_encoder: nn.Module,
+ input_channel_names: Sequence[str],
+ init_mode: str = "zero_extra",
+):
+ """Replica a inicialização espectral do Agri Teacher V2/V2.1."""
+ names = [str(c).strip().upper() for c in input_channel_names]
+ if names == ["R", "G", "B"]:
+ return segformer_encoder
+
+ # Compatibilidade entre versões do transformers.
+ if hasattr(segformer_encoder, "encoder") and hasattr(segformer_encoder.encoder, "patch_embeddings"):
+ patch_owner = segformer_encoder.encoder.patch_embeddings[0]
+ elif hasattr(segformer_encoder, "stages") and len(segformer_encoder.stages) > 0:
+ patch_owner = segformer_encoder.stages[0].patch_embeddings
+ else:
+ raise RuntimeError("Não encontrei o primeiro patch embedding do SegFormer nesta versão do Transformers.")
+
+ proj = patch_owner.proj
+ old_weight = proj.weight.data.clone()
+ old_bias = proj.bias.data.clone() if proj.bias is not None else None
+
+ new_proj = nn.Conv2d(
+ in_channels=len(names),
+ out_channels=proj.out_channels,
+ kernel_size=proj.kernel_size,
+ stride=proj.stride,
+ padding=proj.padding,
+ dilation=proj.dilation,
+ groups=proj.groups,
+ bias=proj.bias is not None,
+ padding_mode=proj.padding_mode,
+ )
+
+ mode = str(init_mode or "zero_extra").lower()
+ with torch.no_grad():
+ if old_weight.shape[1] < 3:
+ raise RuntimeError(f"Patch embedding pré-treinado inesperado: weight={tuple(old_weight.shape)}")
+
+ pretrained = {"R": 0, "G": 1, "B": 2}
+ mean_rgb = old_weight[:, :3].mean(dim=1)
+ has_rgb = any(n in pretrained for n in names)
+
+ for dst, name in enumerate(names):
+ if name in pretrained:
+ new_proj.weight[:, dst].copy_(old_weight[:, pretrained[name]])
+ elif mode == "mean_rgb":
+ new_proj.weight[:, dst].copy_(mean_rgb)
+ elif mode == "scaled_mean":
+ new_proj.weight[:, dst].copy_(mean_rgb * (3.0 / max(3.0, float(len(names)))))
+ elif mode == "zero_extra" and has_rgb:
+ new_proj.weight[:, dst].zero_()
+ else:
+ new_proj.weight[:, dst].copy_(mean_rgb)
+
+ if old_bias is not None:
+ new_proj.bias.copy_(old_bias)
+
+ patch_owner.proj = new_proj
+ print(f"[MODEL] input patch RGB(3) -> {names} | init={mode}")
+ return segformer_encoder
+
+
+def replace_segformer_decode_classifier(decode_head: nn.Module, num_classes: int):
+ old = getattr(decode_head, "classifier", None)
+ if not isinstance(old, nn.Conv2d):
+ raise RuntimeError(f"decode_head.classifier inesperado: {type(old)}")
+
+ new = nn.Conv2d(
+ old.in_channels,
+ int(num_classes),
+ kernel_size=old.kernel_size,
+ stride=old.stride,
+ padding=old.padding,
+ dilation=old.dilation,
+ groups=old.groups,
+ bias=old.bias is not None,
+ padding_mode=old.padding_mode,
+ )
+ nn.init.xavier_uniform_(new.weight)
+ if new.bias is not None:
+ nn.init.zeros_(new.bias)
+ decode_head.classifier = new
+ return decode_head
+
+
+class MultiHeadSegFormer(nn.Module):
+ """Arquitetura compatível com o trainer antigo e com Agri Teacher V2/V2.1."""
+ def __init__(
+ self,
+ backbone: str,
+ input_channel_names: Sequence[str],
+ heads_config: Dict[str, dict],
+ semantic_id2label: Dict[int, str],
+ semantic_label2id: Dict[str, int],
+ model_cfg: Optional[dict] = None,
+ ):
+ super().__init__()
+ model_cfg = model_cfg or {}
+
+ semantic_classes = int(heads_config["semantic"].get("num_classes", len(semantic_id2label)))
+ names = [str(c).strip().upper() for c in input_channel_names]
+
+ base = SegformerForSemanticSegmentation.from_pretrained(
+ backbone,
+ num_labels=semantic_classes,
+ id2label={int(k): str(v) for k, v in semantic_id2label.items()},
+ label2id={str(k): int(v) for k, v in semantic_label2id.items()},
+ ignore_mismatched_sizes=True,
+ )
+
+ self.decoder_mode = str(model_cfg.get("decoder_mode", "separate")).lower()
+ self.spectral_input_init = str(model_cfg.get("spectral_input_init", "mean_rgb")).lower()
+
+ patch_segformer_encoder_input_channels(
+ base.segformer,
+ names,
+ init_mode=self.spectral_input_init,
+ )
+
+ base.config.num_channels = len(names)
+ base.config.input_channel_names = list(names)
+ base.config.agri_decoder_mode = self.decoder_mode
+ base.config.agri_spectral_input_init = self.spectral_input_init
+
+ self.segformer = base.segformer
+ self.heads_config = heads_config
+
+ if self.decoder_mode == "separate":
+ self.decode_heads = nn.ModuleDict()
+ for head_name, hcfg in heads_config.items():
+ h = copy.deepcopy(base.decode_head)
+ self.decode_heads[head_name] = replace_segformer_decode_classifier(
+ h, int(hcfg["num_classes"])
+ )
+ self.shared_decode_head = None
+ self.classifiers = None
+
+ elif self.decoder_mode == "shared_light":
+ self.decode_heads = None
+ self.shared_decode_head = copy.deepcopy(base.decode_head)
+ old_classifier = self.shared_decode_head.classifier
+ if not isinstance(old_classifier, nn.Conv2d):
+ raise RuntimeError("SegFormer decode_head.classifier não é Conv2d.")
+
+ feature_channels = int(old_classifier.in_channels)
+ self.shared_decode_head.classifier = nn.Identity()
+ self.classifiers = nn.ModuleDict()
+
+ for head_name, hcfg in heads_config.items():
+ clf = nn.Conv2d(
+ feature_channels,
+ int(hcfg["num_classes"]),
+ kernel_size=1,
+ bias=True,
+ )
+ nn.init.xavier_uniform_(clf.weight)
+ nn.init.zeros_(clf.bias)
+ self.classifiers[head_name] = clf
+ else:
+ raise RuntimeError(
+ f"training_v2.model.decoder_mode inválido: {self.decoder_mode}"
+ )
+
+ self.config = base.config
+ print(f"[MODEL] decoder_mode={self.decoder_mode}")
+
+ def forward(
+ self,
+ pixel_values: torch.Tensor,
+ head_names: Optional[Sequence[str]] = None,
+ ) -> Dict[str, torch.Tensor]:
+ outputs = self.segformer(
+ pixel_values=pixel_values,
+ output_hidden_states=True,
+ return_dict=True,
+ )
+ hidden_states = outputs.hidden_states
+
+ if self.decoder_mode == "separate":
+ available = list(self.decode_heads.keys())
+ selected = available if head_names is None else [h for h in head_names if h in self.decode_heads]
+ return {name: self.decode_heads[name](hidden_states) for name in selected}
+
+ # shared_light: calcula o decoder compartilhado uma vez e só executa os classifiers pedidos.
+ features = self.shared_decode_head(hidden_states)
+ available = list(self.classifiers.keys())
+ selected = available if head_names is None else [h for h in head_names if h in self.classifiers]
+ return {name: self.classifiers[name](features) for name in selected}
+
+
+class MultiHeadTester:
+ def __init__(
+ self,
+ config: dict,
+ ckpt_path: Path,
+ device: torch.device,
+ input_channel_names: Sequence[str],
+ heads_config: Dict[str, dict],
+ semantic_id2label: Dict[int, str],
+ semantic_label2id: Dict[str, int],
+ mean: Optional[Sequence[float]],
+ std: Optional[Sequence[float]],
+ use_amp: bool = True,
+ ):
+ self.config = config
+ self.ckpt_path = ckpt_path
+ self.device = device
+ self.input_channel_names = [str(c).strip().upper() for c in input_channel_names]
+ self.channels = len(self.input_channel_names)
+ channels = self.channels
+ self.heads_config = heads_config
+ self.use_amp = use_amp and device.type == "cuda"
+ self.runtime_mode = str(config.get("runtime_mode", "all")).lower()
+ self.target_threshold = 0.5
+ self.ckpt_operational_threshold = None
+
+ self.mean = None if mean is None else torch.tensor(mean, dtype=torch.float32).view(1, channels, 1, 1).to(device)
+ self.std = None if std is None else torch.tensor(std, dtype=torch.float32).view(1, channels, 1, 1).to(device)
+
+ backbone = config.get("backbone", config.get("pretrained_model", "nvidia/mit-b1"))
+ print(f"[MODEL] backbone={backbone}")
+ print(f"[MODEL] ckpt={ckpt_path}")
+
+ training_v2 = config.get("training_v2", {}) or {}
+ model_cfg = training_v2.get("model", {}) or {}
+ print(f"[MODEL] config decoder_mode={model_cfg.get('decoder_mode', 'separate')} | spectral_init={model_cfg.get('spectral_input_init', 'mean_rgb')}")
+
+ self.model = MultiHeadSegFormer(
+ backbone=backbone,
+ input_channel_names=self.input_channel_names,
+ heads_config=heads_config,
+ semantic_id2label=semantic_id2label,
+ semantic_label2id=semantic_label2id,
+ model_cfg=model_cfg,
+ )
+
+ self._load_checkpoint(ckpt_path)
+ self.model.to(device)
+ self.model.eval()
+
+ def _load_checkpoint(self, ckpt_path: Path):
+ ckpt = torch.load(str(ckpt_path), map_location="cpu", weights_only=False)
+
+ saved_contract = ckpt.get("model_contract", {}) if isinstance(ckpt, dict) else {}
+ saved_contract = saved_contract or {}
+ saved_channels = int(saved_contract.get("num_channels", 0) or 0)
+ saved_names = [str(x).strip().upper() for x in saved_contract.get("input_channel_names", [])]
+ legacy_default_names = SOURCE_CHANNEL_ORDER[:self.channels]
+ if not saved_names and self.input_channel_names != legacy_default_names:
+ raise RuntimeError(
+ "Checkpoint antigo sem model_contract.input_channel_names não pode ser "
+ f"validado para a ordem atual {self.input_channel_names}. Reexporte/treine com "
+ "o train_multihead atualizado ou use um checkpoint nominal compatível."
+ )
+ if not saved_names:
+ print(
+ f"[MODEL][WARN] checkpoint legado sem nomes; assumindo {legacy_default_names}"
+ )
+ if saved_channels and saved_channels != self.channels:
+ raise RuntimeError(
+ f"Checkpoint incompatível: salvou {saved_channels} canais, "
+ f"config atual pede {self.channels}."
+ )
+ if saved_names and saved_names != self.input_channel_names:
+ raise RuntimeError(
+ f"Checkpoint incompatível: canais salvos={saved_names}, "
+ f"config atual={self.input_channel_names}."
+ )
+
+ saved_decoder = str(saved_contract.get("decoder_mode", "") or "").lower()
+ current_decoder = str(getattr(self.model.config, "agri_decoder_mode", "") or "").lower()
+ if saved_decoder and current_decoder and saved_decoder != current_decoder:
+ raise RuntimeError(
+ f"Checkpoint decoder_mode={saved_decoder}, mas config/teste criou decoder_mode={current_decoder}. "
+ "Use o mesmo training_v2.model.decoder_mode do treinamento."
+ )
+
+ saved_spectral = str(saved_contract.get("spectral_input_init", "") or "").lower()
+ current_spectral = str(getattr(self.model.config, "agri_spectral_input_init", "") or "").lower()
+ if saved_spectral and current_spectral and saved_spectral != current_spectral:
+ raise RuntimeError(
+ f"Checkpoint spectral_input_init={saved_spectral}, mas config/teste usa {current_spectral}."
+ )
+
+ extra = ckpt.get("extra", {}) if isinstance(ckpt, dict) else {}
+ score_now = extra.get("score_now", {}) if isinstance(extra, dict) else {}
+ try:
+ op_thr = score_now.get("operational_threshold")
+ if op_thr is not None:
+ self.ckpt_operational_threshold = float(op_thr)
+ except Exception:
+ self.ckpt_operational_threshold = None
+
+ if isinstance(ckpt, dict):
+ for key in ("model", "model_state", "model_state_dict", "state_dict"):
+ if key in ckpt and isinstance(ckpt[key], dict):
+ state = ckpt[key]
+ break
+ else:
+ state = ckpt
+ else:
+ raise RuntimeError(f"Checkpoint em formato inesperado: {type(ckpt)}")
+
+ clean = {}
+ for k, v in state.items():
+ nk = k
+ for prefix in ("module.", "model."):
+ if nk.startswith(prefix):
+ nk = nk[len(prefix):]
+ clean[nk] = v
+
+ try:
+ self.model.load_state_dict(clean, strict=True)
+ except RuntimeError as exc:
+ raise RuntimeError(
+ "Checkpoint incompatível com a arquitetura/canais atuais. "
+ "Use o mesmo input_channels e heads empregados no treinamento.\n"
+ f"Detalhe original: {exc}"
+ ) from exc
+ print("[MODEL] checkpoint carregado com strict=True")
+
+ def _normalize(self, x: torch.Tensor) -> torch.Tensor:
+ if self.mean is not None and self.std is not None:
+ return (x - self.mean) / torch.clamp(self.std, min=1e-6)
+ return x
+
+ @torch.inference_mode()
+ def infer(self, chw_01: np.ndarray) -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray], float]:
+ x = torch.from_numpy(chw_01).unsqueeze(0).to(self.device, non_blocking=True)
+ x = self._normalize(x)
+
+ h, w = int(chw_01.shape[1]), int(chw_01.shape[2])
+
+ if self.device.type == "cuda":
+ torch.cuda.synchronize()
+ t0 = time.perf_counter()
+
+ with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=self.use_amp):
+ head_names = None
+ if self.runtime_mode in ("target_direct", "target_head"):
+ head_names = ["target"]
+ elif self.runtime_mode in ("operational", "target_op"):
+ head_names = ["vegetation", "cana"]
+
+ logits_by_head = self.model(pixel_values=x, head_names=head_names)
+
+ preds = {}
+ probs = {}
+
+ for head_name, logits in logits_by_head.items():
+ logits = F.interpolate(logits, size=(h, w), mode="bilinear", align_corners=False)
+ prob = torch.softmax(logits, dim=1)[0]
+ if head_name == "target" and prob.shape[0] > 1:
+ pred = (prob[1] >= float(self.target_threshold)).to(torch.uint8)
+ else:
+ pred = torch.argmax(prob, dim=0).to(torch.uint8)
+ preds[head_name] = pred.detach().cpu().numpy().astype(np.uint8)
+ probs[head_name] = prob.detach().cpu().numpy().astype(np.float32)
+
+ if self.device.type == "cuda":
+ torch.cuda.synchronize()
+ t_ms = (time.perf_counter() - t0) * 1000.0
+
+ return preds, probs, t_ms
+
+
+class OnnxMultiHeadTester:
+ def __init__(
+ self,
+ config: dict,
+ onnx_path: Path,
+ provider: str,
+ input_channel_names: Sequence[str],
+ heads_config: Dict[str, dict],
+ mean: Optional[Sequence[float]],
+ std: Optional[Sequence[float]],
+ trt_home: Optional[str] = None,
+ trt_fp16: bool = True,
+ ):
+ self.config = config
+ self.onnx_path = onnx_path
+ self.provider = provider.lower()
+ self.input_channel_names = [str(x).strip().upper() for x in input_channel_names]
+ self.channels = len(self.input_channel_names)
+ channels = self.channels
+ self.heads_config = heads_config
+ self.runtime_mode = str(config.get("runtime_mode", "all")).lower()
+ self.target_threshold = 0.5
+
+ export_meta_path = onnx_path.with_suffix(".export_meta.json")
+ export_meta = load_json(export_meta_path) if export_meta_path.is_file() else {}
+ exported_names = [
+ str(x).strip().upper()
+ for x in export_meta.get("input_channel_names", [])
+ ]
+ if exported_names and exported_names != self.input_channel_names:
+ raise RuntimeError(
+ f"ONNX incompatível: canais exportados={exported_names}, "
+ f"config atual={self.input_channel_names}."
+ )
+
+ postprocess = str(export_meta.get("postprocess", "none")).lower()
+ if postprocess not in ("none", "resize_logits"):
+ raise RuntimeError(
+ f"Este comparador espera logits, mas o ONNX usa postprocess={postprocess}. "
+ "Exporte com --postprocess none ou resize_logits."
+ )
+
+ input_contract = export_meta.get("input_contract", {}) or {}
+ self.normalization_embedded = bool(
+ input_contract.get(
+ "normalization_embedded",
+ export_meta.get("include_norm", False),
+ )
+ )
+ if self.normalization_embedded:
+ self.mean = None
+ self.std = None
+ print("[ONNX_MODEL] normalização já embutida no grafo")
+ else:
+ self.mean = None if mean is None else np.asarray(mean, dtype=np.float32).reshape(1, channels, 1, 1)
+ self.std = None if std is None else np.asarray(std, dtype=np.float32).reshape(1, channels, 1, 1)
+
+ print(f"[ONNX_MODEL] onnx={onnx_path}")
+ print(f"[ONNX_MODEL] provider={provider}")
+
+ self.session = self._create_session(
+ onnx_path=onnx_path,
+ provider=provider,
+ trt_home=trt_home,
+ trt_fp16=trt_fp16,
+ )
+
+ self.input_name = self.session.get_inputs()[0].name
+ input_shape = self.session.get_inputs()[0].shape
+ if len(input_shape) != 4:
+ raise RuntimeError(f"Entrada ONNX inesperada: nome={self.input_name} shape={input_shape}")
+ onnx_channels = input_shape[1]
+ if isinstance(onnx_channels, int) and onnx_channels != self.channels:
+ raise RuntimeError(
+ f"ONNX incompatível: entrada possui {onnx_channels} canais, "
+ f"config atual pede {self.channels}."
+ )
+ self.output_names = [o.name for o in self.session.get_outputs()]
+ print(f"[ONNX_MODEL] input={self.input_name} shape={input_shape}")
+ print(f"[ONNX_MODEL] outputs={self.output_names}")
+
+ def _create_session(
+ self,
+ onnx_path: Path,
+ provider: str,
+ trt_home: Optional[str],
+ trt_fp16: bool,
+ ):
+ try:
+ import onnxruntime as ort
+ except ImportError:
+ raise ImportError(
+ "onnxruntime não está instalado. Use:\n"
+ " pip install onnxruntime-gpu"
+ )
+
+ provider = provider.lower()
+
+ if provider == "tensorrt":
+ trt_home = trt_home or os.environ.get("TRT_HOME", r"C:\dev\TensorRT-10.10.0.31")
+
+ dll_dirs = [
+ os.path.join(trt_home, "lib"),
+ os.path.join(trt_home, "bin"),
+ ]
+
+ cuda_home = os.environ.get("CUDA_PATH")
+ if cuda_home:
+ dll_dirs.append(os.path.join(cuda_home, "bin"))
+
+ # fallback comum que vocês estão usando
+ dll_dirs.append(r"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.3\bin")
+ dll_dirs.append(r"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.4\bin")
+
+ for dll_dir in dll_dirs:
+ if os.path.isdir(dll_dir):
+ try:
+ os.add_dll_directory(dll_dir)
+ print(f"[DLL] add_dll_directory: {dll_dir}")
+ except Exception as e:
+ print(f"[DLL][WARN] falha em {dll_dir}: {e}")
+
+ available = ort.get_available_providers()
+ print(f"[ONNX] providers disponíveis: {available}")
+
+ sess_options = ort.SessionOptions()
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
+
+ if provider == "cpu":
+ providers = ["CPUExecutionProvider"]
+
+ elif provider == "cuda":
+ providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
+
+ elif provider == "tensorrt":
+ cache_dir = onnx_path.parent / "trt_cache"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+
+ trt_options = {
+ "device_id": 0,
+ "trt_fp16_enable": bool(trt_fp16),
+ "trt_engine_cache_enable": True,
+ "trt_engine_cache_path": str(cache_dir),
+ "trt_timing_cache_enable": True,
+ "trt_timing_cache_path": str(cache_dir),
+ "trt_max_workspace_size": 4 * 1024 * 1024 * 1024,
+ }
+
+ providers = [
+ ("TensorrtExecutionProvider", trt_options),
+ "CUDAExecutionProvider",
+ "CPUExecutionProvider",
+ ]
+
+ else:
+ raise RuntimeError(f"Provider ONNX inválido: {provider}")
+
+ providers_ok = [
+ p for p in providers
+ if (p[0] if isinstance(p, tuple) else p) in available
+ ]
+
+ if not providers_ok:
+ raise RuntimeError(f"Nenhum provider ONNX disponível. Pedido={providers}, disponíveis={available}")
+
+ session = ort.InferenceSession(
+ str(onnx_path),
+ sess_options=sess_options,
+ providers=providers_ok,
+ )
+
+ active = session.get_providers()
+ print(f"[ONNX] usando providers: {active}")
+
+ if provider == "tensorrt" and "TensorrtExecutionProvider" not in active:
+ raise RuntimeError(f"TensorRT solicitado, mas não ficou ativo. Providers ativos: {active}")
+
+ if provider == "cuda" and "CUDAExecutionProvider" not in active:
+ raise RuntimeError(f"CUDA solicitado, mas não ficou ativo. Providers ativos: {active}")
+
+ return session
+
+ def _normalize(self, x: np.ndarray) -> np.ndarray:
+ if self.mean is not None and self.std is not None:
+ return ((x - self.mean[0]) / np.clip(self.std[0], 1e-6, None)).astype(np.float32)
+ return x.astype(np.float32)
+
+ def _selected_heads(self) -> Optional[List[str]]:
+ if self.runtime_mode in ("target_direct", "target_head"):
+ return ["target"]
+ if self.runtime_mode in ("operational", "target_op"):
+ return ["vegetation", "cana"]
+ return None
+
+ @staticmethod
+ def _softmax_np(logits: np.ndarray, axis: int = 1) -> np.ndarray:
+ x = logits.astype(np.float32)
+ x = x - np.max(x, axis=axis, keepdims=True)
+ e = np.exp(x)
+ return e / np.clip(np.sum(e, axis=axis, keepdims=True), 1e-12, None)
+
+ @staticmethod
+ def _resize_logits_nchw(logits: np.ndarray, target_hw: Tuple[int, int]) -> np.ndarray:
+ n, c, h, w = logits.shape
+ th, tw = target_hw
+
+ if (h, w) == (th, tw):
+ return logits
+
+ out = np.empty((n, c, th, tw), dtype=np.float32)
+ for bi in range(n):
+ for ci in range(c):
+ out[bi, ci] = cv2.resize(
+ logits[bi, ci].astype(np.float32),
+ (tw, th),
+ interpolation=cv2.INTER_LINEAR,
+ )
+ return out
+
+ def _map_outputs(self, outputs: List[np.ndarray]) -> Dict[str, np.ndarray]:
+ raw = {
+ name: arr.astype(np.float32)
+ for name, arr in zip(self.output_names, outputs)
+ }
+
+ mapped = {}
+ for head in self.heads_config.keys():
+ candidates = [
+ head,
+ f"{head}_logits",
+ f"output_{head}",
+ ]
+
+ found = None
+ for c in candidates:
+ if c in raw:
+ found = c
+ break
+
+ if found is not None:
+ mapped[head] = raw[found]
+
+ return mapped
+
+ def infer(self, chw_01: np.ndarray) -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray], float]:
+ h, w = int(chw_01.shape[1]), int(chw_01.shape[2])
+
+ x = self._normalize(chw_01)
+ x = np.expand_dims(x, axis=0).astype(np.float32)
+
+ t0 = time.perf_counter()
+ outputs = self.session.run(None, {self.input_name: x})
+ t_ms = (time.perf_counter() - t0) * 1000.0
+
+ logits_by_head = self._map_outputs(outputs)
+
+ selected = self._selected_heads()
+ if selected is not None:
+ logits_by_head = {
+ hname: logits
+ for hname, logits in logits_by_head.items()
+ if hname in selected
+ }
+
+ preds = {}
+ probs = {}
+
+ for head_name, logits in logits_by_head.items():
+ logits = self._resize_logits_nchw(logits, (h, w))
+ prob = self._softmax_np(logits, axis=1)[0]
+ if head_name == "target" and prob.shape[0] > 1:
+ pred = (prob[1] >= float(self.target_threshold)).astype(np.uint8)
+ else:
+ pred = np.argmax(prob, axis=0).astype(np.uint8)
+
+ preds[head_name] = pred
+ probs[head_name] = prob.astype(np.float32)
+
+ return preds, probs, t_ms
+
+
+# ============================================================
+# Checkpoints / paths
+# ============================================================
+
+def infer_experiment_tag(config: dict, channels: int) -> str:
+ explicit = str(config.get("stats_source_tag", "")).strip()
+ if explicit:
+ return explicit
+ fusion_mode = str(config.get("fusion_mode", "stacked")).strip()
+ return f"{fusion_mode}_raw{channels}"
+
+
+def infer_save_dir(config: dict, config_dir: Path, channels: int) -> Path:
+ model_name = config.get("model_name", "test_multi")
+ modelo_folder = config.get("modelo", "segformer_b1")
+ exp_tag = infer_experiment_tag(config, channels)
+ return (config_dir / "backup" / modelo_folder / model_name / exp_tag).resolve()
+
+
+def find_checkpoint(save_dir: Path, preferred: Optional[str] = None) -> Path:
+ if preferred is not None:
+ ckpt = Path(preferred)
+ if not ckpt.is_absolute():
+ ckpt_cwd = (Path.cwd() / ckpt).resolve()
+ ckpt_save = (save_dir / ckpt).resolve()
+ ckpt = ckpt_cwd if ckpt_cwd.is_file() else ckpt_save
+ if not ckpt.is_file():
+ raise FileNotFoundError(f"Checkpoint não encontrado: {ckpt}")
+ return ckpt
+
+ candidates = [
+ save_dir / "best_score.pt",
+ save_dir / "best_operational.pt",
+ save_dir / "best_legacy_score.pt",
+ save_dir / "best_target.pt",
+ save_dir / "best_cana_head.pt",
+ save_dir / "best_semantic_miou.pt",
+ save_dir / "last.pt",
+ ]
+ for c in candidates:
+ if c.is_file():
+ return c
+
+ raise FileNotFoundError("Nenhum checkpoint encontrado. Procurei:\n" + "\n".join(str(c) for c in candidates))
+
+
+def find_onnx_model(save_dir: Path, ckpt_path: Path, preferred: Optional[str] = None) -> Path:
+ """
+ Resolve o .onnx.
+
+ Se preferred for informado, usa ele.
+ Caso contrário, usa o mesmo stem do checkpoint:
+ best_score.pt -> best_score.onnx
+ """
+ if preferred:
+ p = Path(preferred)
+ if not p.is_absolute():
+ p_cwd = (Path.cwd() / p).resolve()
+ p_save = (save_dir / p).resolve()
+ p = p_cwd if p_cwd.is_file() else p_save
+
+ if not p.is_file():
+ raise FileNotFoundError(f"ONNX não encontrado: {p}")
+
+ return p.resolve()
+
+ p = ckpt_path.with_suffix(".onnx")
+
+ if not p.is_file():
+ alt = save_dir / f"{ckpt_path.stem}.onnx"
+ p = alt
+
+ if not p.is_file():
+ raise FileNotFoundError(
+ f"ONNX não encontrado para checkpoint {ckpt_path.name}. Procurei:\n"
+ f" {ckpt_path.with_suffix('.onnx')}\n"
+ f" {save_dir / (ckpt_path.stem + '.onnx')}\n"
+ f"Informe manualmente com --onnx."
+ )
+
+ return p.resolve()
+
+
+# ============================================================
+# Visualização
+# ============================================================
+
+def tensor_to_preview_rgb(
+ chw: np.ndarray,
+ channel_names: Sequence[str],
+ gamma: float = 0.85,
+) -> np.ndarray:
+ c, h, w = chw.shape
+ names = [str(x).strip().upper() for x in channel_names]
+ if all(name in names for name in ("R", "G", "B")):
+ indices = [names.index(name) for name in ("R", "G", "B")]
+ rgb = np.transpose(chw[indices], (1, 2, 0)).copy()
+ elif c >= 3:
+ rgb = np.transpose(chw[:3], (1, 2, 0)).copy()
+ else:
+ one = chw[0]
+ rgb = np.stack([one, one, one], axis=-1)
+
+ rgb = np.nan_to_num(rgb, nan=0.0, posinf=1.0, neginf=0.0)
+ lo = np.percentile(rgb, 1.0)
+ hi = np.percentile(rgb, 99.0)
+ if hi > lo:
+ rgb = (rgb - lo) / (hi - lo)
+ rgb = np.clip(rgb, 0.0, 1.0)
+ if gamma and gamma > 0:
+ rgb = np.power(rgb, gamma)
+ return (rgb * 255.0).astype(np.uint8)
+
+
+def ids_to_rgb(mask: np.ndarray, colormap_rgb: Dict[int, Tuple[int, int, int]], ignore_id: int = 255) -> np.ndarray:
+ h, w = mask.shape[:2]
+ out = np.zeros((h, w, 3), dtype=np.uint8)
+ for cid, color in colormap_rgb.items():
+ out[mask == cid] = color
+ out[mask == ignore_id] = (0, 0, 0)
+ return out
+
+
+def prob_to_heat_rgb(prob01: np.ndarray) -> np.ndarray:
+ p = np.clip(prob01, 0.0, 1.0)
+ u8 = (p * 255.0).astype(np.uint8)
+ bgr = cv2.applyColorMap(u8, cv2.COLORMAP_TURBO)
+ return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
+
+
+def overlay_rgb(base_rgb: np.ndarray, mask_rgb: np.ndarray, alpha: float) -> np.ndarray:
+ return cv2.addWeighted(base_rgb, 1.0 - alpha, mask_rgb, alpha, 0.0)
+
+
+def put_label(img_rgb: np.ndarray, title: str, subtitle: str = "") -> np.ndarray:
+ out = img_rgb.copy()
+ cv2.putText(out, title, (10, 26), cv2.FONT_HERSHEY_SIMPLEX, 0.68, (0, 0, 0), 4, cv2.LINE_AA)
+ cv2.putText(out, title, (10, 26), cv2.FONT_HERSHEY_SIMPLEX, 0.68, (255, 255, 255), 2, cv2.LINE_AA)
+ if subtitle:
+ cv2.putText(out, subtitle, (10, 52), cv2.FONT_HERSHEY_SIMPLEX, 0.48, (0, 0, 0), 3, cv2.LINE_AA)
+ cv2.putText(out, subtitle, (10, 52), cv2.FONT_HERSHEY_SIMPLEX, 0.48, (0, 255, 90), 1, cv2.LINE_AA)
+ return out
+
+
+def resize_panel(img: np.ndarray, size: Tuple[int, int]) -> np.ndarray:
+ w, h = size
+ if img.shape[1] == w and img.shape[0] == h:
+ return img
+ return cv2.resize(img, (w, h), interpolation=cv2.INTER_NEAREST)
+
+
+def compose_grid(panels: List[Tuple[str, np.ndarray, str]], cols: int = 3, max_width: int = 1800) -> np.ndarray:
+ if not panels:
+ return np.zeros((480, 640, 3), dtype=np.uint8)
+
+ base_h, base_w = panels[0][1].shape[:2]
+ labeled = []
+ for title, img, subtitle in panels:
+ img = resize_panel(img, (base_w, base_h))
+ labeled.append(put_label(img, title, subtitle))
+
+ rows = []
+ blank = np.zeros_like(labeled[0])
+ for i in range(0, len(labeled), cols):
+ row_imgs = labeled[i:i + cols]
+ while len(row_imgs) < cols:
+ row_imgs.append(blank.copy())
+ rows.append(np.hstack(row_imgs))
+
+ canvas = np.vstack(rows)
+
+ if canvas.shape[1] > max_width:
+ scale = max_width / canvas.shape[1]
+ canvas = cv2.resize(canvas, (int(canvas.shape[1] * scale), int(canvas.shape[0] * scale)), interpolation=cv2.INTER_AREA)
+
+ return canvas
+
+
+def class_percent(mask: np.ndarray, class_id: int, ignore_id: int = 255) -> float:
+ valid = mask != ignore_id
+ den = int(valid.sum())
+ if den <= 0:
+ return 0.0
+ return float(((mask == class_id) & valid).sum() * 100.0 / den)
+
+
+def compare_pred_equal_percent(a: Optional[np.ndarray], b: Optional[np.ndarray]) -> Optional[float]:
+ if a is None or b is None:
+ return None
+
+ if a.shape != b.shape:
+ b = cv2.resize(
+ b.astype(np.uint8),
+ (a.shape[1], a.shape[0]),
+ interpolation=cv2.INTER_NEAREST,
+ )
+
+ return float(np.mean(a == b) * 100.0)
+
+
+def diff_mask_rgb(a: Optional[np.ndarray], b: Optional[np.ndarray]) -> Optional[np.ndarray]:
+ if a is None or b is None:
+ return None
+
+ if a.shape != b.shape:
+ b = cv2.resize(
+ b.astype(np.uint8),
+ (a.shape[1], a.shape[0]),
+ interpolation=cv2.INTER_NEAREST,
+ )
+
+ diff = (a != b).astype(np.uint8) * 255
+ rgb = np.zeros((diff.shape[0], diff.shape[1], 3), dtype=np.uint8)
+ rgb[:, :, 0] = diff # vermelho em RGB
+ return rgb
+
+
+# ============================================================
+# Métricas numpy
+# ============================================================
+
+def confusion_matrix_np(pred: np.ndarray, gt: np.ndarray, num_classes: int, ignore_id: int) -> np.ndarray:
+ if pred.shape != gt.shape:
+ pred = cv2.resize(pred.astype(np.uint8), (gt.shape[1], gt.shape[0]), interpolation=cv2.INTER_NEAREST)
+
+ valid = gt != ignore_id
+ valid &= gt >= 0
+ valid &= gt < num_classes
+ gt_v = gt[valid].astype(np.int64)
+ pred_v = pred[valid].astype(np.int64)
+ pred_v = np.clip(pred_v, 0, num_classes - 1)
+
+ cm = np.bincount(num_classes * gt_v + pred_v, minlength=num_classes * num_classes)
+ return cm.reshape(num_classes, num_classes).astype(np.int64)
+
+
+def metrics_from_cm(cm: np.ndarray) -> Tuple[np.ndarray, float, float]:
+ tp = np.diag(cm).astype(np.float64)
+ fp = cm.sum(axis=0).astype(np.float64) - tp
+ fn = cm.sum(axis=1).astype(np.float64) - tp
+ denom = tp + fp + fn
+ iou = np.divide(tp, denom, out=np.zeros_like(tp), where=denom > 0)
+ miou = float(np.mean(iou)) if len(iou) else 0.0
+ acc = float(tp.sum() / max(cm.sum(), 1))
+ return iou, miou, acc
+
+
+def operational_target_mask(veg_mask: np.ndarray, cana_mask: np.ndarray, ignore_id: int = 255) -> np.ndarray:
+ out = np.zeros_like(veg_mask, dtype=np.uint8)
+ ignore = (veg_mask == ignore_id) | (cana_mask == ignore_id)
+ out[(veg_mask == 1) & (cana_mask == 0)] = 1
+ out[ignore] = ignore_id
+ return out
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config", default="config.json")
+ parser.add_argument("--split_folder", default="val", choices=["train", "val", "test"])
+ parser.add_argument("--root_override", default=None)
+ parser.add_argument("--test_folder", default=None)
+ parser.add_argument("--ckpt", default=None)
+ parser.add_argument("--norm_stats", default=None)
+ parser.add_argument(
+ "--allow_missing_norm_stats",
+ action="store_true",
+ help="Permite inferência sem padronização (não recomendado).",
+ )
+ parser.add_argument("--channels", type=int, default=None)
+ parser.add_argument("--resize_w", type=int, default=None)
+ parser.add_argument("--resize_h", type=int, default=None)
+ parser.add_argument("--alpha", type=float, default=0.45)
+ parser.add_argument("--ignore_id", type=int, default=None)
+ parser.add_argument("--no_amp", action="store_true")
+ parser.add_argument("--require_masks", action="store_true")
+ parser.add_argument("--out_dir", default="outputs_test_multihead")
+ parser.add_argument("--start_idx", type=int, default=0)
+ parser.add_argument("--max_width", type=int, default=1800)
+ parser.add_argument("--runtime_mode", default="all", choices=["all", "target_direct", "operational"])
+ parser.add_argument(
+ "--target_threshold",
+ default="0.5",
+ help="Threshold da head target direta. Use número (ex. 0.7) ou 'auto' para usar o operational_threshold salvo no checkpoint.",
+ )
+ parser.add_argument("--onnx", default="")
+ parser.add_argument("--onnx_provider", default="", choices=["", "cpu", "cuda", "tensorrt"])
+ parser.add_argument("--trt_home", default=None)
+ parser.add_argument("--trt_no_fp16", action="store_true")
+ args = parser.parse_args()
+
+ config_path = resolve_path(args.config, Path.cwd())
+ if config_path is None or not config_path.is_file():
+ raise FileNotFoundError(f"Config não encontrado: {config_path}")
+
+ config_dir = config_path.parent
+ config = load_json(config_path)
+ config["runtime_mode"] = args.runtime_mode
+
+ input_channel_names = get_input_channel_names(config, args.channels)
+ input_channel_indices = get_input_channel_indices(config, args.channels)
+ channels = len(input_channel_names)
+
+ print(f"Input channels: {input_channel_names} idx={input_channel_indices}")
+ res = config.get("resolucao", [1024, 640])
+ default_w, default_h = int(res[0]), int(res[1])
+ target_w = int(args.resize_w or default_w)
+ target_h = int(args.resize_h or default_h)
+ module_params_config = config.get("module_params_json")
+ if module_params_config:
+ module_params_candidate = Path(str(module_params_config))
+ if not module_params_candidate.is_absolute():
+ module_params_candidate = config_dir / module_params_candidate
+ module_params_config = str(module_params_candidate.resolve())
+
+ dataset_path = config_dir / "dataset"
+ labelmap_path = dataset_path / "labelmap.txt"
+ semantic_id2label, semantic_label2id, labelmap_ignore_id, loaded_colormap_rgb = load_labelmap(labelmap_path)
+
+ ignore_id = int(args.ignore_id if args.ignore_id is not None else labelmap_ignore_id)
+ heads_config = build_heads_config(config, ignore_index=ignore_id)
+ heads_config["semantic"]["num_classes"] = int(len(semantic_id2label))
+ heads_config["semantic"]["ignore_index"] = int(ignore_id)
+
+ semantic_cmap = dict(SEMANTIC_COLORS_RGB)
+ semantic_cmap.update({int(k): tuple(map(int, v)) for k, v in loaded_colormap_rgb.items()})
+
+ save_dir = infer_save_dir(config, config_dir, channels)
+ ckpt_path = find_checkpoint(save_dir, args.ckpt)
+
+ if args.norm_stats is not None:
+ norm_stats_path = resolve_path(args.norm_stats, Path.cwd())
+ else:
+ # O treino multihead usa stats do dataset normalizado, mas também tentamos alguns fallbacks.
+ candidates = [
+ save_dir / "norm_stats.json",
+ dataset_path / f"{default_w}x{default_h}" / "group" / "norm_stats.json",
+ config_dir / "backup" / config.get("modelo", "segformer_b1") / config.get("model_name", "test_multi") / config.get("stats_source_tag", "stacked_raw5") / "norm_stats.json",
+ ]
+ norm_stats_path = next((p for p in candidates if p.is_file()), candidates[0])
+
+ mean, std = load_norm_stats(norm_stats_path, channel_names=input_channel_names)
+ if (mean is None or std is None) and not args.allow_missing_norm_stats:
+ raise FileNotFoundError(
+ f"norm_stats não encontrado em {norm_stats_path}. A inferência deve usar os "
+ "mesmos stats do treinamento. Informe --norm_stats ou, conscientemente, "
+ "use --allow_missing_norm_stats."
+ )
+
+ if args.test_folder is not None:
+ root = resolve_path(args.test_folder, Path.cwd())
+ elif args.root_override is not None:
+ root = resolve_path(args.root_override, Path.cwd())
+ else:
+ root = (dataset_path / "split" / args.split_folder).resolve()
+
+ if root is None or not root.is_dir():
+ raise FileNotFoundError(f"Root de dados não encontrado: {root}")
+
+ samples, has_gt = collect_samples(root, heads_config=heads_config, require_masks=args.require_masks)
+ n = len(samples)
+
+ print("==========================================")
+ print("Teste SegFormer OAK-FCC-3 Multi-Head")
+ print(f"Root : {root}")
+ print(f"Samples : {n}")
+ print(f"GT : {'sim' if has_gt else 'não'}")
+ print(f"Resolution : {target_w}x{target_h}")
+ print(f"Channels : {channels}")
+ print(f"Semantic : {semantic_id2label}")
+ print(f"Ignore index: {ignore_id}")
+ print("Heads:")
+ for name, hcfg in heads_config.items():
+ print(f" - {name}: classes={hcfg['num_classes']} mask_dir={hcfg['mask_dir']}")
+ print("==========================================")
+
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ print(f"Device: {device}")
+
+ tester = MultiHeadTester(
+ config=config,
+ ckpt_path=ckpt_path,
+ device=device,
+ input_channel_names=input_channel_names,
+ heads_config=heads_config,
+ semantic_id2label=semantic_id2label,
+ semantic_label2id=semantic_label2id,
+ mean=mean,
+ std=std,
+ use_amp=not args.no_amp,
+ )
+
+ target_threshold_arg = str(args.target_threshold).strip().lower()
+ if target_threshold_arg == "auto":
+ if tester.ckpt_operational_threshold is None:
+ tester.target_threshold = 0.5
+ print("[TARGET][WARN] checkpoint não possui operational_threshold; usando 0.5")
+ else:
+ tester.target_threshold = float(tester.ckpt_operational_threshold)
+ print(f"[TARGET] threshold=auto -> {tester.target_threshold:.3f} (salvo no checkpoint)")
+ else:
+ try:
+ tester.target_threshold = float(target_threshold_arg)
+ except ValueError as exc:
+ raise RuntimeError(f"--target_threshold inválido: {args.target_threshold}") from exc
+ if not (0.0 <= tester.target_threshold <= 1.0):
+ raise RuntimeError("--target_threshold deve ficar entre 0 e 1.")
+ print(f"[TARGET] threshold direto={tester.target_threshold:.3f}")
+
+ onnx_tester = None
+ onnx_path = None
+
+ if args.onnx_provider:
+ onnx_path = find_onnx_model(
+ save_dir=save_dir,
+ ckpt_path=ckpt_path,
+ preferred=args.onnx,
+ )
+
+ onnx_tester = OnnxMultiHeadTester(
+ config=config,
+ onnx_path=onnx_path,
+ provider=args.onnx_provider,
+ input_channel_names=input_channel_names,
+ heads_config=heads_config,
+ mean=mean,
+ std=std,
+ trt_home=args.trt_home,
+ trt_fp16=not args.trt_no_fp16,
+ )
+ onnx_tester.target_threshold = float(tester.target_threshold)
+
+ if onnx_tester is not None:
+ print(f"ONNX : {onnx_path}")
+ print(f"ONNX provider: {args.onnx_provider}")
+
+ out_dir = Path(args.out_dir)
+ ensure_dir(out_dir)
+
+ idx = max(0, min(args.start_idx, n - 1))
+ detailed = False
+
+ cms_total = {
+ name: np.zeros((int(hcfg["num_classes"]), int(hcfg["num_classes"])), dtype=np.int64)
+ for name, hcfg in heads_config.items()
+ }
+ cm_target_total = np.zeros((2, 2), dtype=np.int64)
+ visited = set()
+
+ win_name = "OAK-FCC-3 MultiHead Test | D/A navega | S salva | SPACE detalhado | Q sai"
+ cv2.namedWindow(win_name, cv2.WINDOW_NORMAL)
+ win_name_onnx = None
+ if onnx_tester is not None:
+ win_name_onnx = "ONNX/TensorRT MultiHead Test | comparação visual"
+ cv2.namedWindow(win_name_onnx, cv2.WINDOW_NORMAL)
+
+ while True:
+ sample = samples[idx]
+ chw = load_sample_tensor(
+ sample,
+ requested_channels=input_channel_names,
+ derived_config=config.get("derived_channels", {}) or {},
+ target_size=(target_w, target_h),
+ module_params_path=module_params_config,
+ )
+
+ if (chw.shape[2], chw.shape[1]) != (target_w, target_h):
+ hwc = np.transpose(chw, (1, 2, 0))
+ hwc = cv2.resize(hwc, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
+ chw = np.transpose(hwc, (2, 0, 1)).astype(np.float32)
+
+ preds, probs, t_inf = tester.infer(chw)
+ preview_rgb = tensor_to_preview_rgb(chw, input_channel_names)
+
+ onnx_preds = None
+ onnx_probs = None
+ t_onnx = None
+
+ if onnx_tester is not None:
+ onnx_preds, onnx_probs, t_onnx = onnx_tester.infer(chw)
+
+ first_pred = next(iter(preds.values()))
+ pred_h, pred_w = first_pred.shape[:2]
+
+ pred_sem = preds.get("semantic")
+ pred_veg = preds.get("vegetation")
+ pred_cana = preds.get("cana")
+
+ # Target operacional antigo: vegetation AND not cana
+ pred_target_op = None
+ if pred_veg is not None and pred_cana is not None:
+ pred_target_op = operational_target_mask(pred_veg, pred_cana, ignore_id=ignore_id)
+
+ # Target direta nova: saída da 4ª cabeça
+ pred_target_head = preds.get("target")
+
+ # Fallback visual: se não tiver head target, mostra operacional
+ pred_target = pred_target_head if pred_target_head is not None else pred_target_op
+
+ prob_veg = probs["vegetation"][1] if "vegetation" in probs and probs["vegetation"].shape[0] > 1 else None
+ prob_cana = probs["cana"][1] if "cana" in probs and probs["cana"].shape[0] > 1 else None
+
+ prob_target_op = None
+ if prob_veg is not None and prob_cana is not None:
+ prob_target_op = np.clip(prob_veg * (1.0 - prob_cana), 0.0, 1.0)
+
+ prob_target_head = None
+ if "target" in probs:
+ prob_target_head = probs["target"][1] if probs["target"].shape[0] > 1 else probs["target"][0]
+
+ prob_target = prob_target_head if prob_target_head is not None else prob_target_op
+
+ gt_masks = {name: load_mask(path) for name, path in sample.masks.items()}
+ for name, gt in list(gt_masks.items()):
+ if gt is not None and gt.shape != (pred_h, pred_w):
+ gt_masks[name] = cv2.resize(
+ gt.astype(np.uint8),
+ (pred_w, pred_h),
+ interpolation=cv2.INTER_NEAREST,
+ )
+
+ gt_target = None
+ if gt_masks.get("target") is not None:
+ gt_target = gt_masks["target"]
+ elif gt_masks.get("vegetation") is not None and gt_masks.get("cana") is not None:
+ gt_target = operational_target_mask(
+ gt_masks["vegetation"],
+ gt_masks["cana"],
+ ignore_id=ignore_id,
+ )
+ gt_masks["target"] = gt_target
+
+ # Métricas da amostra.
+ metric_lines = []
+ sample_metrics = {}
+ for head_name, pred in preds.items():
+ gt = gt_masks.get(head_name)
+ if gt is not None:
+ cm = confusion_matrix_np(pred, gt, int(heads_config[head_name]["num_classes"]), ignore_id)
+ iou, miou, acc = metrics_from_cm(cm)
+ sample_metrics[head_name] = {"iou": iou, "miou": miou, "acc": acc}
+ metric_lines.append(f"{head_name}: mIoU={miou:.3f} acc={acc:.3f}")
+
+ if gt_target is not None and pred_target_op is not None:
+ cm_t = confusion_matrix_np(pred_target_op, gt_target, 2, ignore_id)
+ iou_t, miou_t, acc_t = metrics_from_cm(cm_t)
+
+ sample_metrics["target_op"] = {
+ "iou": iou_t,
+ "miou": miou_t,
+ "acc": acc_t,
+ }
+
+ metric_lines.append(
+ f"target_op: IoU_alvo={iou_t[1]:.3f} acc={acc_t:.3f}"
+ )
+
+ if "target" in sample_metrics:
+ iou_head = sample_metrics["target"]["iou"]
+ metric_lines.append(
+ f"target_head: IoU_alvo={iou_head[1]:.3f}"
+ )
+
+ if idx not in visited:
+ for head_name, pred in preds.items():
+ gt = gt_masks.get(head_name)
+ if gt is not None:
+ cms_total[head_name] += confusion_matrix_np(pred, gt, int(heads_config[head_name]["num_classes"]), ignore_id)
+ if gt_target is not None and pred_target_op is not None:
+ cm_target_total += confusion_matrix_np(pred_target_op, gt_target, 2, ignore_id)
+ visited.add(idx)
+
+ # Visuals.
+ panels: List[Tuple[str, np.ndarray, str]] = []
+ if pred_sem is not None:
+ pred_sem_rgb = ids_to_rgb(pred_sem, semantic_cmap, ignore_id)
+ if gt_masks.get("semantic") is not None:
+ panels.append(("GT semantic", ids_to_rgb(gt_masks["semantic"], semantic_cmap, ignore_id), "chao/cana/erva"))
+ panels.append(("Pred semantic", pred_sem_rgb, f"erva={class_percent(pred_sem, 2, ignore_id):.1f}% cana={class_percent(pred_sem, 1, ignore_id):.1f}%"))
+ panels.append(("Overlay semantic", overlay_rgb(preview_rgb, pred_sem_rgb, args.alpha), ""))
+
+ if pred_veg is not None:
+ pred_veg_rgb = ids_to_rgb(pred_veg, BINARY_COLORS_RGB, ignore_id)
+ if gt_masks.get("vegetation") is not None:
+ panels.append(("GT vegetation", ids_to_rgb(gt_masks["vegetation"], BINARY_COLORS_RGB, ignore_id), "0=fundo 1=veg"))
+ panels.append(("Pred vegetation", pred_veg_rgb, f"veg={class_percent(pred_veg, 1, ignore_id):.1f}%"))
+ if prob_veg is not None:
+ panels.append(("P vegetation", prob_to_heat_rgb(prob_veg), f"mean={float(prob_veg.mean()):.3f}"))
+
+ if pred_cana is not None:
+ pred_cana_rgb = ids_to_rgb(pred_cana, CANA_COLORS_RGB, ignore_id)
+ if gt_masks.get("cana") is not None:
+ panels.append(("GT cana", ids_to_rgb(gt_masks["cana"], CANA_COLORS_RGB, ignore_id), "0=not_cana 1=cana"))
+ panels.append(("Pred cana", pred_cana_rgb, f"cana={class_percent(pred_cana, 1, ignore_id):.1f}%"))
+ if prob_cana is not None:
+ panels.append(("P cana", prob_to_heat_rgb(prob_cana), f"mean={float(prob_cana.mean()):.3f}"))
+
+ if detailed:
+ if gt_target is not None:
+ panels.append(("GT target", ids_to_rgb(gt_target, TARGET_COLORS_RGB, ignore_id), "derivado"))
+
+ if pred_target_head is not None:
+ panels.append(("Pred target HEAD", ids_to_rgb(pred_target_head, TARGET_COLORS_RGB, ignore_id), f"thr={tester.target_threshold:.2f} alvo={class_percent(pred_target_head, 1, ignore_id):.1f}%"))
+ if prob_target_head is not None:
+ panels.append(("P target HEAD", prob_to_heat_rgb(prob_target_head), f"mean={float(prob_target_head.mean()):.3f}"))
+
+ if pred_target_op is not None:
+ panels.append(("Pred target OP", ids_to_rgb(pred_target_op, TARGET_COLORS_RGB, ignore_id), f"alvo={class_percent(pred_target_op, 1, ignore_id):.1f}%"))
+ if prob_target_op is not None:
+ panels.append(("P target OP", prob_to_heat_rgb(prob_target_op), f"mean={float(prob_target_op.mean()):.3f}"))
+ else:
+ if pred_target_head is not None:
+ rgb = ids_to_rgb(pred_target_head, TARGET_COLORS_RGB, ignore_id)
+ panels.append(("Pred target HEAD", rgb, f"thr={tester.target_threshold:.2f} alvo={class_percent(pred_target_head, 1, ignore_id):.1f}%"))
+ panels.append(("Overlay target HEAD", overlay_rgb(preview_rgb, rgb, args.alpha), "head direta"))
+ elif pred_target_op is not None:
+ rgb = ids_to_rgb(pred_target_op, TARGET_COLORS_RGB, ignore_id)
+ panels.append(("Pred target OP", rgb, f"alvo={class_percent(pred_target_op, 1, ignore_id):.1f}%"))
+ panels.append(("Overlay target OP", overlay_rgb(preview_rgb, rgb, args.alpha), "veg & !cana"))
+
+ canvas = compose_grid(panels, cols=3, max_width=args.max_width)
+
+ header_h = 78
+ header = np.zeros((header_h, canvas.shape[1], 3), dtype=np.uint8)
+ header[:] = (25, 25, 25)
+ source_name = sample.tensor_path.name if sample.tensor_path is not None else f"{sample.base}.json [{sample.source_kind}]"
+ h1 = f"idx {idx + 1}/{n} | {source_name} | inf={t_inf:.1f}ms | {'detalhado' if detailed else 'compacto'}"
+ h2 = " | ".join(metric_lines[:3]) if metric_lines else "sem GT"
+ cv2.putText(header, h1, (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (235, 235, 235), 1, cv2.LINE_AA)
+ cv2.putText(header, h2, (12, 58), cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 255, 120), 1, cv2.LINE_AA)
+ canvas = np.vstack([header, canvas])
+
+ onnx_canvas = None
+ if onnx_tester is not None and onnx_preds is not None and onnx_probs is not None:
+ onnx_pred_sem = onnx_preds.get("semantic")
+ onnx_pred_veg = onnx_preds.get("vegetation")
+ onnx_pred_cana = onnx_preds.get("cana")
+
+ onnx_pred_target_op = None
+ if onnx_pred_veg is not None and onnx_pred_cana is not None:
+ onnx_pred_target_op = operational_target_mask(
+ onnx_pred_veg,
+ onnx_pred_cana,
+ ignore_id=ignore_id,
+ )
+
+ onnx_pred_target_head = onnx_preds.get("target")
+ onnx_pred_target = onnx_pred_target_head if onnx_pred_target_head is not None else onnx_pred_target_op
+
+ onnx_prob_veg = onnx_probs["vegetation"][1] if "vegetation" in onnx_probs and onnx_probs["vegetation"].shape[0] > 1 else None
+ onnx_prob_cana = onnx_probs["cana"][1] if "cana" in onnx_probs and onnx_probs["cana"].shape[0] > 1 else None
+
+ onnx_prob_target_op = None
+ if onnx_prob_veg is not None and onnx_prob_cana is not None:
+ onnx_prob_target_op = np.clip(onnx_prob_veg * (1.0 - onnx_prob_cana), 0.0, 1.0)
+
+ onnx_prob_target_head = None
+ if "target" in onnx_probs:
+ onnx_prob_target_head = onnx_probs["target"][1] if onnx_probs["target"].shape[0] > 1 else onnx_probs["target"][0]
+
+ onnx_prob_target = onnx_prob_target_head if onnx_prob_target_head is not None else onnx_prob_target_op
+
+ eq_sem = compare_pred_equal_percent(pred_sem, onnx_pred_sem)
+ eq_veg = compare_pred_equal_percent(pred_veg, onnx_pred_veg)
+ eq_cana = compare_pred_equal_percent(pred_cana, onnx_pred_cana)
+ eq_target = compare_pred_equal_percent(pred_target, onnx_pred_target)
+
+ onnx_panels: List[Tuple[str, np.ndarray, str]] = []
+
+ if onnx_pred_sem is not None:
+ onnx_sem_rgb = ids_to_rgb(onnx_pred_sem, semantic_cmap, ignore_id)
+ onnx_panels.append((
+ "ONNX semantic",
+ onnx_sem_rgb,
+ "" if eq_sem is None else f"igual PT={eq_sem:.3f}%"
+ ))
+ onnx_panels.append((
+ "ONNX overlay semantic",
+ overlay_rgb(preview_rgb, onnx_sem_rgb, args.alpha),
+ ""
+ ))
+
+ d = diff_mask_rgb(pred_sem, onnx_pred_sem)
+ if d is not None:
+ onnx_panels.append(("Diff semantic", d, "vermelho=diferente"))
+
+ if onnx_pred_veg is not None:
+ onnx_veg_rgb = ids_to_rgb(onnx_pred_veg, BINARY_COLORS_RGB, ignore_id)
+ onnx_panels.append((
+ "ONNX vegetation",
+ onnx_veg_rgb,
+ "" if eq_veg is None else f"igual PT={eq_veg:.3f}%"
+ ))
+
+ if onnx_prob_veg is not None:
+ onnx_panels.append((
+ "ONNX P vegetation",
+ prob_to_heat_rgb(onnx_prob_veg),
+ f"mean={float(onnx_prob_veg.mean()):.3f}"
+ ))
+
+ if onnx_pred_cana is not None:
+ onnx_cana_rgb = ids_to_rgb(onnx_pred_cana, CANA_COLORS_RGB, ignore_id)
+ onnx_panels.append((
+ "ONNX cana",
+ onnx_cana_rgb,
+ "" if eq_cana is None else f"igual PT={eq_cana:.3f}%"
+ ))
+
+ if onnx_prob_cana is not None:
+ onnx_panels.append((
+ "ONNX P cana",
+ prob_to_heat_rgb(onnx_prob_cana),
+ f"mean={float(onnx_prob_cana.mean()):.3f}"
+ ))
+
+ if onnx_pred_target is not None:
+ onnx_target_rgb = ids_to_rgb(onnx_pred_target, TARGET_COLORS_RGB, ignore_id)
+ title = "ONNX target HEAD" if onnx_pred_target_head is not None else "ONNX target OP"
+
+ onnx_panels.append((
+ title,
+ onnx_target_rgb,
+ "" if eq_target is None else f"igual PT={eq_target:.3f}%"
+ ))
+ onnx_panels.append((
+ "ONNX overlay target",
+ overlay_rgb(preview_rgb, onnx_target_rgb, args.alpha),
+ f"inf={t_onnx:.1f}ms"
+ ))
+
+ if onnx_prob_target is not None:
+ onnx_panels.append((
+ "ONNX P target",
+ prob_to_heat_rgb(onnx_prob_target),
+ f"mean={float(onnx_prob_target.mean()):.3f}"
+ ))
+
+ d = diff_mask_rgb(pred_target, onnx_pred_target)
+ if d is not None:
+ onnx_panels.append(("Diff target", d, "vermelho=diferente"))
+
+ onnx_canvas = compose_grid(onnx_panels, cols=3, max_width=args.max_width)
+
+ header_h_onnx = 78
+ header_onnx = np.zeros((header_h_onnx, onnx_canvas.shape[1], 3), dtype=np.uint8)
+ header_onnx[:] = (18, 18, 35)
+
+ h1_onnx = f"ONNX {args.onnx_provider} | {source_name} | inf={t_onnx:.1f}ms"
+ h2_parts = []
+ if eq_sem is not None:
+ h2_parts.append(f"sem={eq_sem:.3f}%")
+ if eq_veg is not None:
+ h2_parts.append(f"veg={eq_veg:.3f}%")
+ if eq_cana is not None:
+ h2_parts.append(f"cana={eq_cana:.3f}%")
+ if eq_target is not None:
+ h2_parts.append(f"target={eq_target:.3f}%")
+ h2_onnx = "igual PyTorch: " + " | ".join(h2_parts) if h2_parts else "comparação indisponível"
+
+ cv2.putText(header_onnx, h1_onnx, (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (235, 235, 235), 1, cv2.LINE_AA)
+ cv2.putText(header_onnx, h2_onnx, (12, 58), cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 255, 120), 1, cv2.LINE_AA)
+
+ onnx_canvas = np.vstack([header_onnx, onnx_canvas])
+
+ cv2.imshow(win_name, cv2.cvtColor(canvas, cv2.COLOR_RGB2BGR))
+ if onnx_canvas is not None and win_name_onnx is not None:
+ cv2.imshow(win_name_onnx, cv2.cvtColor(onnx_canvas, cv2.COLOR_RGB2BGR))
+
+ k = cv2.waitKey(0) & 0xFF
+
+ if k in (ord("q"), ord("Q"), 27):
+ break
+ elif k in (ord("d"), ord("D"), 83):
+ idx = (idx + 1) % n
+ elif k in (ord("a"), ord("A"), 81):
+ idx = (idx - 1 + n) % n
+ elif k == ord(" "):
+ detailed = not detailed
+ elif k in (ord("s"), ord("S")):
+ out_path = out_dir / f"multihead_pytorch_{idx:05d}_{sample.base}.png"
+ cv2.imwrite(str(out_path), cv2.cvtColor(canvas, cv2.COLOR_RGB2BGR))
+ print(f"[SAVE] {out_path}")
+
+ if onnx_canvas is not None:
+ out_path_onnx = out_dir / f"multihead_onnx_{args.onnx_provider}_{idx:05d}_{sample.base}.png"
+ cv2.imwrite(str(out_path_onnx), cv2.cvtColor(onnx_canvas, cv2.COLOR_RGB2BGR))
+ print(f"[SAVE] {out_path_onnx}")
+
+ cv2.destroyAllWindows()
+
+ if visited:
+ print("\n========== RESUMO DOS SAMPLES VISITADOS ==========")
+ print(f"visitados={len(visited)}/{n}")
+ for head_name, cm in cms_total.items():
+ if cm.sum() <= 0:
+ continue
+ iou, miou, acc = metrics_from_cm(cm)
+ print(f"\n[{head_name}] acc={acc:.4f} mIoU={miou:.4f}")
+ for i, v in enumerate(iou):
+ print(f" IoU {i} = {v:.4f}")
+
+ if cm_target_total.sum() > 0:
+ iou, miou, acc = metrics_from_cm(cm_target_total)
+ print(f"\n[target operacional] acc={acc:.4f} mIoU={miou:.4f}")
+ print(f" IoU background = {iou[0]:.4f}")
+ print(f" IoU alvo = {iou[1]:.4f}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/config.json b/Python/OAK/datasets/oak-fcc-3/config.json
index 8eeb400e9..574570075 100644
--- a/Python/OAK/datasets/oak-fcc-3/config.json
+++ b/Python/OAK/datasets/oak-fcc-3/config.json
@@ -1,18 +1,18 @@
{
"camera": "oak-fcc-3",
"modelo": "segformer_b1",
- "model_name": "2026_08_18",
+ "model_name": "2026_08_28_linear_demosaic",
"main_class_name": "erva",
"es_classes": "",
"model_to_use": "geral",
"raw_size": [1280, 800],
- "resolucao": [640, 400],
+ "resolucao": [960, 600],
"roi_inicio": 0.0,
"roi_tamanho": 1.0,
"shaves": 3,
"source_channels": ["R", "G", "B", "RE", "NIR"],
- "channels": 7,
- "input_channels": ["R", "G", "B", "RE", "NIR", "NDVI", "NDRE"],
+ "channels": 5,
+ "input_channels": ["R", "G", "B", "RE", "NIR"],
"derived_channels": {
"epsilon": 1e-6,
"clip_min": -1.0,
@@ -20,7 +20,7 @@
},
"backbone": "nvidia/mit-b1",
"fusion_mode": "stacked",
- "stats_source_tag": "stacked_raw5_ndvi_ndre",
+ "stats_source_tag": "stacked_raw5",
"module_params_json": "calibration/module_params.json",
"ckpt_test": "best_score",
"multi_head": true,
@@ -41,7 +41,7 @@
"mask_dir": "masks_vegetation",
"classes": {"background": 0, "vegetation": 1},
"ignore_index": 255,
- "loss_weight": 0.20
+ "loss_weight": 0.15
},
"cana": {
"enabled": true,
@@ -50,7 +50,7 @@
"mask_dir": "masks_cana",
"classes": {"not_cana": 0, "cana": 1},
"ignore_index": 255,
- "loss_weight": 0.25
+ "loss_weight": 0.35
},
"target": {
"enabled": true,
@@ -59,23 +59,158 @@
"mask_dir": "__derived_target__",
"classes": {"background": 0, "target": 1},
"ignore_index": 255,
- "loss_weight": 0.35,
+ "loss_weight": 0.30,
"derived": true
}
},
- "target_distillation": {
- "enabled": true,
- "hard_weight": 0.85,
- "distill_weight": 0.15,
- "rampup_enabled": true,
- "start_epoch": 8,
- "rampup_epochs": 12,
- "w_sem_erva": 0.45,
- "w_veg_not_cana": 0.35,
- "w_veg_suppressed": 0.20,
- "cana_suppression_power": 1.5,
- "teacher_min": 0.0,
- "teacher_max": 1.0,
- "detach_teacher": true
+ "training_v2": {
+ "model": {
+ "decoder_mode": "shared_light",
+ "spectral_input_init": "zero_extra"
+ },
+ "augmentation": {
+ "enabled": true,
+ "horizontal_flip_p": 0.5,
+ "vertical_flip_p": 0.0,
+ "affine_p": 0.7,
+ "rotate_deg": 5.0,
+ "scale_min": 0.9,
+ "scale_max": 1.1,
+ "translate_frac": 0.04,
+ "crop_p": 0.45,
+ "crop_scale_min": 0.7,
+ "crop_scale_max": 1.0,
+ "crop_focus_target_p": 0.55,
+ "crop_focus_cana_p": 0.25,
+ "global_gain_p": 0.35,
+ "global_gain_min": 0.92,
+ "global_gain_max": 1.08,
+ "band_gain_p": 0.25,
+ "band_gain_min": 0.96,
+ "band_gain_max": 1.04,
+ "rgb_gamma_p": 0.2,
+ "rgb_gamma_min": 0.94,
+ "rgb_gamma_max": 1.06,
+ "noise_p": 0.2,
+ "noise_sigma_min": 0.001,
+ "noise_sigma_max": 0.008,
+ "blur_p": 0.12,
+ "blur_kernel": 3,
+ "sensor_channel_dropout_p": 0.0,
+ "clip_physical": true
+ },
+ "sampler": {
+ "mode": "diverse",
+ "samples_per_epoch": 0,
+ "tiny_target_pct": 0.005,
+ "small_target_pct": 0.02,
+ "medium_target_pct": 0.1
+ },
+ "class_weighting": {
+ "method": "log_inverse",
+ "log_offset": 1.02,
+ "power": 0.5,
+ "min_weight": 0.25,
+ "max_weight": 4.0
+ },
+ "loss": {
+ "dice_reduction": "per_image",
+ "dice_smooth": 1.0,
+ "boundary_weight": 0.0,
+ "ohem_ratio": 0.0,
+ "safety": {
+ "enabled": true,
+ "weight": 0.08,
+ "cana_weight": 1.0,
+ "ground_weight": 0.2
+ }
+ },
+ "optimizer": {
+ "encoder_lr": null,
+ "patch_lr_mult": 2.0,
+ "heads_lr_mult": 5.0,
+ "weight_decay": null,
+ "no_decay_bias": true,
+ "no_decay_norm": true,
+ "betas": [
+ 0.9,
+ 0.999
+ ],
+ "eps": 1e-08
+ },
+ "scheduler": {
+ "mode": "poly",
+ "warmup_ratio": 0.05,
+ "warmup_start_factor": 0.1,
+ "poly_power": 1.0,
+ "min_lr_ratio": 0.02
+ },
+ "optimization": {
+ "grad_clip_norm": 1.0,
+ "matmul_precision": "high",
+ "cudnn_benchmark": true,
+ "persistent_workers": true,
+ "prefetch_factor": 2
+ },
+ "target_distillation": {
+ "enabled": false,
+ "mode": "cross_head",
+ "start_epoch": 8,
+ "rampup_epochs": 12,
+ "hard_weight": 0.75,
+ "distill_weight": 0.25,
+ "teacher_confidence_min": 0.6,
+ "detach_teacher": true,
+ "w_sem_erva": 0.45,
+ "w_veg_not_cana": 0.35,
+ "w_veg_suppressed": 0.2,
+ "cana_suppression_power": 1.5
+ },
+ "metrics": {
+ "target_thresholds": [
+ 0.3,
+ 0.4,
+ 0.5,
+ 0.6,
+ 0.7,
+ 0.8,
+ 0.9
+ ],
+ "ece_bins": 15,
+ "scenario_metrics": true,
+ "group_metrics": true,
+ "rich_train_metrics": false,
+ "operational_threshold": {
+ "max_cana_spray_rate": 0.02,
+ "max_ground_spray_rate": 0.03,
+ "max_weed_miss_rate": 0.2,
+ "score_weights": {
+ "target_iou": 0.35,
+ "target_f1": 0.2,
+ "cana_safety": 0.25,
+ "ground_safety": 0.1,
+ "weed_recall": 0.1
+ }
+ }
+ },
+ "selection_score": {
+ "target_iou": 0.4,
+ "cana_iou": 0.2,
+ "target_f1": 0.1,
+ "vegetation_miou": 0.1,
+ "semantic_miou": 0.05,
+ "cana_safety": 0.15
+ },
+ "checkpoint": {
+ "early_stop_min_delta": 0.0005,
+ "save_best_safety": true,
+ "save_best_legacy": true,
+ "save_best_operational": true
+ },
+ "data": {
+ "validate_npy_content": true,
+ "skip_corrupt_samples": true,
+ "max_corrupt_fraction": 0.005
+ }
}
}
\ No newline at end of file
diff --git a/Python/OAK/datasets/oak-fcc-3/core/benchmark.py b/Python/OAK/datasets/oak-fcc-3/core/benchmark.py
deleted file mode 100644
index 755895987..000000000
--- a/Python/OAK/datasets/oak-fcc-3/core/benchmark.py
+++ /dev/null
@@ -1,620 +0,0 @@
-import argparse
-import json
-import sys
-import time
-from pathlib import Path
-
-import cv2
-import numpy as np
-
-try:
- import torch
-except Exception:
- torch = None
-
-
-# ============================================================
-# Ajuste de import local
-# ============================================================
-
-THIS_FILE = Path(__file__).resolve()
-
-# Esperado:
-# .../Python/Scripts/workers/camera_worker/oak_fcc3_core/benchmark_raw_bruto_scientific.py
-WORKERS_DIR = THIS_FILE.parents[2]
-
-if str(WORKERS_DIR) not in sys.path:
- sys.path.insert(0, str(WORKERS_DIR))
-
-from camera_worker.oak_fcc3_core.oak_fcc3_client import OakFcc3Client
-
-try:
- from camera_worker.oak_fcc3_core.segformer_service import MultiSpecSegformerService
-except Exception:
- MultiSpecSegformerService = None
-
-
-# ============================================================
-# Utils
-# ============================================================
-
-def now_ms():
- return time.perf_counter() * 1000.0
-
-
-def mean(xs):
- return float(np.mean(xs)) if xs else 0.0
-
-
-def p95(xs):
- return float(np.percentile(xs, 95)) if xs else 0.0
-
-
-def maxv(xs):
- return float(np.max(xs)) if xs else 0.0
-
-
-def last_mean(xs, n=30):
- return float(np.mean(xs[-n:])) if xs else 0.0
-
-
-def last_max(xs, n=30):
- return float(np.max(xs[-n:])) if xs else 0.0
-
-
-def load_json_if_exists(path):
- if not path:
- return None
- with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
-
-
-def parse_float_list(s, expected=5, default=None):
- if s is None:
- return default
-
- vals = [float(x.strip()) for x in str(s).split(",") if x.strip() != ""]
-
- if len(vals) != expected:
- raise ValueError(f"Esperado {expected} valores, veio {len(vals)}: {s}")
-
- return np.array(vals, dtype=np.float32)
-
-
-def extract_mean_std_from_model_config(cfg):
- if not isinstance(cfg, dict):
- return None, None
-
- mean_cfg = cfg.get("mean") or cfg.get("channel_mean") or cfg.get("norm_mean")
- std_cfg = cfg.get("std") or cfg.get("channel_std") or cfg.get("norm_std")
-
- norm = cfg.get("normalization") or cfg.get("norm") or {}
-
- if mean_cfg is None and isinstance(norm, dict):
- mean_cfg = norm.get("mean") or norm.get("channel_mean")
-
- if std_cfg is None and isinstance(norm, dict):
- std_cfg = norm.get("std") or norm.get("channel_std")
-
- if mean_cfg is None or std_cfg is None:
- return None, None
-
- mean_arr = np.array(mean_cfg, dtype=np.float32)
- std_arr = np.array(std_cfg, dtype=np.float32)
-
- if mean_arr.size != 5 or std_arr.size != 5:
- return None, None
-
- return mean_arr, std_arr
-
-
-def tensor_stats(tensor):
- names = ["R", "G", "B", "RE", "NIR"]
- out = {}
-
- for i, name in enumerate(names):
- ch = tensor[i].astype(np.float32)
- out[name] = {
- "min": float(np.min(ch)),
- "p01": float(np.percentile(ch, 1)),
- "p50": float(np.percentile(ch, 50)),
- "p99": float(np.percentile(ch, 99)),
- "max": float(np.max(ch)),
- "mean": float(np.mean(ch)),
- "std": float(np.std(ch)),
- }
-
- return out
-
-
-def print_tensor_stats(label, tensor):
- print("============================================")
- print(f"[{label}] TENSOR")
- print(f"shape={tensor.shape} dtype={tensor.dtype}")
-
- stats = tensor_stats(tensor)
- for ch, s in stats.items():
- print(
- f"{ch:>3} | "
- f"min={s['min']:.4f} "
- f"p01={s['p01']:.4f} "
- f"p50={s['p50']:.4f} "
- f"p99={s['p99']:.4f} "
- f"max={s['max']:.4f} "
- f"mean={s['mean']:.4f} "
- f"std={s['std']:.4f}"
- )
-
- print("============================================")
-
-
-def to_u8_01(arr):
- arr = np.asarray(arr, dtype=np.float32)
- arr = np.nan_to_num(arr, nan=0.0, posinf=1.0, neginf=0.0)
- arr = np.clip(arr, 0.0, 1.0)
- return (arr * 255.0).astype(np.uint8)
-
-
-def make_panel(tensor):
- rgb = np.stack([tensor[0], tensor[1], tensor[2]], axis=2)
- rgb_bgr = cv2.cvtColor(to_u8_01(rgb), cv2.COLOR_RGB2BGR)
-
- re_bgr = cv2.cvtColor(to_u8_01(tensor[3]), cv2.COLOR_GRAY2BGR)
- nir_bgr = cv2.cvtColor(to_u8_01(tensor[4]), cv2.COLOR_GRAY2BGR)
-
- false_rgb = np.stack(
- [
- tensor[4], # visual R = NIR
- tensor[3], # visual G = RE
- tensor[0], # visual B = R real
- ],
- axis=2,
- )
- false_bgr = cv2.cvtColor(to_u8_01(false_rgb), cv2.COLOR_RGB2BGR)
-
- def title(img, text):
- h, w = img.shape[:2]
- bar_h = 34
- bar = np.zeros((bar_h, w, 3), dtype=np.uint8)
- cv2.putText(
- bar,
- text,
- (10, 24),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.7,
- (255, 255, 255),
- 2,
- cv2.LINE_AA,
- )
- return np.vstack([bar, img])
-
- rgb_bgr = title(rgb_bgr, "RGB")
- re_bgr = title(re_bgr, "RE")
- nir_bgr = title(nir_bgr, "NIR")
- false_bgr = title(false_bgr, "Falso color NIR/RE/R")
-
- top = np.hstack([rgb_bgr, re_bgr])
- bottom = np.hstack([nir_bgr, false_bgr])
-
- return np.vstack([top, bottom])
-
-
-# ============================================================
-# Benchmark processor
-# ============================================================
-
-class ScientificBenchmark:
- def __init__(self, args):
- self.args = args
- self.target_size = (int(args.width), int(args.height))
-
- self.model_cfg = load_json_if_exists(args.model_config_json)
- mean_cfg, std_cfg = extract_mean_std_from_model_config(self.model_cfg)
-
- if args.model_mean is not None:
- self.mean = parse_float_list(args.model_mean, expected=5)
- elif mean_cfg is not None:
- self.mean = mean_cfg
- else:
- self.mean = np.array([0.5, 0.5, 0.5, 0.5, 0.5], dtype=np.float32)
-
- if args.model_std is not None:
- self.std = parse_float_list(args.model_std, expected=5)
- elif std_cfg is not None:
- self.std = std_cfg
- else:
- self.std = np.array([0.25, 0.25, 0.25, 0.25, 0.25], dtype=np.float32)
-
- self.mean_chw = self.mean[:, None, None].astype(np.float32)
- self.std_chw = self.std[:, None, None].astype(np.float32)
-
- self.device = None
- if torch is not None and torch.cuda.is_available():
- self.device = torch.device("cuda")
- elif torch is not None:
- self.device = torch.device("cpu")
-
- self.model_svc = None
-
- if args.run_model:
- if MultiSpecSegformerService is None:
- raise RuntimeError("MultiSpecSegformerService não pôde ser importado.")
-
- if not isinstance(self.model_cfg, dict):
- raise RuntimeError("--run_model requer --model_config_json válido.")
-
- self.model_svc = MultiSpecSegformerService(
- model_config=self.model_cfg,
- mostrar_log=print,
- )
-
- dummy = np.zeros((5, args.height, args.width), dtype=np.float32)
-
- for _ in range(max(0, int(args.warmup_model))):
- self.model_svc.infer_tensor_fast(dummy, keep_probs=False)
-
- print(f"[BENCH] Warmup modelo concluído: {args.warmup_model}x")
-
- def process_once(self, client):
- """
- Mede uma iteração completa do fluxo científico.
- """
- times = {
- "capture_ms": 0.0,
- "decode_ms": 0.0,
- "controller_ms": 0.0,
- "fuse_total_ms": 0.0,
- "fuse_dark_ms": 0.0,
- "fuse_radnorm_ms": 0.0,
- "fuse_flat_ms": 0.0,
- "fuse_prepare_ms": 0.0,
- "fuse_warp_ms": 0.0,
- "fuse_crop_resize_ms": 0.0,
- "fuse_concat_ms": 0.0,
- "model_norm_ms": 0.0,
- "torch_ms": 0.0,
- "infer_ms": 0.0,
- "total_ms": 0.0,
- "sync_dt_ms": 0.0,
- }
-
- t_total0 = now_ms()
-
- # ========================================================
- # 1. Captura RAW_BRUTO
- # ========================================================
- t0 = now_ms()
- raw_frame, raw_meta = client.get_next_raw_frame(timeout=self.args.timeout)
- times["capture_ms"] = now_ms() - t0
-
- #cp = raw_meta.get("capture_perf", {})
- #print(
- # "[CAP_ASYNC] "
- # f"get_wait={cp.get('async_get_wait_ms',0):.2f}ms "
- # f"age={cp.get('async_packet_age_ms',0):.2f}ms "
- # f"seq={cp.get('async_packet_seq')} "
- # f"thread_wait={cp.get('wait_total_ms',0):.2f}ms "
- # f"sleep={cp.get('sleep_ms',0):.2f}ms/{cp.get('sleep_count',0)} "
- # f"drain={cp.get('drain_total_ms',0):.2f}ms "
- # f"copy={cp.get('drain_frombuffer_copy_ms',0):.2f}ms "
- # f"status={cp.get('async_status',{})}"
- #)
-
- times["sync_dt_ms"] = float(raw_meta.get("sync_dt_ms", 0.0) or 0.0)
-
- # ========================================================
- # 2. Decode RAW10 packed -> float científico por câmera
- # ========================================================
- t0 = now_ms()
- decoded = client.decode_stream_cameras(raw_frame, raw_meta)
- times["decode_ms"] = now_ms() - t0
-
- # ========================================================
- # 3. RadiometricController update, se ativo
- # Isso NÃO é a radiometric_normalization do tensor.
- # É o controlador de exposição/ganho.
- # ========================================================
- t0 = now_ms()
- client.update_radiometry(decoded, raw_meta)
- times["controller_ms"] = now_ms() - t0
-
- # ========================================================
- # 4. Fusão científica no RawProcessorCore
- # dark + radnorm + flat + homografia + crop/resize + concat
- # ========================================================
- t0 = now_ms()
- tensor = client.build_infer_tensor_from_decoded(
- decoded=decoded,
- meta=raw_meta,
- channels_expected=5,
- target_size=self.target_size,
- )
- times["fuse_total_ms"] = now_ms() - t0
-
- # Pega detalhamento interno do core
- try:
- perf = (client.core.last_fusion_result or {}).get("perf", {}) or {}
- times["fuse_dark_ms"] = float(perf.get("dark_ms", 0.0) or 0.0)
- times["fuse_radnorm_ms"] = float(perf.get("radnorm_ms", 0.0) or 0.0)
- times["fuse_flat_ms"] = float(perf.get("flat_ms", 0.0) or 0.0)
- times["fuse_prepare_ms"] = float(perf.get("prepare_ms", 0.0) or 0.0)
- times["fuse_warp_ms"] = float(perf.get("warp_total_ms", 0.0) or 0.0)
- times["fuse_crop_resize_ms"] = float(perf.get("crop_resize_ms", 0.0) or 0.0)
- times["fuse_concat_ms"] = float(perf.get("concat_ms", 0.0) or 0.0)
- except Exception:
- pass
-
- # ========================================================
- # 5. Normalização do modelo, opcional
- # ========================================================
- if self.args.simulate_model_norm:
- t0 = now_ms()
- tensor = (tensor - self.mean_chw) / np.maximum(self.std_chw, 1e-6)
- tensor = np.ascontiguousarray(tensor, dtype=np.float32)
- times["model_norm_ms"] = now_ms() - t0
-
- # ========================================================
- # 6. Transferência para torch/cuda, opcional
- # ========================================================
- if self.args.to_torch:
- if torch is None:
- raise RuntimeError("--to_torch requer torch instalado.")
-
- t0 = now_ms()
- x = torch.from_numpy(tensor).unsqueeze(0).to(self.device, non_blocking=True)
-
- if self.device is not None and self.device.type == "cuda":
- torch.cuda.synchronize()
-
- times["torch_ms"] = now_ms() - t0
-
- # ========================================================
- # 7. Inferência real, opcional
- # ========================================================
- pred = None
- if self.args.run_model:
- t0 = now_ms()
- pred = self.model_svc.infer_tensor_fast(tensor, keep_probs=False)
- times["infer_ms"] = now_ms() - t0
-
- times["total_ms"] = now_ms() - t_total0
-
- return tensor, pred, raw_frame, raw_meta, decoded, times
-
-
-# ============================================================
-# Main
-# ============================================================
-
-def main():
- ap = argparse.ArgumentParser(
- description="Benchmark científico OAK-FCC-3 RAW_BRUTO -> tensor multiespectral final."
- )
-
- ap.add_argument(
- "--module_params",
- default=r"C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\calibration\module_params.json",
- help="Caminho do module_params.json.",
- )
-
- ap.add_argument("--width", type=int, default=1024, help="Largura final do tensor.")
- ap.add_argument("--height", type=int, default=640, help="Altura final do tensor.")
- ap.add_argument("--fps", type=float, default=30.0)
- ap.add_argument("--seconds", type=float, default=20.0)
- ap.add_argument("--timeout", type=float, default=3.0)
- ap.add_argument("--warmup", type=int, default=5)
- ap.add_argument("--mx_id", default=None)
- ap.add_argument("--sync_tolerance_ms", type=float, default=25.0)
- ap.add_argument("--buffer_size", type=int, default=8)
-
- ap.add_argument("--simulate_model_norm", action="store_true")
- ap.add_argument("--model_mean", default=None)
- ap.add_argument("--model_std", default=None)
- ap.add_argument("--to_torch", action="store_true")
-
- ap.add_argument("--run_model", action="store_true")
- ap.add_argument("--model_config_json", default=None)
- ap.add_argument("--warmup_model", type=int, default=3)
-
- ap.add_argument("--save_debug", action="store_true")
- ap.add_argument("--debug_dir", default="raw_bruto_scientific_benchmark")
- ap.add_argument("--show", action="store_true")
- ap.add_argument("--display_scale", type=float, default=0.65)
-
- args = ap.parse_args()
-
- bench = ScientificBenchmark(args)
-
- client = OakFcc3Client(
- width=args.width,
- height=args.height,
- fps=args.fps,
- frame_type="RAW_BRUTO",
- output_dtype="uint8",
- capture_mode="TRIPLE",
- raw_policy="require_triple",
- module_calibration_json=args.module_params,
- sync_tolerance_ms=args.sync_tolerance_ms,
- buffer_size=args.buffer_size,
- mx_id=args.mx_id,
- )
-
- client.core.warmup_numba_raw10_decode()
-
- samples = {
- "capture_ms": [],
- "decode_ms": [],
- "controller_ms": [],
- "fuse_total_ms": [],
- "fuse_dark_ms": [],
- "fuse_radnorm_ms": [],
- "fuse_flat_ms": [],
- "fuse_prepare_ms": [],
- "fuse_warp_ms": [],
- "fuse_crop_resize_ms": [],
- "fuse_concat_ms": [],
- "model_norm_ms": [],
- "torch_ms": [],
- "infer_ms": [],
- "total_ms": [],
- "sync_dt_ms": [],
- }
-
- n_frames = 0
- t_start = time.perf_counter()
- t_last_log = t_start
-
- last_tensor = None
- last_meta = None
-
- try:
- client.start(print_debug=True)
-
- # Warmup de câmera/controlador/filas
- print(f"[BENCH] Warmup frames: {args.warmup}")
- for _ in range(max(0, int(args.warmup))):
- try:
- bench.process_once(client)
- except Exception as e:
- print(f"[WARN] warmup falhou: {type(e).__name__}: {e}")
- time.sleep(0.02)
-
- print("============================================")
- print("[BENCH] Iniciando benchmark científico RAW_BRUTO")
- print(f"target tensor : (5,{args.height},{args.width})")
- print(f"duration : {args.seconds}s")
- print("============================================")
-
- while True:
- now = time.perf_counter()
- elapsed = now - t_start
-
- if elapsed >= args.seconds:
- break
-
- try:
- tensor, pred, raw_frame, raw_meta, decoded, times = bench.process_once(client)
- except TimeoutError as e:
- print(f"[TIMEOUT] {e}")
- continue
-
- n_frames += 1
- last_tensor = tensor
- last_meta = raw_meta
-
- for k in samples:
- samples[k].append(float(times.get(k, 0.0) or 0.0))
-
- if now - t_last_log >= 1.0:
- elapsed = now - t_start
- fps = n_frames / max(elapsed, 1e-6)
-
- print(
- "[RAW_SCI_PERF] "
- f"elapsed={elapsed:.1f}s "
- f"frames={n_frames} "
- f"fps={fps:.2f} "
- f"sync={last_mean(samples['sync_dt_ms']):.2f}ms "
- f"capture={last_mean(samples['capture_ms']):.2f}ms "
- f"decode={last_mean(samples['decode_ms']):.2f}ms "
- f"controller={last_mean(samples['controller_ms']):.2f}ms "
- f"fuse={last_mean(samples['fuse_total_ms']):.2f}ms "
- f"radnorm={last_mean(samples['fuse_radnorm_ms']):.2f}ms "
- f"flat={last_mean(samples['fuse_flat_ms']):.2f}ms "
- f"warp={last_mean(samples['fuse_warp_ms']):.2f}ms "
- f"crop_resize={last_mean(samples['fuse_crop_resize_ms']):.2f}ms "
- f"concat={last_mean(samples['fuse_concat_ms']):.2f}ms "
- f"model_norm={last_mean(samples['model_norm_ms']):.2f}ms "
- f"torch={last_mean(samples['torch_ms']):.2f}ms "
- f"infer={last_mean(samples['infer_ms']):.2f}ms "
- f"total={last_mean(samples['total_ms']):.2f}ms "
- f"tensor_shape={tuple(tensor.shape)}"
- )
-
- t_last_log = now
-
- elapsed_total = time.perf_counter() - t_start
- fps_total = n_frames / max(elapsed_total, 1e-6)
-
- print("============================================")
- print("RESULTADO FINAL RAW_BRUTO CIENTÍFICO")
- print(f"elapsed : {elapsed_total:.2f}s")
- print(f"frames : {n_frames}")
- print(f"fps : {fps_total:.2f}")
- print("--------------------------------------------")
-
- def print_metric(name):
- xs = samples[name]
- print(
- f"{name:18s} "
- f"mean={mean(xs):8.2f}ms "
- f"p95={p95(xs):8.2f}ms "
- f"max={maxv(xs):8.2f}ms"
- )
-
- for name in [
- "sync_dt_ms",
- "capture_ms",
- "decode_ms",
- "controller_ms",
- "fuse_total_ms",
- "fuse_dark_ms",
- "fuse_radnorm_ms",
- "fuse_flat_ms",
- "fuse_prepare_ms",
- "fuse_warp_ms",
- "fuse_crop_resize_ms",
- "fuse_concat_ms",
- "model_norm_ms",
- "torch_ms",
- "infer_ms",
- "total_ms",
- ]:
- print_metric(name)
-
- print("============================================")
-
- if last_tensor is not None:
- print_tensor_stats("LAST RAW_BRUTO SCI", last_tensor)
-
- if args.save_debug:
- debug_dir = Path(args.debug_dir)
- debug_dir.mkdir(parents=True, exist_ok=True)
-
- np.save(str(debug_dir / "last_tensor.npy"), last_tensor)
-
- stats_path = debug_dir / "last_tensor_stats.json"
- with open(stats_path, "w", encoding="utf-8") as f:
- json.dump(tensor_stats(last_tensor), f, indent=2, ensure_ascii=False)
-
- panel = make_panel(last_tensor)
- cv2.imwrite(str(debug_dir / "last_tensor_panel.png"), panel)
-
- if last_meta is not None:
- with open(debug_dir / "last_meta.json", "w", encoding="utf-8") as f:
- json.dump(last_meta, f, indent=2, ensure_ascii=False)
-
- print(f"[SAVE] Debug salvo em: {debug_dir}")
-
- if args.show:
- panel = make_panel(last_tensor)
-
- if args.display_scale and abs(args.display_scale - 1.0) > 1e-6:
- new_w = max(1, int(panel.shape[1] * args.display_scale))
- new_h = max(1, int(panel.shape[0] * args.display_scale))
- panel = cv2.resize(panel, (new_w, new_h), interpolation=cv2.INTER_AREA)
-
- cv2.imshow("RAW_BRUTO scientific tensor", panel)
- print("[INFO] Pressione qualquer tecla para fechar.")
- cv2.waitKey(0)
- cv2.destroyAllWindows()
-
- finally:
- try:
- client.stop()
- except Exception:
- pass
-
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/Python/OAK/datasets/oak-fcc-3/core/benchmark_preview.py b/Python/OAK/datasets/oak-fcc-3/core/benchmark_preview.py
deleted file mode 100644
index 407a3bbbe..000000000
--- a/Python/OAK/datasets/oak-fcc-3/core/benchmark_preview.py
+++ /dev/null
@@ -1,645 +0,0 @@
-import argparse
-import json
-import sys
-import time
-import threading
-from pathlib import Path
-
-import cv2
-import numpy as np
-
-# ============================================================
-# Ajuste de import local
-# ============================================================
-THIS_FILE = Path(__file__).resolve()
-WORKERS_DIR = THIS_FILE.parents[2]
-if str(WORKERS_DIR) not in sys.path:
- sys.path.insert(0, str(WORKERS_DIR))
-
-from camera_worker.oak_fcc3_core.oak_fcc3_client import OakFcc3Client
-
-try:
- from camera_worker.oak_fcc3_core.segformer_service import MultiSpecSegformerService
-except Exception:
- MultiSpecSegformerService = None
-
-
-# ============================================================
-# FPS / utilidades
-# ============================================================
-class FpsMeter:
- def __init__(self, alpha=0.15):
- self.alpha = float(alpha)
- self.last_ts = None
- self.fps = 0.0
-
- def tick(self):
- now = time.perf_counter()
- if self.last_ts is not None:
- dt = now - self.last_ts
- if dt > 1e-9:
- inst = 1.0 / dt
- self.fps = inst if self.fps <= 0 else (1.0 - self.alpha) * self.fps + self.alpha * inst
- self.last_ts = now
- return self.fps
-
-
-def load_json_if_exists(path):
- if not path:
- return None
- with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
-
-
-def init_model_service(args):
- """
- Mesmo contrato do benchmark científico:
- --run_model exige --model_config_json
- MultiSpecSegformerService(model_config=cfg).infer_tensor_fast(tensor, keep_probs=False)
- """
- if not args.run_model:
- return None
-
- if MultiSpecSegformerService is None:
- raise RuntimeError("MultiSpecSegformerService não pôde ser importado.")
-
- model_cfg = load_json_if_exists(args.model_config_json)
- if not isinstance(model_cfg, dict):
- raise RuntimeError("--run_model requer --model_config_json válido.")
-
- svc = MultiSpecSegformerService(
- model_config=model_cfg,
- mostrar_log=print,
- )
-
- dummy = np.zeros((5, int(args.height), int(args.width)), dtype=np.float32)
- for _ in range(max(0, int(args.warmup_model))):
- svc.infer_tensor_fast(dummy, keep_probs=False)
-
- print(f"[MODEL] Warmup concluído: {args.warmup_model}x")
- return svc
-
-
-def to_u8_01(arr, auto_level=False):
- arr = np.asarray(arr, dtype=np.float32)
- arr = np.nan_to_num(arr, nan=0.0, posinf=1.0, neginf=0.0)
-
- if auto_level:
- p1 = float(np.percentile(arr, 1))
- p99 = float(np.percentile(arr, 99))
- den = max(p99 - p1, 1e-6)
- arr = (arr - p1) / den
-
- arr = np.clip(arr, 0.0, 1.0)
- return (arr * 255.0).astype(np.uint8)
-
-
-def ensure_bgr(img, auto_level=False):
- img = np.asarray(img)
-
- if img.ndim == 2:
- g = to_u8_01(img, auto_level=auto_level)
- return cv2.cvtColor(g, cv2.COLOR_GRAY2BGR)
-
- if img.ndim == 3 and img.shape[2] == 3:
- u8 = to_u8_01(img, auto_level=auto_level)
- # decoded/tensor RGB vem em RGB; OpenCV mostra BGR
- return cv2.cvtColor(u8, cv2.COLOR_RGB2BGR)
-
- raise RuntimeError(f"Imagem inválida para visualização: shape={img.shape}")
-
-
-def tensor_rgb_to_bgr(tensor, auto_level=False):
- rgb = np.stack([tensor[0], tensor[1], tensor[2]], axis=2)
- return ensure_bgr(rgb, auto_level=auto_level)
-
-
-def tensor_channel_to_bgr(tensor, idx, auto_level=False):
- return ensure_bgr(tensor[idx], auto_level=auto_level)
-
-
-def add_title(img, title, color=(255, 255, 255)):
- out = img.copy()
- h, w = out.shape[:2]
- bar_h = 34
- bar = np.zeros((bar_h, w, 3), dtype=np.uint8)
- cv2.putText(bar, str(title), (10, 23), cv2.FONT_HERSHEY_SIMPLEX, 0.62, color, 2, cv2.LINE_AA)
- return np.vstack([bar, out])
-
-
-def add_hud(panel, lines):
- out = panel.copy()
- x, y = 12, 45
- for line in lines:
- cv2.putText(out, line, (x, y), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (0, 255, 255), 2, cv2.LINE_AA)
- y += 24
- return out
-
-
-def resize_tile(img, tile_w, tile_h):
- return cv2.resize(img, (int(tile_w), int(tile_h)), interpolation=cv2.INTER_AREA)
-
-
-def get_cam_by_role(decoded, role):
- role = str(role).lower()
- for cam_id, item in decoded.items():
- r = str(item.get("role") or item.get("meta", {}).get("role") or "").lower()
- if r == role:
- return cam_id
- return None
-
-
-def decoded_raw_tiles(decoded, tile_w, tile_h, auto_level=False):
- """
- Retorna tiles RAW/decoded para RGB, RE, NIR antes da fusão final.
- Aqui 'RAW_BRUTO' significa o conteúdo decodificado vindo das câmeras, ainda no espaço nativo.
- """
- tiles = {}
-
- rgb_id = get_cam_by_role(decoded, "rgb")
- re_id = get_cam_by_role(decoded, "re")
- nir_id = get_cam_by_role(decoded, "nir")
-
- if rgb_id is not None:
- img = decoded[rgb_id]["image"]
- tiles["rgb"] = resize_tile(ensure_bgr(img, auto_level=auto_level), tile_w, tile_h)
- else:
- tiles["rgb"] = np.zeros((tile_h, tile_w, 3), dtype=np.uint8)
-
- if re_id is not None:
- img = decoded[re_id]["image"]
- tiles["re"] = resize_tile(ensure_bgr(img, auto_level=auto_level), tile_w, tile_h)
- else:
- tiles["re"] = np.zeros((tile_h, tile_w, 3), dtype=np.uint8)
-
- if nir_id is not None:
- img = decoded[nir_id]["image"]
- tiles["nir"] = resize_tile(ensure_bgr(img, auto_level=auto_level), tile_w, tile_h)
- else:
- tiles["nir"] = np.zeros((tile_h, tile_w, 3), dtype=np.uint8)
-
- return tiles
-
-
-def tensor_tiles(tensor, tile_w, tile_h, auto_level=False):
- return {
- "rgb": resize_tile(tensor_rgb_to_bgr(tensor, auto_level=auto_level), tile_w, tile_h),
- "re": resize_tile(tensor_channel_to_bgr(tensor, 3, auto_level=auto_level), tile_w, tile_h),
- "nir": resize_tile(tensor_channel_to_bgr(tensor, 4, auto_level=auto_level), tile_w, tile_h),
- }
-
-
-def colorize_label_map(label_map, num_classes=None):
- label = np.asarray(label_map)
- if label.ndim == 3:
- label = np.argmax(label, axis=0)
- label = label.astype(np.int32)
-
- if num_classes is None:
- num_classes = int(max(1, label.max() + 1))
-
- # Paleta simples e estável. BGR.
- palette = np.array([
- [40, 40, 40],
- [60, 180, 60],
- [60, 60, 220],
- [220, 180, 60],
- [180, 60, 180],
- [180, 180, 60],
- [60, 180, 180],
- [220, 220, 220],
- ], dtype=np.uint8)
-
- out = palette[label % len(palette)]
- return out
-
-
-def try_extract_prediction_tiles(pred, target_w, target_h):
- """
- Tentativa genérica. Adapte aqui se o benchmark tiver nomes específicos das cabeças.
- Retorna até 3 tiles BGR: semântica/head0/head1.
- """
- if pred is None:
- blank = np.zeros((target_h, target_w, 3), dtype=np.uint8)
- return [blank, blank.copy(), blank.copy()], ["Pred vazio", "Head 1", "Head 2"]
-
- candidates = []
- names = []
-
- if isinstance(pred, dict):
- # nomes comuns
- for key in ("mask", "pred_mask", "class_map", "semantic", "semantic_mask", "segmentation"):
- if key in pred:
- candidates.append(pred[key])
- names.append(key)
-
- heads = pred.get("heads") or pred.get("head_outputs") or pred.get("predictions")
- if isinstance(heads, dict):
- for k, v in heads.items():
- candidates.append(v)
- names.append(str(k))
- elif isinstance(heads, (list, tuple)):
- for i, v in enumerate(heads):
- candidates.append(v)
- names.append(f"head_{i}")
- else:
- candidates.append(pred)
- names.append("prediction")
-
- tiles = []
- out_names = []
-
- for name, arr in zip(names, candidates):
- arr = np.asarray(arr)
-
- # remove batch se existir
- if arr.ndim == 4 and arr.shape[0] == 1:
- arr = arr[0]
-
- if arr.ndim == 3:
- # CHW logits/probs ou HWC RGB/probs
- if arr.shape[0] <= 32:
- vis = colorize_label_map(np.argmax(arr, axis=0), num_classes=arr.shape[0])
- elif arr.shape[2] in (1, 3):
- vis = ensure_bgr(arr[:, :, 0] if arr.shape[2] == 1 else arr, auto_level=True)
- else:
- vis = ensure_bgr(np.max(arr, axis=2), auto_level=True)
- elif arr.ndim == 2:
- # se parecer label map, colore; se parecer float, cinza auto-level
- if np.issubdtype(arr.dtype, np.integer) and int(np.max(arr)) <= 64:
- vis = colorize_label_map(arr)
- else:
- vis = ensure_bgr(arr, auto_level=True)
- elif arr.ndim == 1:
- # vetor de classe/score: desenha texto
- vis = np.zeros((target_h, target_w, 3), dtype=np.uint8)
- txt = np.array2string(arr[:8], precision=2, separator=", ")
- cv2.putText(vis, txt[:80], (10, target_h // 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINE_AA)
- else:
- continue
-
- vis = resize_tile(vis, target_w, target_h)
- tiles.append(vis)
- out_names.append(name)
-
- if len(tiles) >= 3:
- break
-
- while len(tiles) < 3:
- tiles.append(np.zeros((target_h, target_w, 3), dtype=np.uint8))
- out_names.append(f"pred_{len(tiles)}")
-
- return tiles[:3], out_names[:3]
-
-
-def try_run_model(model_svc, tensor):
- """
- Inferência real, igual ao benchmark científico.
- Mantém esta função isolada para adaptar fácil caso o retorno do modelo mude.
- """
- if model_svc is None:
- return None, "model_svc_none"
-
- if hasattr(model_svc, "infer_tensor_fast"):
- return model_svc.infer_tensor_fast(tensor, keep_probs=False), None
-
- if hasattr(model_svc, "infer"):
- return model_svc.infer(tensor), None
-
- return None, "model_svc_sem_infer"
-
-
-def build_grid(raw_tiles, final_tiles, pred_tiles=None, pred_names=None, tile_w=420, tile_h=260):
- rows = []
- row_defs = [
- ("RGB", "rgb"),
- ("RE", "re"),
- ("NIR", "nir"),
- ]
-
- for i, (label, key) in enumerate(row_defs):
- left = add_title(raw_tiles[key], f"RAW_BRUTO decoded {label}")
- mid = add_title(final_tiles[key], f"Tensor final {label}")
-
- cells = [left, mid]
-
- if pred_tiles is not None:
- name = pred_names[i] if pred_names and i < len(pred_names) else f"Pred {i}"
- cells.append(add_title(pred_tiles[i], name))
-
- # iguala altura após título
- h_min = min(c.shape[0] for c in cells)
- norm = [cv2.resize(c, (tile_w, h_min), interpolation=cv2.INTER_AREA) if c.shape[1] != tile_w or c.shape[0] != h_min else c for c in cells]
- rows.append(np.hstack(norm))
-
- return np.vstack(rows)
-
-
-def get_core_perf(client):
- for attr in ("raw_processor", "processor", "core", "raw_processor_core"):
- obj = getattr(client, attr, None)
- if obj is not None and getattr(obj, "last_fusion_result", None) is not None:
- return obj.last_fusion_result.get("perf", {}) or {}
- return {}
-
-
-
-# ============================================================
-# Estado compartilhado / workers assíncronos
-# ============================================================
-class SharedState:
- def __init__(self):
- self.lock = threading.Lock()
- self.running = True
- self.latest_decoded = None
- self.latest_meta = None
- self.latest_tensor = None
- self.latest_perf = {}
- self.latest_pred = None
- self.latest_model_warn = None
- self.latest_model_ms = 0.0
- self.latest_error = None
- self.tensor_fps = FpsMeter()
- self.model_fps = FpsMeter()
- self.preview_fps = FpsMeter()
- self.tensor_seq = 0
- self.model_seq = 0
-
- def stop(self):
- with self.lock:
- self.running = False
-
- def is_running(self):
- with self.lock:
- return bool(self.running)
-
-
-def tensor_worker(client, state, args):
- """
- Roda no talo: captura RAW_BRUTO, monta tensor final e atualiza cache.
- Não depende do FPS da janela.
- """
- while state.is_running():
- try:
- frame, meta, decoded = client.get_next_decoded(timeout=args.timeout)
- if not isinstance(frame, dict):
- raise RuntimeError(f"RAW_BRUTO esperado como dict. Veio {type(frame)}")
-
- tensor = client.build_infer_tensor_from_decoded(
- decoded=decoded,
- meta=meta,
- channels_expected=5,
- target_size=(args.width, args.height),
- )
- tensor = np.ascontiguousarray(tensor.astype(np.float32, copy=False))
- perf = get_core_perf(client)
- fps = state.tensor_fps.tick()
-
- with state.lock:
- state.latest_decoded = decoded
- state.latest_meta = meta
- state.latest_tensor = tensor
- state.latest_perf = dict(perf or {})
- state.tensor_seq += 1
- state.latest_error = None
-
- except Exception as e:
- with state.lock:
- state.latest_error = f"tensor_worker: {type(e).__name__}: {e}"
- time.sleep(0.02)
-
-
-def model_worker(model_svc, state, args):
- """
- Opcional: roda inferência no último tensor disponível.
- Não bloqueia o worker de tensor nem a janela.
- """
- last_seq = -1
-
- while state.is_running():
- with state.lock:
- tensor = None if state.latest_tensor is None else state.latest_tensor.copy()
- seq = state.tensor_seq
-
- if tensor is None or seq == last_seq:
- time.sleep(0.005)
- continue
-
- last_seq = seq
-
- try:
- t0_model = time.perf_counter()
- pred, warn = try_run_model(model_svc, tensor)
- model_ms = (time.perf_counter() - t0_model) * 1000.0
- if warn is None:
- state.model_fps.tick()
-
- with state.lock:
- state.latest_pred = pred
- state.latest_model_warn = warn
- state.latest_model_ms = float(model_ms)
- state.model_seq += 1
-
- except Exception as e:
- with state.lock:
- state.latest_model_warn = f"model_worker: {type(e).__name__}: {e}"
- time.sleep(0.02)
-
-
-def snapshot_state(state):
- """
- Copia referências do cache para desenhar. A janela só roda na cadência do preview.
- """
- with state.lock:
- return {
- "decoded": state.latest_decoded,
- "meta": state.latest_meta,
- "tensor": state.latest_tensor,
- "perf": dict(state.latest_perf or {}),
- "pred": state.latest_pred,
- "model_warn": state.latest_model_warn,
- "model_ms": state.latest_model_ms,
- "error": state.latest_error,
- "tensor_fps": state.tensor_fps.fps,
- "model_fps": state.model_fps.fps,
- "tensor_seq": state.tensor_seq,
- "model_seq": state.model_seq,
- }
-
-
-# ============================================================
-# Main loop assíncrono
-# ============================================================
-def main():
- ap = argparse.ArgumentParser(description="Preview assíncrono RAW_BRUTO decoded vs tensor final multispectral.")
- ap.add_argument("--module_params", default=r"C:\ZendionInc\agrobot_base\Python\OAK\datasets\oak-fcc-3\calibration\module_params.json")
- ap.add_argument("--width", type=int, default=1024, help="Largura final do tensor.")
- ap.add_argument("--height", type=int, default=640, help="Altura final do tensor.")
- ap.add_argument("--fps", type=float, default=40.0, help="FPS alvo da câmera.")
- ap.add_argument("--preview_fps", type=float, default=5.0, help="FPS da janela OpenCV apenas.")
- ap.add_argument("--timeout", type=float, default=2.0)
- ap.add_argument("--warmup", type=int, default=5)
- ap.add_argument("--mx_id", default=None)
- ap.add_argument("--display_scale", type=float, default=0.75)
- ap.add_argument("--tile_w", type=int, default=420)
- ap.add_argument("--tile_h", type=int, default=260)
- ap.add_argument("--auto_level_raw", action="store_true")
- ap.add_argument("--auto_level_tensor", action="store_true")
- ap.add_argument("--run_model", action="store_true", help="Roda inferência em thread separada usando o último tensor cacheado.")
- ap.add_argument("--model_config_json", default=None, help="JSON de configuração do modelo SegFormer, igual ao benchmark.")
- ap.add_argument("--warmup_model", type=int, default=3, help="Número de inferências dummy para aquecer o modelo.")
- ap.add_argument("--save_last", default=None)
- args = ap.parse_args()
-
- client = OakFcc3Client(
- width=args.width,
- height=args.height,
- fps=args.fps,
- frame_type="RAW_BRUTO",
- capture_mode="TRIPLE",
- raw_policy="require_triple",
- module_calibration_json=args.module_params,
- mx_id=args.mx_id,
- )
-
- model_svc = init_model_service(args) if args.run_model else None
-
- state = SharedState()
- last_panel = None
- last_warn_ts = 0.0
- last_seq_drawn = -1
-
- try:
- client.start(print_debug=True)
-
- for _ in range(max(0, int(args.warmup))):
- try:
- client.get_next_decoded(timeout=args.timeout)
- except Exception:
- pass
- time.sleep(0.03)
-
- tw = threading.Thread(target=tensor_worker, args=(client, state, args), daemon=True)
- tw.start()
-
- mw = None
- if args.run_model:
- mw = threading.Thread(target=model_worker, args=(model_svc, state, args), daemon=True)
- mw.start()
-
- min_period = 1.0 / max(float(args.preview_fps), 0.1)
- next_draw_ts = 0.0
-
- print("[INFO] Preview assíncrono iniciado. Pressione Q ou ESC para sair.")
- print("[INFO] Tensor FPS = geração real do tensor. Preview FPS = janela. Model FPS = inferência, se habilitada.")
-
- while True:
- now = time.perf_counter()
- if now < next_draw_ts:
- time.sleep(min(0.005, next_draw_ts - now))
- key = cv2.waitKey(1) & 0xFF
- if key in (27, ord('q'), ord('Q')):
- break
- continue
-
- next_draw_ts = now + min_period
- snap = snapshot_state(state)
-
- if snap["tensor"] is None or snap["decoded"] is None:
- blank = np.zeros((360, 900, 3), dtype=np.uint8)
- cv2.putText(blank, "Aguardando primeiro tensor...", (30, 180), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255,255,255), 2, cv2.LINE_AA)
- cv2.imshow("OAK RAW_BRUTO vs Tensor Final", blank)
- key = cv2.waitKey(1) & 0xFF
- if key in (27, ord('q'), ord('Q')):
- break
- continue
-
- # Desenha só usando o cache. Não captura nem monta tensor aqui.
- t_draw0 = time.perf_counter()
- raw_tiles = decoded_raw_tiles(
- snap["decoded"],
- tile_w=args.tile_w,
- tile_h=args.tile_h,
- auto_level=args.auto_level_raw,
- )
- final_tiles = tensor_tiles(
- snap["tensor"],
- tile_w=args.tile_w,
- tile_h=args.tile_h,
- auto_level=args.auto_level_tensor,
- )
-
- pred_tiles = None
- pred_names = None
- if args.run_model:
- pred_tiles, pred_names = try_extract_prediction_tiles(
- snap["pred"],
- target_w=args.tile_w,
- target_h=args.tile_h,
- )
- if snap["model_warn"]:
- tnow = time.time()
- if tnow - last_warn_ts > 2.0:
- print(f"[WARN][MODEL] {snap['model_warn']}")
- last_warn_ts = tnow
-
- panel = build_grid(
- raw_tiles=raw_tiles,
- final_tiles=final_tiles,
- pred_tiles=pred_tiles,
- pred_names=pred_names,
- tile_w=args.tile_w,
- tile_h=args.tile_h,
- )
-
- preview_fps = state.preview_fps.tick()
- draw_ms = (time.perf_counter() - t_draw0) * 1000.0
- perf = snap["perf"]
- meta = snap["meta"] or {}
- tensor_seq = int(snap["tensor_seq"])
- dropped_for_preview = max(0, tensor_seq - last_seq_drawn - 1) if last_seq_drawn >= 0 else 0
- last_seq_drawn = tensor_seq
-
- hud = [
- f"Preview FPS: {preview_fps:.1f} | Tensor FPS: {snap['tensor_fps']:.1f} | Model FPS: {snap['model_fps']:.1f} | infer={snap.get('model_ms', 0.0):.1f}ms",
- f"draw={draw_ms:.1f}ms flat={perf.get('flat_ms', 0):.1f} warp={perf.get('warp_total_ms', 0):.1f} crop={perf.get('crop_resize_ms', 0):.1f} fuse={perf.get('total_ms', 0):.1f}",
- f"seq={tensor_seq} skipped_preview={dropped_for_preview} frame_type={meta.get('frame_type')} run_model={args.run_model}",
- ]
- if snap["error"]:
- hud.append(str(snap["error"])[:120])
-
- panel = add_hud(panel, hud)
- last_panel = panel
-
- disp = panel
- if args.display_scale and abs(args.display_scale - 1.0) > 1e-6:
- disp = cv2.resize(
- disp,
- (max(1, int(disp.shape[1] * args.display_scale)), max(1, int(disp.shape[0] * args.display_scale))),
- interpolation=cv2.INTER_AREA,
- )
-
- cv2.imshow("OAK RAW_BRUTO vs Tensor Final", disp)
- key = cv2.waitKey(1) & 0xFF
- if key in (27, ord('q'), ord('Q')):
- break
-
- finally:
- state.stop()
- time.sleep(0.05)
-
- if args.save_last and last_panel is not None:
- out_path = Path(args.save_last)
- out_path.parent.mkdir(parents=True, exist_ok=True)
- cv2.imwrite(str(out_path), last_panel)
- print(f"[SAVE] {out_path}")
-
- try:
- client.stop()
- except Exception:
- pass
- cv2.destroyAllWindows()
-
-
-if __name__ == "__main__":
- main()
diff --git a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py
index 3cb60e76c..ba9dc5406 100644
--- a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py
+++ b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_client.py
@@ -46,6 +46,7 @@ class OakFcc3Client:
mx_id=None,
imu_modo="rotation_vector",
imu_freq_hz=200,
+ evaluate_quality=True,
**kwargs,
):
self.width = width
@@ -63,6 +64,16 @@ class OakFcc3Client:
self.imu_modo = str(imu_modo).strip().lower()
self.imu_freq_hz = int(imu_freq_hz)
+ # Auditoria radiométrica completa do Raw5.
+ #
+ # True mantém o comportamento histórico e é útil para captura científica,
+ # normalize/auditoria e ferramentas offline.
+ #
+ # No runtime em tempo real deve ficar False: evaluate_frame_quality()
+ # calcula estatísticas/percentis pesados e não faz parte da montagem
+ # necessária para a inferência.
+ self.evaluate_quality = bool(evaluate_quality)
+
self.mx_id = str(mx_id) if mx_id else None
self.svc = OakFcc3Service(
@@ -327,8 +338,33 @@ class OakFcc3Client:
target_size=target_size,
)
- def build_infer_tensor_from_decoded(self, decoded, meta, channels_expected, target_size=None):
+ def build_infer_tensor_from_decoded(
+ self,
+ decoded,
+ meta,
+ channels_expected,
+ target_size=None,
+ evaluate_quality=None,
+ ):
+ """
+ Monta o Raw5 físico a partir das câmeras já decodificadas.
+
+ evaluate_quality:
+ - None -> usa self.evaluate_quality
+ - True -> executa evaluate_frame_quality() e atualiza
+ core.last_frame_quality_result
+ - False -> não executa a auditoria pesada e limpa
+ core.last_frame_quality_result
+
+ A flag altera somente a auditoria de qualidade. Não altera decode,
+ radiometria, flat-field, homografia, crop/resize ou patch normalization.
+ """
channels_expected = self._validate_physical_channel_count(channels_expected)
+
+ if evaluate_quality is None:
+ evaluate_quality = self.evaluate_quality
+ evaluate_quality = bool(evaluate_quality)
+
tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.core.resize_tensor_chw(tensor, target_size=target_size)
@@ -338,7 +374,12 @@ class OakFcc3Client:
if bool(patch_cfg.get("enabled", False)):
tensor = self.core.apply_patch_normalization_to_tensor(tensor)
- self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
+ if evaluate_quality:
+ self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
+ else:
+ # Evita deixar um resultado antigo parecer referente ao frame atual.
+ self.core.last_frame_quality_result = None
+
return tensor
def decode_stream_cameras(self, frame, meta):
@@ -382,12 +423,19 @@ class OakFcc3Client:
tensor = np.transpose(rgb01.astype(np.float32), (2, 0, 1))
return np.ascontiguousarray(tensor.astype(np.float32, copy=False))
- def build_multispec_tensor(self, decoded, meta=None, target_size=None):
+ def build_multispec_tensor(
+ self,
+ decoded,
+ meta=None,
+ target_size=None,
+ evaluate_quality=None,
+ ):
tensor = self.build_infer_tensor_from_decoded(
decoded=decoded,
meta=meta,
channels_expected=5,
target_size=target_size,
+ evaluate_quality=evaluate_quality,
)
return np.ascontiguousarray(tensor.astype(np.float32, copy=False))
diff --git a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py
index 4544dcebf..ad04a1a47 100644
--- a/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py
+++ b/Python/OAK/datasets/oak-fcc-3/core/oak_fcc3_manager.py
@@ -9,6 +9,8 @@ import cv2
import depthai as dai
import numpy as np
+from itertools import product
+
class OakFcc3Manager:
"""
@@ -43,6 +45,8 @@ class OakFcc3Manager:
raw_policy="allow_single",
roles=None,
sync_mode="best",
+ hardware_sync_enabled=True,
+ frame_sync_master="CAM_A",
sync_tolerance_ms=12.0,
buffer_size=8,
only_camera=None,
@@ -52,6 +56,9 @@ class OakFcc3Manager:
imu_modo="rotation_vector",
imu_freq_hz=200,
):
+ self.hardware_sync_enabled = hardware_sync_enabled
+ self.frame_sync_master = frame_sync_master
+
self.fps = fps
# Para compatibilidade, mantemos width/height.
@@ -432,6 +439,49 @@ class OakFcc3Manager:
# Pipeline creation, MULTISPEC aligned on OAK
# ============================================================
+ def _aplicar_frame_sync(self, cam, cam_id):
+ cam_id_normalizado = str(cam_id).strip().upper()
+ master = str(self.frame_sync_master).strip().upper()
+
+ if not self.hardware_sync_enabled:
+ print(
+ f"[OAK FSYNC] cam={cam_id_normalizado} "
+ f"habilitado=False modo=DISABLED"
+ )
+ return
+
+ cameras_validas = {"CAM_A", "CAM_B", "CAM_C"}
+
+ if master not in cameras_validas:
+ raise ValueError(
+ f"frame_sync_master inválido: {self.frame_sync_master}. "
+ f"Esperado: CAM_A, CAM_B ou CAM_C."
+ )
+
+ if cam_id_normalizado not in cameras_validas:
+ print(
+ f"[OAK FSYNC] Câmera ignorada: "
+ f"cam_id={cam_id_normalizado}"
+ )
+ return
+
+ if cam_id_normalizado == master:
+ modo = dai.CameraControl.FrameSyncMode.OUTPUT
+ nome_modo = "OUTPUT"
+ else:
+ modo = dai.CameraControl.FrameSyncMode.INPUT
+ nome_modo = "INPUT"
+
+ cam.initialControl.setFrameSyncMode(modo)
+
+ print(
+ f"[OAK FSYNC] "
+ f"cam={cam_id_normalizado} "
+ f"master={master} "
+ f"habilitado=True "
+ f"modo={nome_modo}"
+ )
+
def _create_color_camera_multispec(self, socket):
cam = self.pipeline.create(dai.node.ColorCamera)
cam.setBoardSocket(socket)
@@ -591,6 +641,7 @@ class OakFcc3Manager:
raise RuntimeError(f"Role não suportada no MULTISPEC: cam_id={cam_id}, role={role}")
self.apply_initial_camera_controls_to_node(cam, cam_id)
+ self._aplicar_frame_sync(cam, cam_id)
xin_ctrl = self.pipeline.create(dai.node.XLinkIn)
xin_ctrl.setStreamName(f"{cam_id}_ctrl")
@@ -871,6 +922,7 @@ class OakFcc3Manager:
)
self.apply_initial_camera_controls_to_node(cam, socket_name)
+ self._aplicar_frame_sync(cam, socket_name)
xin_ctrl = self.pipeline.create(dai.node.XLinkIn)
xin_ctrl.setStreamName(f"{socket_name}_ctrl")
@@ -1241,6 +1293,41 @@ class OakFcc3Manager:
f"Tolerância atual={self.sync_tolerance_ms} ms."
)
+ def _encontrar_melhor_tripleta(self, required_cam_ids):
+ listas = [
+ list(self.buffers[cam_id])
+ for cam_id in required_cam_ids
+ ]
+
+ melhor_selecao = None
+ melhor_score = None
+
+ for combinacao in product(*listas):
+ timestamps = [
+ item["timestamp"]
+ for item in combinacao
+ ]
+
+ menor_ts = min(timestamps)
+ maior_ts = max(timestamps)
+ spread_ms = (maior_ts - menor_ts) * 1000.0
+
+ # Primeiro prioriza menor dispersão.
+ # Em empate, prefere o pacote mais recente.
+ score = (
+ spread_ms,
+ -menor_ts,
+ )
+
+ if melhor_score is None or score < melhor_score:
+ melhor_score = score
+ melhor_selecao = {
+ cam_id: item
+ for cam_id, item in zip(required_cam_ids, combinacao)
+ }
+
+ return melhor_selecao
+
def _extract_frame_controls(self, msg):
controls = {
@@ -1330,9 +1417,19 @@ class OakFcc3Manager:
t0_ts = self._cap_now_ms()
try:
- ts = msg.getTimestamp().total_seconds()
+ ts_start = msg.getTimestampDevice(
+ dai.CameraExposureOffset.START
+ ).total_seconds()
+
+ ts_end = msg.getTimestampDevice(
+ dai.CameraExposureOffset.END
+ ).total_seconds()
+
except Exception:
- ts = time.time()
+ ts_start = msg.getTimestamp().total_seconds()
+ ts_end = ts_start
+
+ ts = ts_start
if perf is not None:
perf["drain_get_timestamp_ms"] += self._cap_now_ms() - t0_ts
@@ -1395,7 +1492,9 @@ class OakFcc3Manager:
self.buffers[cam_id].append({
"frame": frame,
- "timestamp": ts,
+ "timestamp": ts_start,
+ "timestamp_start": ts_start,
+ "timestamp_end": ts_end,
"controls": frame_controls,
})
@@ -1420,34 +1519,25 @@ class OakFcc3Manager:
def _try_get_synced_packet(self, perf=None):
required_cam_ids = self._get_required_cam_ids()
+ if not required_cam_ids:
+ if perf is not None:
+ perf["wait_reason"] = "no_required_cameras"
+ return None
+
+ # Todas as câmeras precisam ter pelo menos um frame disponível.
for cam_id in required_cam_ids:
if cam_id not in self.buffers or len(self.buffers[cam_id]) == 0:
if perf is not None:
perf["wait_reason"] = f"empty_buffer:{cam_id}"
return None
- ref_cam_id = min(required_cam_ids, key=lambda cid: len(self.buffers[cid]))
- ref_item = self.buffers[ref_cam_id][0]
- ref_ts = ref_item["timestamp"]
+ # Procura a melhor combinação entre todos os frames disponíveis.
+ selected = self._encontrar_melhor_tripleta(required_cam_ids)
- selected = {}
-
- for cam_id in required_cam_ids:
- best_item = None
- best_dt = None
-
- for item in self.buffers[cam_id]:
- dt = abs(item["timestamp"] - ref_ts)
- if best_dt is None or dt < best_dt:
- best_dt = dt
- best_item = item
-
- if best_item is None:
- if perf is not None:
- perf["wait_reason"] = f"no_best_item:{cam_id}"
- return None
-
- selected[cam_id] = best_item
+ if not selected or len(selected) != len(required_cam_ids):
+ if perf is not None:
+ perf["wait_reason"] = "no_valid_selection"
+ return None
timestamps = {
cam_id: item["timestamp"]
@@ -1459,44 +1549,80 @@ class OakFcc3Manager:
for cam_id, item in selected.items()
}
+ ts_values = list(timestamps.values())
+
+ sync_dt_ms = (
+ (max(ts_values) - min(ts_values)) * 1000.0
+ if len(ts_values) >= 2
+ else 0.0
+ )
+
+ sync_ok = sync_dt_ms <= self.sync_tolerance_ms
+
if perf is not None:
- perf["selected_ts_by_cam"] = {cam_id: float(ts) for cam_id, ts in timestamps.items()}
+ perf["selected_ts_by_cam"] = {
+ cam_id: float(ts)
+ for cam_id, ts in timestamps.items()
+ }
+
perf["selected_seq_by_cam"] = {
cam_id: item.get("controls", {}).get("sequence_num")
for cam_id, item in selected.items()
}
- ts_values = list(timestamps.values())
- sync_dt_ms = (max(ts_values) - min(ts_values)) * 1000.0 if len(ts_values) >= 2 else 0.0
- sync_ok = sync_dt_ms <= self.sync_tolerance_ms
+ perf["sync_dt_ms"] = float(sync_dt_ms)
+ perf["sync_ok"] = bool(sync_ok)
+
+ modo_sync = str(self.sync_mode).strip().lower()
+
+ # No modo estrito, nunca entrega uma tripleta fora da tolerância.
+ if not sync_ok and modo_sync == "strict":
+ #Remove o frame globalmente mais antigo entre as cabeças
+ #dos buffers. Frames futuros somente estarão mais distantes
+ #desse frame, então ele não conseguirá formar uma combinação
+ #melhor posteriormente.
+ oldest_cam_id = min(
+ required_cam_ids,
+ key=lambda cam_id: self.buffers[cam_id][0]["timestamp"]
+ )
+
+ dropped_item = self.buffers[oldest_cam_id].popleft()
- if not sync_ok and self.sync_mode == "strict":
- oldest_cam_id = min(timestamps, key=timestamps.get)
- if len(self.buffers[oldest_cam_id]) > 0:
- self.buffers[oldest_cam_id].popleft()
if perf is not None:
perf["wait_reason"] = f"strict_drop_oldest:{oldest_cam_id}"
- perf["sync_dt_ms"] = float(sync_dt_ms)
- perf["sync_ok"] = False
+ perf["dropped_timestamp"] = float(dropped_item["timestamp"])
+
return None
+ # Em best/best_effort, entrega a melhor combinação disponível,
+ # mesmo quando estiver fora da tolerância.
frames = {
cam_id: item["frame"]
for cam_id, item in selected.items()
}
+ # Consome todos os frames anteriores e o próprio frame selecionado.
for cam_id, used_item in selected.items():
- while len(self.buffers[cam_id]) > 0:
+ while self.buffers[cam_id]:
item = self.buffers[cam_id].popleft()
+
if item is used_item:
break
if perf is not None:
- perf["wait_reason"] = "synced_selected"
- perf["sync_dt_ms"] = float(sync_dt_ms)
- perf["sync_ok"] = bool(sync_ok)
+ perf["wait_reason"] = (
+ "synced_selected"
+ if sync_ok
+ else "best_effort_selected_outside_tolerance"
+ )
- return frames, timestamps, sync_dt_ms, sync_ok, frame_controls
+ return (
+ frames,
+ timestamps,
+ sync_dt_ms,
+ sync_ok,
+ frame_controls,
+ )
def _get_available_cam_ids_ordered(self):
role_order = ["rgb", "re", "nir"]
diff --git a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py
index c105e7b8f..3a2ba9d24 100644
--- a/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py
+++ b/Python/OAK/datasets/oak-fcc-3/core/raw_processor_core.py
@@ -1,9 +1,42 @@
+"""
+RawProcessorCore - Production
+=============================
+
+Canonical multispectral preprocessing core for training and inference.
+
+Product contract:
+- CAM_A RGB: OV9782 1280x800 OR AR0234 1920x1200
+- CAM_B RE : OV9282 1280x800
+- CAM_C NIR: OV9282 1280x800
+- Physical output tensor: [R, G, B, RE, NIR]
+
+Production revision highlights:
+- mixed-resolution homography with independent source/reference spaces;
+- OpenCV pixel-center-aware geometry scaling;
+- native RE/NIR warp without pre-resizing to RGB;
+- strict validation of final module_params and per-frame hardware metadata;
+- product RAW_BRUTO fail-closed input contract;
+- radiometric frame-control fallbacks for saved datasets;
+- single-resample fusion directly to requested tensor size;
+- intrinsics contract loading/validation with runtime undistort gated off;
+- faster Flat-Field saturation guard using fused Numba kernel;
+- lower-allocation in-place Flat-Field path;
+- faster frame-quality statistics while retaining exact critical percentages.
+
+This module intentionally keeps legacy behavior available when a non-product
+module_params is loaded. The strict fail-closed behavior is enabled only for
+module_params generated by the production assembler.
+"""
+
+RAW_PROCESSOR_CORE_VERSION = "production_v1_2026_08_24"
+
import json
import os
import time
import cv2
import numpy as np
import math
+from copy import deepcopy
from typing import Optional
@@ -215,6 +248,97 @@ if _HAS_NUMBA:
out[y, x] = v
+ @_numba.njit(cache=True, fastmath=True, parallel=True)
+ def _apply_flat_gain_guard_numba(
+ base,
+ gain,
+ saturation_mask,
+ out,
+ height,
+ width,
+ strength,
+ gain_min_runtime,
+ gain_max_runtime,
+ sat_soft_start,
+ sat_hard,
+ clip_output,
+ mode_code,
+ ):
+ """
+ Flat-field com saturation guard em uma única passada.
+
+ mode_code:
+ 1 = fade_strength
+ 2 = skip
+ 0 = simple
+ """
+ denom = sat_hard - sat_soft_start
+ if denom < 1e-6:
+ denom = 1e-6
+
+ for y in _numba.prange(height):
+ for x in range(width):
+ v = float(base[y, x])
+ g = float(gain[y, x])
+
+ if mode_code == 1:
+ t = (v - sat_soft_start) / denom
+
+ if t < 0.0:
+ t = 0.0
+ elif t > 1.0:
+ t = 1.0
+
+ local_strength = 1.0 - t
+
+ if saturation_mask[y, x]:
+ local_strength = 0.0
+
+ gain_eff = 1.0 + (
+ strength
+ * local_strength
+ * (g - 1.0)
+ )
+
+ if gain_eff < gain_min_runtime:
+ gain_eff = gain_min_runtime
+ elif gain_eff > gain_max_runtime:
+ gain_eff = gain_max_runtime
+
+ value = v * gain_eff
+
+ elif mode_code == 2:
+ if saturation_mask[y, x]:
+ value = v
+ else:
+ gain_eff = 1.0 + strength * (g - 1.0)
+
+ if gain_eff < gain_min_runtime:
+ gain_eff = gain_min_runtime
+ elif gain_eff > gain_max_runtime:
+ gain_eff = gain_max_runtime
+
+ value = v * gain_eff
+
+ else:
+ gain_eff = 1.0 + strength * (g - 1.0)
+
+ if gain_eff < gain_min_runtime:
+ gain_eff = gain_min_runtime
+ elif gain_eff > gain_max_runtime:
+ gain_eff = gain_max_runtime
+
+ value = v * gain_eff
+
+ if clip_output:
+ if value < 0.0:
+ value = 0.0
+ elif value > 1.0:
+ value = 1.0
+
+ out[y, x] = value
+
+
@_numba.njit(cache=True, fastmath=True, parallel=True)
def _apply_tensor_flat_gain_chw_numba(tensor, gain, channels, height, width, clip_output):
for c in _numba.prange(channels):
@@ -389,6 +513,7 @@ class RawProcessorCore:
"save_debug": True,
}
self.last_radiometric_normalization_result = None
+ self.last_flatfield_result = None
self.radiometric_config = {}
self.patch_normalization_config = {
@@ -440,37 +565,160 @@ class RawProcessorCore:
self.last_fusion_result = None
self.camera_settings = {}
+ # Contrato de produto carregado pelo assembler final.
+ self.strict_product_contract = False
+ self.module_params_schema = None
+ self.sensor_size_by_role = {}
+ self.camera_hardware = {}
+ self.intrinsics_config = {
+ "enabled": False,
+ "calibration_space": "native_stream_no_external_undistort",
+ "cameras": {},
+ "runtime_undistort": {"enabled": False},
+ }
+ self.calibration_provenance = {}
+ self.assembly_metadata = {}
+ self._last_frame_controls_source = None
+
self._flatfield_runtime_cache = {}
self.last_decode_perf = {}
self._last_decode_perf_log_ts = 0.0
self.last_rgb_enhancement_result = None
+ # QC: percentis são diagnóstico, não parte do tensor.
+ # Mantemos saturação/dark/range exatos e limitamos apenas estatísticas
+ # descritivas para não gastar dezenas/centenas de ms por frame.
+ self.quality_stats_max_samples = 65536
+
if calibration_json_path:
self.load_config_json(calibration_json_path)
def warmup_numba_raw10_decode(self):
+ """
+ Warmup sensor-aware.
+
+ Numba especializa por dtype/layout, não por shape, mas usar os tamanhos
+ reais também aquece alocações e o caminho OpenCV do RGB correto.
+ """
if not _HAS_NUMBA:
return {"ok": False, "reason": "numba_not_available"}
- w, h = 1280, 800
- packed_w = int(np.ceil(w * 10 / 8))
- dummy = np.zeros((h, packed_w), dtype=np.uint8)
+ sizes = getattr(self, "sensor_size_by_role", {}) or {}
- _ = self._raw10_mono_to_float01_aggressive(dummy, w, h, 10)
- _ = self._raw10_rgb_bayer_planes_to_rgb_float01_aggressive(dummy, w, h, "RGGB", 10)
- _ = self._raw10_to_raw16_aggressive(dummy, w, h)
+ rgb_size = sizes.get("rgb") or [
+ int(self.sensor_width),
+ int(self.sensor_height),
+ ]
+ re_size = sizes.get("re") or [1280, 800]
- raw16 = self._raw10_to_raw16_aggressive(dummy, w, h)
- rgb16 = cv2.cvtColor(raw16, cv2.COLOR_BayerRG2RGB)
+ rgb_w, rgb_h = int(rgb_size[0]), int(rgb_size[1])
+ spec_w, spec_h = int(re_size[0]), int(re_size[1])
+
+ rgb_packed_w = self._raw10_expected_packed_width_fast(rgb_w)
+ spec_packed_w = self._raw10_expected_packed_width_fast(spec_w)
+
+ dummy_rgb = np.zeros((rgb_h, rgb_packed_w), dtype=np.uint8)
+ dummy_spec = np.zeros((spec_h, spec_packed_w), dtype=np.uint8)
+
+ _ = self._raw10_mono_to_float01_aggressive(
+ dummy_spec,
+ spec_w,
+ spec_h,
+ 10,
+ )
+
+ _ = self._raw10_rgb_bayer_planes_to_rgb_float01_aggressive(
+ dummy_rgb,
+ rgb_w,
+ rgb_h,
+ self.bayer_pattern,
+ 10,
+ )
+
+ raw16 = self._raw10_to_raw16_aggressive(
+ dummy_rgb,
+ rgb_w,
+ rgb_h,
+ )
+
+ cv_code, _ = self._get_bayer_cv2_code(
+ bayer_pattern=self.bayer_pattern,
+ algorithm=str(
+ (self.rgb_processing_config or {}).get(
+ "demosaic_algorithm",
+ "ea",
+ )
+ ),
+ )
+
+ rgb16 = cv2.cvtColor(raw16, cv_code)
_ = self._rgb16_to_float32_gain_clip_aggressive(rgb16, 10)
- dummy_tensor = np.zeros((5, 640, 1024), dtype=np.float32)
- dummy_gain = np.ones((5, 640, 1024), dtype=np.float32)
- _apply_tensor_flat_gain_chw_numba(dummy_tensor, dummy_gain, 5, 640, 1024, True)
+ # Warmup do flat final/Numba com target real, se houver.
+ target = (self.fusion_config or {}).get("target_size") or [
+ max(1, rgb_w // 2),
+ max(1, rgb_h // 2),
+ ]
+ tw, th = int(target[0]), int(target[1])
- return {"ok": True, "backend": "numba", "shape": [h, packed_w]}
+ dummy_tensor = np.zeros((5, th, tw), dtype=np.float32)
+ dummy_gain = np.ones((5, th, tw), dtype=np.float32)
+
+ _apply_tensor_flat_gain_chw_numba(
+ dummy_tensor,
+ dummy_gain,
+ 5,
+ th,
+ tw,
+ True,
+ )
+
+ # Warmup do kernel fused do Flat-Field com saturation guard.
+ kh = min(th, 64)
+ kw = min(tw, 64)
+
+ dummy_base = np.zeros(
+ (kh, kw),
+ dtype=np.float32,
+ )
+ dummy_gain_ch = np.ones(
+ (kh, kw),
+ dtype=np.float32,
+ )
+ dummy_mask = np.zeros(
+ (kh, kw),
+ dtype=np.bool_,
+ )
+ dummy_out = np.empty(
+ (kh, kw),
+ dtype=np.float32,
+ )
+
+ _apply_flat_gain_guard_numba(
+ dummy_base,
+ dummy_gain_ch,
+ dummy_mask,
+ dummy_out,
+ kh,
+ kw,
+ 1.0,
+ 0.75,
+ 1.35,
+ 0.88,
+ 0.97,
+ True,
+ 1,
+ )
+
+ return {
+ "ok": True,
+ "backend": "numba",
+ "rgb_native": [rgb_w, rgb_h],
+ "spectral_native": [spec_w, spec_h],
+ "target_size": [tw, th],
+ }
def unpack_raw10_packed(
self,
@@ -910,8 +1158,19 @@ class RawProcessorCore:
output_dtype: str = "float32",
bit_depth: int = 10,
) -> np.ndarray:
+ """
+ RAW Bayer uint16 -> RGB CHW usando exatamente o rgb_processing atual.
+
+ Suporta:
+ linear_demosaic
+ linear_demosaic_half
+ bayer_planes
+ """
rgb_mode = str(
- (getattr(self, "rgb_processing_config", {}) or {}).get("mode", "linear_demosaic")
+ (getattr(self, "rgb_processing_config", {}) or {}).get(
+ "mode",
+ "linear_demosaic",
+ )
).lower()
if rgb_mode in ("linear_demosaic", "demosaic", "full_res"):
@@ -920,6 +1179,23 @@ class RawProcessorCore:
bit_depth=bit_depth,
)
+ elif rgb_mode in (
+ "linear_demosaic_half",
+ "demosaic_half",
+ "full_demosaic_half",
+ ):
+ r, g, b = self.demosaic_raw16_to_rgb_linear(
+ raw16,
+ bit_depth=bit_depth,
+ )
+
+ h, w = r.shape[:2]
+ size = (w // 2, h // 2)
+
+ r = cv2.resize(r, size, interpolation=cv2.INTER_AREA)
+ g = cv2.resize(g, size, interpolation=cv2.INTER_AREA)
+ b = cv2.resize(b, size, interpolation=cv2.INTER_AREA)
+
elif rgb_mode in ("bayer_planes", "bayer", "half_res"):
r, g, b = self.bayer_planes_to_rgb_linear(
raw16,
@@ -927,7 +1203,9 @@ class RawProcessorCore:
)
else:
- raise ValueError(f"rgb_processing.mode inválido: {rgb_mode}")
+ raise ValueError(
+ f"rgb_processing.mode inválido: {rgb_mode}"
+ )
rgb_cal = getattr(self, "rgb_calibration", {}) or {}
if rgb_cal.get("enabled", False):
@@ -936,30 +1214,58 @@ class RawProcessorCore:
g = g * float(gains.get("G", 1.0))
b = b * float(gains.get("B", 1.0))
- rgb_hwc = np.stack([r, g, b], axis=2).astype(np.float32)
- np.clip(rgb_hwc, 0.0, 1.0, out=rgb_hwc)
+ rgb_hwc = np.stack(
+ [r, g, b],
+ axis=2,
+ ).astype(np.float32)
+
+ np.clip(
+ rgb_hwc,
+ 0.0,
+ 1.0,
+ out=rgb_hwc,
+ )
- # Pós-processamento RGB opcional.
- # Aplicado aqui para o caminho de treino/offline que chama build_training_rgb().
rgb_hwc = self.apply_rgb_enhancement_to_hwc_float01(
rgb_hwc,
stage="build_training_rgb.after_decode",
source_kind="raw",
)
- chw = np.transpose(rgb_hwc, (2, 0, 1)).astype(np.float32, copy=False)
- np.clip(chw, 0.0, 1.0, out=chw)
+ chw = np.transpose(
+ rgb_hwc,
+ (2, 0, 1),
+ ).astype(np.float32, copy=False)
+
+ np.clip(
+ chw,
+ 0.0,
+ 1.0,
+ out=chw,
+ )
if output_dtype == "float32":
return chw
if output_dtype == "uint8":
- return (chw * 255.0).clip(0, 255).astype(np.uint8)
+ return (
+ chw * 255.0
+ ).clip(
+ 0,
+ 255,
+ ).astype(np.uint8)
if output_dtype == "uint16":
- return (chw * 65535.0).clip(0, 65535).astype(np.uint16)
+ return (
+ chw * 65535.0
+ ).clip(
+ 0,
+ 65535,
+ ).astype(np.uint16)
- raise ValueError(f"output_dtype não suportado: {output_dtype}")
+ raise ValueError(
+ f"output_dtype não suportado: {output_dtype}"
+ )
def _channel_names_from_decoded(self, decoded):
names = ["R", "G", "B"]
@@ -1049,38 +1355,106 @@ class RawProcessorCore:
return decoded
- def build_multispectral_tensor(self, bins_data, bins_meta, target_size=None):
- decoded = self.decode_bins_cameras(bins_data, bins_meta)
+ def build_multispectral_tensor(
+ self,
+ bins_data,
+ bins_meta,
+ target_size=None,
+ capture_meta=None,
+ ):
+ """
+ Caminho offline/legado para dados já carregados.
- rgb_cam_id = self._find_cam_by_role(decoded, "rgb")
+ Em module_params de produção, radiometric_normalization é obrigatória.
+ Portanto capture_meta deve acompanhar o sample; caso contrário este método
+ recusará gerar um tensor silenciosamente diferente do runtime.
+
+ Para datasets RAW_BRUTO novos, prefira build_infer_tensor_from_stream().
+ """
+ decoded = self.decode_bins_cameras(
+ bins_data,
+ bins_meta,
+ )
+
+ rgb_cam_id = self._find_cam_by_role(
+ decoded,
+ "rgb",
+ )
if rgb_cam_id is None:
raise RuntimeError("RGB obrigatório")
- channel_names = self._channel_names_from_decoded(decoded)
+ channel_names = self._channel_names_from_decoded(
+ decoded
+ )
+
+ if (
+ getattr(self, "strict_product_contract", False)
+ and bool(
+ (self.radiometric_normalization_config or {}).get(
+ "enabled",
+ False,
+ )
+ )
+ and not isinstance(capture_meta, dict)
+ ):
+ raise RuntimeError(
+ "build_multispectral_tensor() em modo produto exige capture_meta "
+ "com controles reais da captura. Prefira build_infer_tensor_from_stream()."
+ )
tensor = self.fuse_multispec_cameras(
decoded,
- meta=None,
+ meta=capture_meta,
channels_expected=len(channel_names),
+ target_size=target_size,
)
- tensor = self.resize_tensor_chw(tensor, target_size=target_size)
+ if bool(
+ (self.patch_normalization_config or {}).get(
+ "enabled",
+ False,
+ )
+ ):
+ tensor = self.apply_patch_normalization_to_tensor(
+ tensor
+ )
- if bool((self.patch_normalization_config or {}).get("enabled", False)):
- tensor = self.apply_patch_normalization_to_tensor(tensor)
-
- self.last_frame_quality_result = self.evaluate_frame_quality(tensor)
+ self.last_frame_quality_result = self.evaluate_frame_quality(
+ tensor
+ )
return tensor, channel_names
def build_infer_tensor_from_stream(self, frame, meta, channels_expected, target_size=None):
channels_expected = self._validate_physical_channel_count(channels_expected)
- frame_type = meta.get("frame_type")
+ stream_meta = (
+ meta.get("stream_meta", {})
+ if isinstance(meta, dict)
+ else {}
+ ) or {}
+ frame_type = (
+ meta.get("frame_type")
+ if isinstance(meta, dict)
+ else None
+ ) or stream_meta.get("frame_type")
+
+ if (
+ getattr(self, "strict_product_contract", False)
+ and frame_type != "RAW_BRUTO"
+ ):
+ raise RuntimeError(
+ "module_params de produção aceita somente frame_type='RAW_BRUTO' "
+ "neste core. RGB/MULTISPEC pré-processado contornaria calibrações."
+ )
if frame_type == "RAW_BRUTO":
decoded = self.decode_stream_cameras(frame, meta)
- tensor = self.fuse_multispec_cameras(decoded, meta, channels_expected)
- tensor = self.resize_tensor_chw(tensor, target_size=target_size)
+ tensor = self.fuse_multispec_cameras(
+ decoded,
+ meta,
+ channels_expected,
+ target_size=target_size,
+ )
# Sem cartões neste modo novo.
# patch_normalization deve ficar desligado no JSON.
@@ -1125,42 +1499,92 @@ class RawProcessorCore:
if not isinstance(frame, dict):
raise RuntimeError("RAW_BRUTO esperado como dict de câmeras no modo multi")
- camera_frames = meta.get("camera_frames", {}) or {}
- camera_info = meta.get("camera_info", {}) or {}
+ if not isinstance(meta, dict):
+ raise RuntimeError("RAW_BRUTO exige meta dict.")
+
+ self._validate_stream_camera_contract(frame, meta)
+
+ stream_meta = (
+ meta.get("stream_meta", {})
+ if isinstance(meta.get("stream_meta"), dict)
+ else {}
+ )
+ camera_frames = (
+ meta.get("camera_frames", {})
+ or stream_meta.get("camera_frames", {})
+ or {}
+ )
+ camera_info = (
+ meta.get("camera_info", {})
+ or stream_meta.get("camera_info", {})
+ or {}
+ )
decoded = {}
rgb_mode = str(
- (getattr(self, "rgb_processing_config", {}) or {}).get("mode", "linear_demosaic")
+ (getattr(self, "rgb_processing_config", {}) or {}).get(
+ "mode",
+ "linear_demosaic",
+ )
).lower()
for cam_id, data in frame.items():
- cam_meta = camera_frames.get(cam_id) or camera_info.get(cam_id) or {}
+ cam_meta = {}
+ if isinstance(camera_info.get(cam_id), dict):
+ cam_meta.update(camera_info.get(cam_id) or {})
+ if isinstance(camera_frames.get(cam_id), dict):
+ cam_meta.update(camera_frames.get(cam_id) or {})
role = str(cam_meta.get("role", "")).lower()
if not role:
- raise RuntimeError(f"Meta da câmera {cam_id} sem role. Esperado role='rgb', 'nir' ou 're'.")
+ raise RuntimeError(
+ f"Meta da câmera {cam_id} sem role. "
+ "Esperado role='rgb', 'nir' ou 're'."
+ )
bit_depth = int(cam_meta.get("bit_depth", 10))
raw_format = str(cam_meta.get("raw_format", "")).upper()
- packed = bool(cam_meta.get("packed", False))
- is_raw10 = raw_format == "RAW10_PACKED" or packed or bit_depth == 10
+ packed_flag = bool(cam_meta.get("packed", False))
if role == "rgb":
- if is_raw10 and data.ndim == 2:
- sensor_w = int(cam_meta.get("width", self.sensor_width))
- sensor_h = int(cam_meta.get("height", self.sensor_height))
+ sensor_w = int(cam_meta.get("width", self.sensor_width))
+ sensor_h = int(cam_meta.get("height", self.sensor_height))
- bayer = (
- cam_meta.get("bayer_pattern")
- or cam_meta.get("bayer")
- or self.bayer_pattern
- or "RGGB"
+ bayer = str(
+ cam_meta.get("bayer_pattern")
+ or cam_meta.get("bayer")
+ or self.bayer_pattern
+ or "RGGB"
+ ).upper()
+
+ arr = np.asarray(data)
+
+ expected_packed_w = self._raw10_expected_packed_width_fast(sensor_w)
+ packed_width_meta = int(cam_meta.get("packed_width", 0) or 0)
+
+ looks_like_raw10_packed = (
+ arr.ndim == 2
+ and arr.dtype == np.uint8
+ and arr.shape[0] == sensor_h
+ and (
+ raw_format == "RAW10_PACKED"
+ or packed_flag
+ or (
+ packed_width_meta > 0
+ and arr.shape[1] >= packed_width_meta
+ )
+ or (
+ bit_depth == 10
+ and arr.shape[1] >= expected_packed_w
+ )
)
+ )
+ if looks_like_raw10_packed:
if rgb_mode in ("bayer_planes", "bayer", "half_res"):
rgb_hwc = self._raw10_rgb_bayer_planes_to_rgb_float01_aggressive(
- data,
+ arr,
width=sensor_w,
height=sensor_h,
bayer_pattern=bayer,
@@ -1176,7 +1600,7 @@ class RawProcessorCore:
"full_demosaic_half",
):
rgb_hwc = self._raw10_rgb_linear_demosaic_to_rgb_float01_fast(
- data,
+ arr,
width=sensor_w,
height=sensor_h,
bayer_pattern=bayer,
@@ -1184,10 +1608,12 @@ class RawProcessorCore:
)
else:
- raise ValueError(f"rgb_processing.mode inválido: {rgb_mode}")
+ raise ValueError(
+ f"rgb_processing.mode inválido: {rgb_mode}"
+ )
- # Pós-processamento RGB opcional.
- # Este é o caminho principal de inferência RAW_BRUTO.
+ # Em produto enhancement é validado como OFF.
+ # Em modo legado permanece compatível com os experimentos antigos.
rgb_hwc = self.apply_rgb_enhancement_to_hwc_float01(
rgb_hwc,
stage="decode_stream_cameras.after_raw_decode",
@@ -1201,18 +1627,50 @@ class RawProcessorCore:
"meta": cam_meta,
}
+ elif arr.ndim == 2 and arr.dtype == np.uint16:
+ # RAW Bayer já desempacotado. Útil em fluxo offline/diagnóstico.
+ old_bayer = self.bayer_pattern
+ try:
+ self.bayer_pattern = bayer
+ rgb_chw = self.build_training_rgb(
+ arr,
+ output_dtype="float32",
+ bit_depth=bit_depth,
+ )
+ finally:
+ self.bayer_pattern = old_bayer
+
+ rgb_hwc = np.transpose(
+ rgb_chw,
+ (1, 2, 0),
+ ).astype(np.float32, copy=False)
+
+ decoded[cam_id] = {
+ "name": "RGB",
+ "role": "rgb",
+ "image": rgb_hwc,
+ "meta": cam_meta,
+ }
+
else:
- # Caso preview/processado antigo: BGR HWC uint8.
- if data.ndim != 3 or data.shape[2] != 3:
- raise RuntimeError(f"{cam_id} RGB inválida: shape={data.shape}")
+ # Preview/processado antigo: BGR HWC.
+ if arr.ndim != 3 or arr.shape[2] != 3:
+ raise RuntimeError(
+ f"{cam_id} RGB inválida: shape={arr.shape}, "
+ f"dtype={arr.dtype}"
+ )
- rgb = data[:, :, ::-1].astype(np.float32) / 255.0
+ if arr.dtype == np.uint8:
+ rgb = arr[:, :, ::-1].astype(np.float32) / 255.0
+ elif arr.dtype == np.uint16:
+ rgb = arr[:, :, ::-1].astype(np.float32) / 65535.0
+ else:
+ rgb = arr[:, :, ::-1].astype(np.float32)
+ if np.nanmax(rgb) > 1.5:
+ rgb /= 255.0
- rgb = np.clip(rgb, 0.0, 1.0)
+ np.clip(rgb, 0.0, 1.0, out=rgb)
- # Por padrão não mexe em preview/RGB já processado.
- # Se quiser aplicar também neste caminho, use:
- # rgb_processing.enhancement.apply_to_preview_input=true
rgb = self.apply_rgb_enhancement_to_hwc_float01(
rgb,
stage="decode_stream_cameras.preview_input",
@@ -1230,7 +1688,10 @@ class RawProcessorCore:
decoded[cam_id] = {
"name": "RE",
"role": "re",
- "image": self._decode_spectral_frame_to_float01(data, cam_meta),
+ "image": self._decode_spectral_frame_to_float01(
+ data,
+ cam_meta,
+ ),
"meta": cam_meta,
}
@@ -1238,12 +1699,17 @@ class RawProcessorCore:
decoded[cam_id] = {
"name": "NIR",
"role": "nir",
- "image": self._decode_spectral_frame_to_float01(data, cam_meta),
+ "image": self._decode_spectral_frame_to_float01(
+ data,
+ cam_meta,
+ ),
"meta": cam_meta,
}
else:
- raise RuntimeError(f"Role não suportada em {cam_id}: {role}")
+ raise RuntimeError(
+ f"Role não suportada em {cam_id}: {role}"
+ )
return decoded
@@ -1313,7 +1779,13 @@ class RawProcessorCore:
return np.clip(arr01, 0.0, 1.0)
- def fuse_multispec_cameras(self, decoded, meta, channels_expected):
+ def fuse_multispec_cameras(
+ self,
+ decoded,
+ meta,
+ channels_expected,
+ target_size=None,
+ ):
t_total0 = time.perf_counter()
t0 = time.perf_counter()
@@ -1344,6 +1816,7 @@ class RawProcessorCore:
decoded,
meta,
channels_expected,
+ target_size_override=target_size,
)
t_flat_ms = float(t_flat_native_ms + float(direct_perf.get("final_flat_ms", 0.0)))
@@ -1465,13 +1938,55 @@ class RawProcessorCore:
)
def _warp_with_valid_mask(self, img, role, ref_shape, meta):
- ref_h, ref_w = ref_shape
+ """
+ Legacy spatial path mantido correto para mixed-resolution.
- if img.shape[:2] != (ref_h, ref_w):
- img = cv2.resize(img, (ref_w, ref_h), interpolation=cv2.INTER_LINEAR)
+ Em homography, RE/NIR permanecem no tamanho nativo até o warp.
+ Nos modos identity/manual antigos, resize para a referência continua
+ permitido apenas por compatibilidade.
+ """
+ ref_h, ref_w = int(ref_shape[0]), int(ref_shape[1])
cfg = self.fusion_config
- mode = cfg.get("alignment_mode", "identity")
+ mode = str(cfg.get("alignment_mode", "identity")).lower()
+
+ if mode == "homography":
+ H = self._direct_fusion_get_role_homography_fast(
+ role,
+ meta,
+ (ref_h, ref_w),
+ source_size=img.shape[:2],
+ )
+
+ source_mask = np.ones(img.shape[:2], dtype=np.uint8) * 255
+
+ warped = cv2.warpPerspective(
+ img,
+ H,
+ (ref_w, ref_h),
+ flags=cv2.INTER_LINEAR,
+ borderMode=cv2.BORDER_CONSTANT,
+ borderValue=0,
+ )
+
+ warped_mask = cv2.warpPerspective(
+ source_mask,
+ H,
+ (ref_w, ref_h),
+ flags=cv2.INTER_NEAREST,
+ borderMode=cv2.BORDER_CONSTANT,
+ borderValue=0,
+ )
+
+ return warped, (warped_mask > 0).astype(np.uint8)
+
+ # Modos legados operavam em uma grade comum.
+ if img.shape[:2] != (ref_h, ref_w):
+ img = cv2.resize(
+ img,
+ (ref_w, ref_h),
+ interpolation=cv2.INTER_LINEAR,
+ )
mask = np.ones((ref_h, ref_w), dtype=np.uint8) * 255
@@ -1496,36 +2011,10 @@ class RawProcessorCore:
warped = self._affine_image(img, dx, dy, theta_deg)
warped_mask = self._affine_image(mask, dx, dy, theta_deg)
- elif mode == "homography":
- H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
- H = self._scale_homography_to_runtime(
- H,
- calib_size=calib_size,
- runtime_size=(ref_w, ref_h),
- )
-
- if H.shape != (3, 3):
- raise RuntimeError(f"Homografia inválida para {role}: shape={H.shape}")
-
- warped = cv2.warpPerspective(
- img, H, (ref_w, ref_h),
- flags=cv2.INTER_LINEAR,
- borderMode=cv2.BORDER_CONSTANT,
- borderValue=0
- )
-
- warped_mask = cv2.warpPerspective(
- mask, H, (ref_w, ref_h),
- flags=cv2.INTER_NEAREST,
- borderMode=cv2.BORDER_CONSTANT,
- borderValue=0
- )
-
else:
raise RuntimeError(f"alignment_mode inválido: {mode}")
- warped_mask = (warped_mask > 0).astype(np.uint8)
- return warped, warped_mask
+ return warped, (warped_mask > 0).astype(np.uint8)
def _compute_common_crop_box(self, masks):
if not masks:
@@ -1599,56 +2088,157 @@ class RawProcessorCore:
return np.stack(chans, axis=0)
- def _scale_homography_to_runtime(self, H, calib_size, runtime_size):
+ def _scale_homography_to_runtime(
+ self,
+ H,
+ calib_size=None,
+ runtime_size=None,
+ *,
+ calib_source_size=None,
+ calib_reference_size=None,
+ runtime_source_size=None,
+ runtime_reference_size=None,
+ ):
"""
- Ajusta uma homografia calculada em calib_size para ser aplicada em runtime_size.
+ Escala H entre dois espaços independentes usando convenção de centro
+ de pixel compatível com cv2.resize:
- H original:
- ponto_spec_calib -> ponto_rgb_calib
+ x_dst = (x_src + 0.5) * scale - 0.5
- H runtime:
- ponto_spec_runtime -> ponto_rgb_runtime
+ Caso geral:
+ H_calib : source_calib -> reference_calib
+ H_runtime : source_runtime -> reference_runtime
+
+ H_runtime = A_dst @ H_calib @ inv(A_src)
+
+ Chamadas antigas com calib_size/runtime_size seguem compatíveis.
"""
if H is None:
return None
- H = np.asarray(H, dtype=np.float32)
+ H = np.asarray(
+ H,
+ dtype=np.float64,
+ )
- if calib_size is None:
- return H
+ if H.shape != (3, 3):
+ raise RuntimeError(
+ f"Homografia inválida: shape={H.shape}"
+ )
- calib_w, calib_h = calib_size
- runtime_w, runtime_h = runtime_size
+ if calib_source_size is None:
+ calib_source_size = calib_size
- calib_w = float(calib_w)
- calib_h = float(calib_h)
- runtime_w = float(runtime_w)
- runtime_h = float(runtime_h)
+ if calib_reference_size is None:
+ calib_reference_size = calib_size
- if calib_w <= 0 or calib_h <= 0:
- return H
+ if runtime_source_size is None:
+ runtime_source_size = runtime_size
- sx = runtime_w / calib_w
- sy = runtime_h / calib_h
+ if runtime_reference_size is None:
+ runtime_reference_size = runtime_size
- S = np.array([
- [sx, 0.0, 0.0],
- [0.0, sy, 0.0],
- [0.0, 0.0, 1.0],
- ], dtype=np.float32)
+ if (
+ calib_source_size is None
+ or calib_reference_size is None
+ or runtime_source_size is None
+ or runtime_reference_size is None
+ ):
+ out = H.copy()
- S_inv = np.array([
- [1.0 / sx, 0.0, 0.0],
- [0.0, 1.0 / sy, 0.0],
- [0.0, 0.0, 1.0],
- ], dtype=np.float32)
+ if abs(
+ float(
+ out[2, 2]
+ )
+ ) > 1e-12:
+ out /= out[2, 2]
- H_runtime = S @ H @ S_inv
+ return out.astype(
+ np.float32
+ )
- if abs(H_runtime[2, 2]) > 1e-9:
- H_runtime = H_runtime / H_runtime[2, 2]
+ def resize_matrix(
+ src_size,
+ dst_size,
+ ):
+ src_w, src_h = [
+ float(v)
+ for v in src_size
+ ]
+ dst_w, dst_h = [
+ float(v)
+ for v in dst_size
+ ]
- return H_runtime.astype(np.float32)
+ if (
+ src_w <= 0
+ or src_h <= 0
+ or dst_w <= 0
+ or dst_h <= 0
+ ):
+ raise RuntimeError(
+ f"Tamanho inválido: "
+ f"src={src_size}, dst={dst_size}"
+ )
+
+ sx = dst_w / src_w
+ sy = dst_h / src_h
+
+ tx = (
+ 0.5 * sx
+ - 0.5
+ )
+ ty = (
+ 0.5 * sy
+ - 0.5
+ )
+
+ return np.array(
+ [
+ [sx, 0.0, tx],
+ [0.0, sy, ty],
+ [0.0, 0.0, 1.0],
+ ],
+ dtype=np.float64,
+ )
+
+ A_src = resize_matrix(
+ calib_source_size,
+ runtime_source_size,
+ )
+
+ A_dst = resize_matrix(
+ calib_reference_size,
+ runtime_reference_size,
+ )
+
+ H_runtime = (
+ A_dst
+ @ H
+ @ np.linalg.inv(
+ A_src
+ )
+ )
+
+ if abs(
+ float(
+ H_runtime[2, 2]
+ )
+ ) > 1e-12:
+ H_runtime /= H_runtime[2, 2]
+
+ if not np.all(
+ np.isfinite(
+ H_runtime
+ )
+ ):
+ raise RuntimeError(
+ "Homografia runtime contém valores não finitos."
+ )
+
+ return H_runtime.astype(
+ np.float32
+ )
def apply_patch_normalization_to_tensor(self, tensor: np.ndarray) -> np.ndarray:
@@ -1941,36 +2531,114 @@ class RawProcessorCore:
def _array01_stats(self, arr: np.ndarray) -> dict:
"""
- Estatísticas compactas para debug radiométrico de arrays float.
- Mantém tudo serializável em JSON e leve o bastante para log por frame.
+ Estatísticas compactas para debug/quality.
+
+ Performance:
+ - sat/dark/over/under, min/max e finitude são EXATOS;
+ - percentis, mean e std usam amostragem determinística quando o array
+ ultrapassa quality_stats_max_samples.
+
+ Essa amostragem não altera o tensor nem decisões de saturação/range.
"""
if arr is None:
return {}
- vals = np.asarray(arr, dtype=np.float32).reshape(-1)
+ vals = np.asarray(
+ arr,
+ dtype=np.float32,
+ ).reshape(-1)
+
if vals.size <= 0:
return {}
- finite = vals[np.isfinite(vals)]
- if finite.size <= 0:
- return {"count": int(vals.size), "finite_count": 0}
+ finite_mask = np.isfinite(vals)
+ finite_count = int(
+ np.count_nonzero(finite_mask)
+ )
+
+ if finite_count <= 0:
+ return {
+ "count": int(vals.size),
+ "finite_count": 0,
+ }
+
+ if finite_count == vals.size:
+ finite = vals
+ else:
+ finite = vals[finite_mask]
+
+ n = int(finite.size)
+
+ max_samples = int(
+ getattr(
+ self,
+ "quality_stats_max_samples",
+ 65536,
+ )
+ or 65536
+ )
+
+ if max_samples <= 0:
+ max_samples = 65536
+
+ if n > max_samples:
+ stride = int(
+ math.ceil(
+ n / float(max_samples)
+ )
+ )
+ sample = finite[::stride]
+ else:
+ stride = 1
+ sample = finite
+
+ # Percentis em uma única chamada.
+ p01, p05, p50, p95, p99 = np.percentile(
+ sample,
+ [1, 5, 50, 95, 99],
+ )
+
+ sat_count = int(
+ np.count_nonzero(
+ finite >= 0.98
+ )
+ )
+ dark_count = int(
+ np.count_nonzero(
+ finite <= 0.02
+ )
+ )
+ over_count = int(
+ np.count_nonzero(
+ finite > 1.0
+ )
+ )
+ under_count = int(
+ np.count_nonzero(
+ finite < 0.0
+ )
+ )
+
+ inv_n = 100.0 / float(n)
return {
"count": int(vals.size),
- "finite_count": int(finite.size),
+ "finite_count": finite_count,
+ "sample_count": int(sample.size),
+ "sample_stride": int(stride),
"min": float(np.min(finite)),
- "p01": float(np.percentile(finite, 1)),
- "p05": float(np.percentile(finite, 5)),
- "p50": float(np.percentile(finite, 50)),
- "p95": float(np.percentile(finite, 95)),
- "p99": float(np.percentile(finite, 99)),
+ "p01": float(p01),
+ "p05": float(p05),
+ "p50": float(p50),
+ "p95": float(p95),
+ "p99": float(p99),
"max": float(np.max(finite)),
- "mean": float(np.mean(finite)),
- "std": float(np.std(finite)),
- "sat_pct": float((finite >= 0.98).mean() * 100.0),
- "dark_pct": float((finite <= 0.02).mean() * 100.0),
- "over_1_pct": float((finite > 1.0).mean() * 100.0),
- "under_0_pct": float((finite < 0.0).mean() * 100.0),
+ "mean": float(np.mean(sample)),
+ "std": float(np.std(sample)),
+ "sat_pct": float(sat_count * inv_n),
+ "dark_pct": float(dark_count * inv_n),
+ "over_1_pct": float(over_count * inv_n),
+ "under_0_pct": float(under_count * inv_n),
}
def _tensor_channel_stats(self, tensor: np.ndarray, channel_names: list) -> dict:
@@ -2503,6 +3171,107 @@ class RawProcessorCore:
f"patch_warning:{warning_text}",
)
+ # ========================================================
+ # Contrato obrigatório de calibração
+ # ========================================================
+
+ rad_norm_cfg = self.radiometric_normalization_config or {}
+ if bool(rad_norm_cfg.get("enabled", False)):
+ rad_result = self.last_radiometric_normalization_result or {}
+
+ if not isinstance(rad_result, dict) or not bool(rad_result.get("applied", False)):
+ mark("bad", "radiometric_normalization_required_but_not_applied")
+ else:
+ roles_done = set((rad_result.get("by_role", {}) or {}).keys())
+ missing_roles = [
+ role
+ for role in ("rgb", "re", "nir")
+ if role not in roles_done
+ ]
+
+ if missing_roles:
+ mark(
+ "bad",
+ "radiometric_normalization_missing_roles:"
+ + ",".join(missing_roles),
+ )
+
+ rad_warnings = [
+ str(x)
+ for x in (rad_result.get("warnings", []) or [])
+ ]
+
+ hard_rad_tokens = (
+ "missing_frame_controls",
+ "invalid_factor",
+ "unsupported_method",
+ )
+
+ for warning in rad_warnings:
+ if any(token in warning for token in hard_rad_tokens):
+ mark("bad", f"radiometric_normalization:{warning}")
+
+ quality["metrics"]["radiometric_normalization"] = rad_result
+
+ flat_cfg = self.flatfield_config or {}
+ if bool(flat_cfg.get("enabled", False)):
+ if not bool(getattr(self, "flatfield_loaded", False)):
+ mark("bad", "flatfield_required_but_not_loaded")
+
+ loaded_channels = sorted(
+ str(x)
+ for x in (getattr(self, "flatfield_maps", {}) or {}).keys()
+ )
+ quality["metrics"]["flatfield_loaded_channels"] = loaded_channels
+
+ required_flat_channels = {"R", "G", "B", "RE", "NIR"}
+ if not required_flat_channels.issubset(set(loaded_channels)):
+ mark(
+ "bad",
+ "flatfield_missing_channels:"
+ + ",".join(
+ sorted(required_flat_channels - set(loaded_channels))
+ ),
+ )
+
+ flat_result = self.last_flatfield_result or {}
+ quality["metrics"]["flatfield_result"] = flat_result
+
+ if (
+ str(flat_cfg.get("apply_space", "native_camera_space")).lower()
+ == "native_camera_space"
+ and (
+ not isinstance(flat_result, dict)
+ or not bool(flat_result.get("applied", False))
+ )
+ ):
+ mark(
+ "bad",
+ "flatfield_required_but_not_applied",
+ )
+
+ if getattr(self, "strict_product_contract", False):
+ fusion_result = self.last_fusion_result or {}
+ profiles_used = fusion_result.get("homography_profiles_used", {}) or {}
+
+ for role in ("re", "nir"):
+ if role not in profiles_used:
+ mark("bad", f"homography_not_applied:{role}")
+
+ intr = self.intrinsics_config or {}
+ if bool((intr.get("runtime_undistort", {}) or {}).get("enabled", False)):
+ mark("bad", "runtime_undistort_enabled_but_not_supported")
+
+ quality["metrics"]["product_contract"] = {
+ "strict": True,
+ "frame_controls_source": getattr(
+ self,
+ "_last_frame_controls_source",
+ None,
+ ),
+ "homography_profiles_used": profiles_used,
+ }
+
# ========================================================
# Resultado final
# ========================================================
@@ -2861,53 +3630,725 @@ class RawProcessorCore:
)
+ def _validate_product_runtime_contract(self, data: dict):
+ """
+ Validação fail-closed do module_params montado pelo assembler de produção.
+
+ Arquivos legados continuam aceitos fora do strict_product_contract.
+ """
+ if not getattr(self, "strict_product_contract", False):
+ return True
+
+ if self.module_params_schema != "multispec_module_params_v3":
+ raise RuntimeError(
+ f"Schema de module_params inválido: {self.module_params_schema!r}"
+ )
+
+ if str(data.get("frame_type", "RAW_BRUTO")).upper() != "RAW_BRUTO":
+ raise RuntimeError(
+ f"Produto exige frame_type='RAW_BRUTO', veio {data.get('frame_type')!r}."
+ )
+
+ if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"):
+ raise RuntimeError(
+ f"Bayer inválido no module_params: {self.bayer_pattern!r}"
+ )
+
+ if not isinstance(self.sensor_size_by_role, dict):
+ raise RuntimeError("sensor_size_by_role ausente/inválido.")
+
+ if not isinstance(self.camera_hardware, dict):
+ raise RuntimeError("camera_hardware ausente/inválido.")
+
+ for role in ("rgb", "re", "nir"):
+ size = self.sensor_size_by_role.get(role)
+ hw = self.camera_hardware.get(role)
+
+ if not (
+ isinstance(size, (list, tuple))
+ and len(size) == 2
+ and int(size[0]) > 0
+ and int(size[1]) > 0
+ ):
+ raise RuntimeError(
+ f"sensor_size_by_role.{role} inválido: {size}"
+ )
+
+ if not isinstance(hw, dict):
+ raise RuntimeError(
+ f"camera_hardware.{role} ausente."
+ )
+
+ hw_size = hw.get("size")
+ if hw_size is not None:
+ hw_size = [
+ int(hw_size[0]),
+ int(hw_size[1]),
+ ]
+ expected_size = [
+ int(size[0]),
+ int(size[1]),
+ ]
+
+ if hw_size != expected_size:
+ raise RuntimeError(
+ f"Hardware/size divergente em {role}: "
+ f"hw={hw_size}, size={expected_size}"
+ )
+
+ rgb_size = [
+ int(self.sensor_width),
+ int(self.sensor_height),
+ ]
+ expected_rgb = [
+ int(x)
+ for x in self.sensor_size_by_role["rgb"]
+ ]
+
+ if rgb_size != expected_rgb:
+ raise RuntimeError(
+ f"sensor_width/height root {rgb_size} != RGB {expected_rgb}"
+ )
+
+ rgb_mode = str(
+ (self.rgb_processing_config or {}).get(
+ "mode",
+ "",
+ )
+ ).lower()
+
+ allowed_rgb_modes = {
+ "linear_demosaic",
+ "demosaic",
+ "full_res",
+ "linear_demosaic_half",
+ "demosaic_half",
+ "full_demosaic_half",
+ "bayer_planes",
+ "bayer",
+ "half_res",
+ }
+
+ if rgb_mode not in allowed_rgb_modes:
+ raise RuntimeError(
+ f"rgb_processing.mode inválido no produto: {rgb_mode!r}"
+ )
+
+ if (
+ str(
+ (self.fusion_config or {}).get(
+ "alignment_mode",
+ "",
+ )
+ ).lower()
+ != "homography"
+ ):
+ raise RuntimeError(
+ "Produto exige fusion_config.alignment_mode='homography'."
+ )
+
+ if not bool(
+ (self.flatfield_config or {}).get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Produto exige flatfield_config.enabled=true."
+ )
+
+ flat_space = str(
+ (self.flatfield_config or {}).get(
+ "apply_space",
+ "native_camera_space",
+ )
+ ).lower()
+
+ if flat_space != "native_camera_space":
+ raise RuntimeError(
+ f"Produto exige Flat-Field no espaço nativo; "
+ f"apply_space={flat_space!r}"
+ )
+
+ if bool(
+ (self.flatfield_config or {}).get(
+ "subtract_dark",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Produto atual exige flatfield_config.subtract_dark=false."
+ )
+
+ if not self.flatfield_loaded:
+ raise RuntimeError(
+ "Flat-field produto não foi carregado integralmente."
+ )
+
+ rad_norm = self.radiometric_normalization_config or {}
+
+ if not bool(
+ rad_norm.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Produto exige radiometric_normalization.enabled=true."
+ )
+
+ if (
+ str(
+ rad_norm.get(
+ "method",
+ "",
+ )
+ ).lower()
+ != "oak_ae_frame_controls_v1"
+ ):
+ raise RuntimeError(
+ f"Método radiométrico inválido no produto: "
+ f"{rad_norm.get('method')!r}"
+ )
+
+ factor_model = str(
+ rad_norm.get(
+ "factor_model",
+ "exposure_time_us_x_iso",
+ )
+ ).lower()
+
+ if factor_model != "exposure_time_us_x_iso":
+ raise RuntimeError(
+ f"factor_model radiométrico inválido: {factor_model!r}"
+ )
+
+ apply_stage = str(
+ rad_norm.get(
+ "apply_stage",
+ "after_dark_before_flat_gain",
+ )
+ ).lower()
+
+ if apply_stage != "after_dark_before_flat_gain":
+ raise RuntimeError(
+ f"apply_stage radiométrico incompatível: {apply_stage!r}"
+ )
+
+ refs = rad_norm.get(
+ "reference_controls",
+ {},
+ ) or {}
+
+ for role in ("rgb", "re", "nir"):
+ ref = refs.get(role)
+ if not isinstance(ref, dict):
+ raise RuntimeError(
+ f"radiometric_normalization sem reference_controls.{role}"
+ )
+
+ try:
+ exp = float(
+ ref.get(
+ "exposure_time_us",
+ 0,
+ )
+ )
+ iso = float(
+ ref.get(
+ "sensitivity_iso",
+ 0,
+ )
+ )
+ except Exception as exc:
+ raise RuntimeError(
+ f"reference_controls inválido em {role}: {ref}"
+ ) from exc
+
+ if exp <= 0 or iso <= 0:
+ raise RuntimeError(
+ f"reference_controls inválido em {role}: {ref}"
+ )
+
+ if bool(
+ (self.radiometric_config or {}).get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "radiometric_config controller deve estar desligado no produto."
+ )
+
+ if bool(
+ (self.patch_normalization_config or {}).get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "patch_normalization deve estar desligado no produto."
+ )
+
+ if bool(
+ (self.rgb_calibration or {}).get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "rgb_calibration manual deve estar desligado no produto."
+ )
+
+ intr = self.intrinsics_config or {}
+
+ if not bool(
+ intr.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Produto exige intrinsics_config.enabled=true."
+ )
+
+ und_cfg = (
+ intr.get(
+ "runtime_undistort",
+ {},
+ )
+ or {}
+ )
+
+ if bool(
+ und_cfg.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "runtime_undistort=true ainda não é permitido neste core. "
+ "Recalibre Homography no espaço undistorted e implemente esse "
+ "estágio antes de ativar."
+ )
+
+ intr_space = str(
+ intr.get(
+ "calibration_space",
+ "native_stream_no_external_undistort",
+ )
+ )
+
+ for role in ("re", "nir"):
+ entry = self._resolve_homography_geometry_entry_for_role(
+ role
+ )
+
+ hom_space = str(
+ entry.get(
+ "coordinate_space",
+ )
+ or ""
+ )
+
+ if (
+ hom_space
+ and intr_space
+ and hom_space != intr_space
+ ):
+ raise RuntimeError(
+ f"Domínio geométrico divergente em {role}: "
+ f"intrinsics={intr_space}, homography={hom_space}"
+ )
+
+ cameras = (
+ intr.get(
+ "cameras",
+ {},
+ )
+ or {}
+ )
+
+ for role in ("rgb", "re", "nir"):
+ cam = cameras.get(role)
+
+ if not isinstance(cam, dict):
+ raise RuntimeError(
+ f"intrinsics_config.cameras.{role} ausente."
+ )
+
+ image_size = cam.get(
+ "image_size"
+ )
+ expected = [
+ int(x)
+ for x in self.sensor_size_by_role[role]
+ ]
+
+ if not (
+ isinstance(image_size, (list, tuple))
+ and len(image_size) == 2
+ and [
+ int(image_size[0]),
+ int(image_size[1]),
+ ]
+ == expected
+ ):
+ raise RuntimeError(
+ f"Intrinsics size divergente em {role}: "
+ f"{image_size} != {expected}"
+ )
+
+ K = np.asarray(
+ cam.get(
+ "camera_matrix"
+ ),
+ dtype=np.float64,
+ )
+
+ D = np.asarray(
+ cam.get(
+ "dist_coeffs"
+ ),
+ dtype=np.float64,
+ ).reshape(-1)
+
+ if (
+ K.shape != (3, 3)
+ or not np.all(
+ np.isfinite(K)
+ )
+ ):
+ raise RuntimeError(
+ f"Intrinsics K inválida em {role}."
+ )
+
+ if (
+ D.size < 4
+ or not np.all(
+ np.isfinite(D)
+ )
+ ):
+ raise RuntimeError(
+ f"Intrinsics D inválida em {role}."
+ )
+
+ # Enhancement é não linear e, no caminho legado atual, acontece antes
+ # da normalização radiométrica. Produto calibrado não pode ativá-lo.
+ enh = self._get_rgb_enhancement_config()
+
+ if bool(
+ enh.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "rgb_processing.enhancement deve ficar disabled no pipeline "
+ "calibrado de produto."
+ )
+
+ for role in ("rgb", "re", "nir"):
+ if role not in (
+ self.camera_settings
+ or {}
+ ):
+ raise RuntimeError(
+ f"camera_settings sem role {role}."
+ )
+
+ return True
+
+
+ def _validate_stream_camera_contract(self, frame: dict, meta: dict):
+ """
+ Confere por frame se o RAW recebido pertence ao hardware homologado.
+
+ Em modo produto:
+ - exige CAM_A/CAM_B/CAM_C por role;
+ - exige resolução nativa correta;
+ - exige RAW10 packed uint8 2D;
+ - exige sensor/socket coerentes quando informados;
+ - exige Bayer RGB coerente.
+ """
+ if not getattr(self, "strict_product_contract", False):
+ return True
+
+ if not isinstance(frame, dict) or not isinstance(meta, dict):
+ raise RuntimeError("Contrato stream inválido para produto.")
+
+ stream_meta = (
+ meta.get("stream_meta", {})
+ if isinstance(meta.get("stream_meta"), dict)
+ else {}
+ )
+ camera_frames = (
+ meta.get("camera_frames", {})
+ or stream_meta.get("camera_frames", {})
+ or {}
+ )
+ camera_info = (
+ meta.get("camera_info", {})
+ or stream_meta.get("camera_info", {})
+ or {}
+ )
+
+ seen_roles = {}
+
+ for cam_id, data in frame.items():
+ cam_meta = {}
+
+ if isinstance(camera_info.get(cam_id), dict):
+ cam_meta.update(camera_info.get(cam_id) or {})
+
+ if isinstance(camera_frames.get(cam_id), dict):
+ cam_meta.update(camera_frames.get(cam_id) or {})
+
+ role = str(cam_meta.get("role", "")).lower()
+
+ if role not in ("rgb", "re", "nir"):
+ raise RuntimeError(
+ f"{cam_id}: role inválida/ausente no stream: {role!r}"
+ )
+
+ if role in seen_roles:
+ raise RuntimeError(
+ f"Role duplicada no stream: {role} "
+ f"em {seen_roles[role]} e {cam_id}"
+ )
+
+ seen_roles[role] = cam_id
+
+ expected_size = self.sensor_size_by_role.get(role)
+ if not expected_size:
+ raise RuntimeError(f"MP sem sensor_size_by_role.{role}")
+
+ width = cam_meta.get("width")
+ height = cam_meta.get("height")
+
+ if width is None or height is None:
+ raise RuntimeError(
+ f"{cam_id}/{role}: metadata sem width/height nativos."
+ )
+
+ got = [int(width), int(height)]
+ expected = [int(expected_size[0]), int(expected_size[1])]
+
+ if got != expected:
+ raise RuntimeError(
+ f"{cam_id}/{role}: resolução nativa {got} "
+ f"!= homologada {expected}"
+ )
+
+ arr = np.asarray(data)
+ expected_packed_width = self._raw10_expected_packed_width_fast(
+ expected[0]
+ )
+
+ if arr.ndim != 2 or arr.dtype != np.uint8:
+ raise RuntimeError(
+ f"{cam_id}/{role}: produto RAW_BRUTO exige "
+ f"RAW10 packed uint8 2D; shape={arr.shape}, dtype={arr.dtype}"
+ )
+
+ if arr.shape[0] != expected[1]:
+ raise RuntimeError(
+ f"{cam_id}/{role}: altura packed={arr.shape[0]} "
+ f"!= nativa={expected[1]}"
+ )
+
+ if arr.shape[1] < expected_packed_width:
+ raise RuntimeError(
+ f"{cam_id}/{role}: packed_width={arr.shape[1]} "
+ f"< mínimo RAW10={expected_packed_width}"
+ )
+
+ padding = int(arr.shape[1] - expected_packed_width)
+ if padding > 64:
+ raise RuntimeError(
+ f"{cam_id}/{role}: padding RAW10 excessivo={padding}"
+ )
+
+ bit_depth = int(cam_meta.get("bit_depth", 10))
+ if bit_depth != 10:
+ raise RuntimeError(
+ f"{cam_id}/{role}: produto espera bit_depth=10, "
+ f"veio {bit_depth}"
+ )
+
+ expected_hw = self.camera_hardware.get(role, {}) or {}
+ expected_sensor = str(
+ expected_hw.get("sensor", "") or ""
+ ).upper()
+
+ actual_sensor = str(
+ cam_meta.get("sensor")
+ or cam_meta.get("sensor_name")
+ or ""
+ ).upper()
+
+ if (
+ expected_sensor
+ and actual_sensor
+ and actual_sensor != expected_sensor
+ ):
+ raise RuntimeError(
+ f"{cam_id}/{role}: sensor={actual_sensor} "
+ f"!= homologado={expected_sensor}"
+ )
+
+ expected_socket = str(
+ expected_hw.get("socket", "") or ""
+ )
+
+ if (
+ expected_socket
+ and str(cam_id).upper() != expected_socket.upper()
+ ):
+ raise RuntimeError(
+ f"{role}: cam_id={cam_id} "
+ f"!= socket homologado={expected_socket}"
+ )
+
+ if role == "rgb":
+ actual_bayer = str(
+ cam_meta.get("bayer_pattern")
+ or cam_meta.get("bayer")
+ or self.bayer_pattern
+ ).upper()
+
+ if actual_bayer != self.bayer_pattern:
+ raise RuntimeError(
+ f"RGB Bayer do frame={actual_bayer} "
+ f"!= MP={self.bayer_pattern}"
+ )
+
+ missing = [
+ role
+ for role in ("rgb", "re", "nir")
+ if role not in seen_roles
+ ]
+
+ if missing:
+ raise RuntimeError(
+ f"Frame produto incompleto. Faltam roles: {missing}"
+ )
+
+ return True
+
def load_config_json(self, path: str):
if not path or not os.path.isfile(path):
- raise FileNotFoundError(f"Arquivo de calibração não encontrado: {path}")
+ raise FileNotFoundError(
+ f"Arquivo de calibração não encontrado: {path}"
+ )
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
- self.bayer_pattern = data.get("bayer_pattern", self.bayer_pattern)
+ if not isinstance(data, dict):
+ raise RuntimeError("module_params root deve ser dict/object.")
+
+ self.calibration_json_path = path
+ self.calibration_base_dir = os.path.dirname(os.path.abspath(path))
+
+ self.strict_product_contract = bool(
+ isinstance(data.get("assembly_metadata"), dict)
+ and isinstance(data.get("camera_hardware"), dict)
+ )
+
+ self.module_params_schema = data.get("schema")
+ self.assembly_metadata = deepcopy(data.get("assembly_metadata", {}) or {})
+ self.calibration_provenance = deepcopy(
+ data.get("calibration_provenance", {}) or {}
+ )
+ self.camera_hardware = deepcopy(data.get("camera_hardware", {}) or {})
+ self.sensor_size_by_role = deepcopy(
+ data.get("sensor_size_by_role", {}) or {}
+ )
+ self.intrinsics_config = deepcopy(data.get("intrinsics_config", {}) or {})
+
+ if data.get("sensor_width") is not None:
+ self.sensor_width = int(data["sensor_width"])
+
+ if data.get("sensor_height") is not None:
+ self.sensor_height = int(data["sensor_height"])
+
+ self.bayer_pattern = str(
+ data.get("bayer_pattern", self.bayer_pattern)
+ ).upper()
rgb_proc = data.get("rgb_processing")
if isinstance(rgb_proc, dict):
- self.rgb_processing_config = self._merge_config(self.rgb_processing_config, rgb_proc)
+ self.rgb_processing_config = self._merge_config(
+ self.rgb_processing_config,
+ rgb_proc,
+ )
fusion = data.get("fusion_config")
if isinstance(fusion, dict):
- self.fusion_config = self._merge_config(self.fusion_config, fusion)
+ self.fusion_config = self._merge_config(
+ self.fusion_config,
+ fusion,
+ )
+ elif self.strict_product_contract:
+ raise RuntimeError("module_params produto sem fusion_config.")
else:
print("[WARN] JSON sem fusion_config. Mantendo config padrão.")
rgb_cal = data.get("rgb_calibration")
if isinstance(rgb_cal, dict):
- self.rgb_calibration = self._merge_config(self.rgb_calibration, rgb_cal)
+ self.rgb_calibration = self._merge_config(
+ self.rgb_calibration,
+ rgb_cal,
+ )
+
+ radiometric = data.get("radiometric_config")
+ if isinstance(radiometric, dict):
+ self.radiometric_config = self._merge_config(
+ self.radiometric_config,
+ radiometric,
+ )
+
+ rad_norm = data.get("radiometric_normalization")
+ if isinstance(rad_norm, dict):
+ self.radiometric_normalization_config = self._merge_config(
+ self.radiometric_normalization_config,
+ rad_norm,
+ )
+
+ patch_norm = data.get("patch_normalization")
+ if isinstance(patch_norm, dict):
+ self.patch_normalization_config = self._merge_config(
+ self.patch_normalization_config,
+ patch_norm,
+ )
+
+ cam_set = data.get("camera_settings")
+ if isinstance(cam_set, dict):
+ self.camera_settings = self._merge_config(
+ self.camera_settings,
+ cam_set,
+ )
flatfield = data.get("flatfield_config")
if isinstance(flatfield, dict):
- self.flatfield_config = self._merge_config(self.flatfield_config, flatfield)
- self.load_flatfield_maps()
+ self.flatfield_config = self._merge_config(
+ self.flatfield_config,
+ flatfield,
+ )
else:
self.flatfield_config["enabled"] = False
self.flatfield_maps = {}
self.flatfield_loaded = False
- radiometric = data.get("radiometric_config")
- if isinstance(radiometric, dict):
- self.radiometric_config = self._merge_config(self.radiometric_config, radiometric)
+ self.clear_direct_fusion_geometry_cache()
+ self._flatfield_runtime_cache = {}
- rad_norm_config = data.get("radiometric_normalization")
- if isinstance(rad_norm_config, dict):
- self.radiometric_normalization_config = self._merge_config(self.radiometric_normalization_config, rad_norm_config)
+ if self.flatfield_config.get("enabled", False):
+ self.load_flatfield_maps()
- patch_norm = data.get("patch_normalization")
- if isinstance(patch_norm, dict):
- self.patch_normalization_config = self._merge_config(self.patch_normalization_config, patch_norm)
-
- cam_set = data.get("camera_settings")
- if isinstance(cam_set, dict):
- self.camera_settings = self._merge_config(self.camera_settings, cam_set)
+ self._validate_product_runtime_contract(data)
+ return data
def _merge_config(self, default_cfg: dict, loaded_cfg: dict) -> dict:
cfg = json.loads(json.dumps(default_cfg))
@@ -2961,55 +4402,110 @@ class RawProcessorCore:
npz_file = cfg.get("npz_file")
if not npz_file:
- print("[WARN] flatfield_config habilitado, mas sem npz_file.")
+ msg = "flatfield_config habilitado, mas sem npz_file."
self.flatfield_maps = {}
self.flatfield_loaded = False
+
+ if getattr(self, "strict_product_contract", False):
+ raise RuntimeError(msg)
+
+ print(f"[WARN] {msg}")
return False
npz_path = self._resolve_calibration_path(npz_file)
if not os.path.isfile(npz_path):
- print(f"[WARN] Arquivo flat-field não encontrado: {npz_file} -> {npz_path}")
+ msg = f"Arquivo flat-field não encontrado: {npz_file} -> {npz_path}"
self.flatfield_maps = {}
self.flatfield_loaded = False
- return False
- data = np.load(npz_path)
+ if getattr(self, "strict_product_contract", False):
+ raise FileNotFoundError(msg)
+
+ print(f"[WARN] {msg}")
+ return False
maps = {}
channel_maps = cfg.get("channel_maps", {}) or {}
channels = cfg.get("channels", ["R", "G", "B", "RE", "NIR"])
- for ch in channels:
- ch = str(ch).upper()
- ch_cfg = channel_maps.get(ch, {}) or {}
+ with np.load(npz_path, allow_pickle=False) as data:
+ for ch in channels:
+ ch = str(ch).upper()
+ ch_cfg = channel_maps.get(ch, {}) or {}
- gain_key = ch_cfg.get("gain_key", f"gain_{ch}")
- dark_key = ch_cfg.get("dark_median_key", f"dark_median_{ch}")
+ if isinstance(ch_cfg, str):
+ ch_cfg = {"gain_key": ch_cfg}
- if gain_key not in data:
- print(f"[WARN] Flat-field sem chave {gain_key} para canal {ch}.")
- continue
+ gain_key = ch_cfg.get("gain_key", f"gain_{ch}")
+ dark_key = ch_cfg.get("dark_median_key", f"dark_median_{ch}")
- entry = {
- "gain": data[gain_key].astype(np.float32),
- "gain_key": gain_key,
- }
+ if gain_key not in data:
+ if getattr(self, "strict_product_contract", False):
+ raise RuntimeError(
+ f"Flat-field sem chave obrigatória {gain_key} para {ch}."
+ )
- if dark_key and dark_key in data:
- entry["dark"] = data[dark_key].astype(np.float32)
- entry["dark_key"] = dark_key
+ print(f"[WARN] Flat-field sem chave {gain_key} para canal {ch}.")
+ continue
- maps[ch] = entry
+ gain = np.asarray(data[gain_key], dtype=np.float32)
+
+ if gain.ndim != 2 or gain.size == 0:
+ raise RuntimeError(
+ f"Flat-field {ch}/{gain_key} shape inválido: {gain.shape}"
+ )
+
+ if not np.all(np.isfinite(gain)):
+ raise RuntimeError(
+ f"Flat-field {ch}/{gain_key} contém NaN/Inf."
+ )
+
+ if np.any(gain <= 0):
+ raise RuntimeError(
+ f"Flat-field {ch}/{gain_key} contém ganho <= 0."
+ )
+
+ entry = {
+ "gain": gain,
+ "gain_key": gain_key,
+ }
+
+ if dark_key and dark_key in data:
+ dark = np.asarray(data[dark_key], dtype=np.float32)
+ if dark.ndim != 2 or not np.all(np.isfinite(dark)):
+ raise RuntimeError(
+ f"Dark map {ch}/{dark_key} inválido."
+ )
+
+ entry["dark"] = dark
+ entry["dark_key"] = dark_key
+
+ maps[ch] = entry
+
+ required = {"R", "G", "B", "RE", "NIR"}
self.flatfield_maps = maps
- self.flatfield_loaded = len(maps) > 0
+ self.flatfield_loaded = required.issubset(set(maps))
+
+ if getattr(self, "strict_product_contract", False) and not self.flatfield_loaded:
+ missing = sorted(required - set(maps))
+ raise RuntimeError(
+ f"Flat-field produto incompleto. Faltam canais: {missing}"
+ )
if self.flatfield_loaded:
- print(f"[OK] Flat-field carregado: {npz_path} | canais={list(maps.keys())}")
+ print(
+ f"[OK] Flat-field carregado: {npz_path} | "
+ f"canais={list(maps.keys())}"
+ )
else:
- print(f"[WARN] Flat-field habilitado, mas nenhum mapa foi carregado: {npz_path}")
+ print(
+ f"[WARN] Flat-field parcial: {npz_path} | "
+ f"canais={list(maps.keys())}"
+ )
+ self._flatfield_runtime_cache = {}
return self.flatfield_loaded
def apply_dark_to_decoded(self, decoded: dict) -> dict:
@@ -3077,50 +4573,97 @@ class RawProcessorCore:
def apply_flat_gain_to_decoded(self, decoded: dict) -> dict:
cfg = self.flatfield_config or {}
+ result = {
+ "enabled": bool(cfg.get("enabled", False)),
+ "applied": False,
+ "by_role": {},
+ "warnings": [],
+ }
+ self.last_flatfield_result = result
+
if not cfg.get("enabled", False):
+ result["warnings"].append("flatfield_disabled")
return decoded
if not self.flatfield_loaded:
self.load_flatfield_maps()
if not self.flatfield_loaded:
+ result["warnings"].append("flatfield_maps_not_loaded")
return decoded
clip_output = bool(cfg.get("clip_output", True))
corrected = {}
- # Guarda anti-roxo / anti-artefato em saturação
- sat_guard_enabled = bool(cfg.get("saturation_guard_enabled", True))
- sat_mode = str(cfg.get("saturation_guard_mode", "fade_strength")).lower()
- sat_threshold = float(cfg.get("saturation_guard_threshold", 0.97))
+ sat_guard_enabled = bool(
+ cfg.get(
+ "saturation_guard_enabled",
+ True,
+ )
+ )
+ sat_mode = str(
+ cfg.get(
+ "saturation_guard_mode",
+ "fade_strength",
+ )
+ ).lower()
+ sat_threshold = float(
+ cfg.get(
+ "saturation_guard_threshold",
+ 0.97,
+ )
+ )
for cam_id, item in decoded.items():
- role = str(item.get("role") or item.get("meta", {}).get("role") or "").lower()
+ role = str(
+ item.get("role")
+ or item.get("meta", {}).get("role")
+ or ""
+ ).lower()
+
img = item.get("image")
if img is None:
corrected[cam_id] = item
+ result["warnings"].append(
+ f"{cam_id}:missing_image"
+ )
continue
new_item = dict(item)
- new_meta = dict(item.get("meta", {}) or {})
+ new_meta = dict(
+ item.get("meta", {})
+ or {}
+ )
if role == "rgb":
- if img.ndim != 3 or img.shape[2] < 3:
+ if (
+ img.ndim != 3
+ or img.shape[2] < 3
+ ):
corrected[cam_id] = item
+ result["warnings"].append(
+ f"{cam_id}:invalid_rgb_shape:{img.shape}"
+ )
continue
- out = img.astype(np.float32).copy()
+ out, reused = self._radiometric_get_writable_float32_image(
+ img
+ )
- # Máscara comum RGB:
- # se qualquer canal estiver perto de saturar, tratamos os 3 canais juntos.
- # Isso evita R/G/B receberem correções diferentes e criarem magenta/roxo.
- saturation_mask = None
+ # Máscara comum sem criar rgb_max float32 full-res.
if sat_guard_enabled:
- rgb_max = np.max(out[:, :, :3], axis=2)
- saturation_mask = rgb_max >= sat_threshold
+ saturation_mask = (
+ (out[:, :, 0] >= sat_threshold)
+ | (out[:, :, 1] >= sat_threshold)
+ | (out[:, :, 2] >= sat_threshold)
+ )
+ else:
+ saturation_mask = None
- for idx, ch in enumerate(("R", "G", "B")):
+ for idx, ch in enumerate(
+ ("R", "G", "B")
+ ):
out[:, :, idx] = self._apply_flat_gain_single_channel(
out[:, :, idx],
ch,
@@ -3130,29 +4673,61 @@ class RawProcessorCore:
new_item["image"] = out
+ result["by_role"]["rgb"] = {
+ "camera_id": cam_id,
+ "channels": ["R", "G", "B"],
+ "applied": True,
+ "inplace_reused_input": bool(reused),
+ "shape": list(out.shape),
+ }
+
elif role in ("re", "nir"):
- ch = "RE" if role == "re" else "NIR"
+ ch = (
+ "RE"
+ if role == "re"
+ else "NIR"
+ )
+
+ img_f, reused = self._radiometric_get_writable_float32_image(
+ img
+ )
- # Para RE/NIR a guarda pode ser por canal mesmo.
- # Não existe cor roxa aqui, mas ainda evita mexer em pixels clipados.
- saturation_mask = None
- img_f = img.astype(np.float32)
if sat_guard_enabled:
- saturation_mask = img_f >= sat_threshold
+ saturation_mask = (
+ img_f >= sat_threshold
+ )
+ else:
+ saturation_mask = None
- new_item["image"] = self._apply_flat_gain_single_channel(
+ out = self._apply_flat_gain_single_channel(
img_f,
ch,
clip_output=clip_output,
saturation_mask=saturation_mask,
)
+ new_item["image"] = out
+
+ result["by_role"][role] = {
+ "camera_id": cam_id,
+ "channels": [ch],
+ "applied": True,
+ "inplace_reused_input": bool(reused),
+ "shape": list(out.shape),
+ }
+
else:
corrected[cam_id] = item
+ result["warnings"].append(
+ f"{cam_id}:unsupported_role:{role}"
+ )
continue
new_meta["flatfield_applied"] = True
- new_meta["flatfield_map_type"] = cfg.get("map_type", "gain")
+ new_meta["flatfield_map_type"] = cfg.get(
+ "map_type",
+ "gain",
+ )
new_meta["flatfield_saturation_guard"] = {
"enabled": sat_guard_enabled,
"mode": sat_mode,
@@ -3162,6 +4737,45 @@ class RawProcessorCore:
new_item["meta"] = new_meta
corrected[cam_id] = new_item
+ required_roles = {
+ "rgb",
+ "re",
+ "nir",
+ }
+ applied_roles = {
+ role
+ for role, info in result["by_role"].items()
+ if isinstance(info, dict)
+ and bool(info.get("applied", False))
+ }
+
+ result["applied"] = (
+ required_roles.issubset(
+ applied_roles
+ )
+ )
+ result["applied_roles"] = sorted(
+ applied_roles
+ )
+ result["missing_roles"] = sorted(
+ required_roles
+ - applied_roles
+ )
+
+ if (
+ getattr(
+ self,
+ "strict_product_contract",
+ False,
+ )
+ and not result["applied"]
+ ):
+ raise RuntimeError(
+ "Flat-Field obrigatório não foi aplicado em todas as roles: "
+ f"missing={result['missing_roles']}"
+ )
+
+ self.last_flatfield_result = result
return corrected
def _subtract_dark_single_channel(
@@ -3289,7 +4903,93 @@ class RawProcessorCore:
saturation_mask = base >= sat_threshold
# ============================================================
- # Calcula ganho efetivo
+ # Fast path Numba: saturation guard + gain + clip em uma passada.
+ # ============================================================
+ if (
+ _HAS_NUMBA
+ and sat_guard_enabled
+ and saturation_mask is not None
+ and sat_mode in ("fade_strength", "skip")
+ ):
+ base_c = base
+ if not base_c.flags.c_contiguous:
+ base_c = np.ascontiguousarray(base_c)
+
+ gain_c = gain.astype(np.float32, copy=False)
+ if not gain_c.flags.c_contiguous:
+ gain_c = np.ascontiguousarray(gain_c)
+
+ mask_c = np.asarray(
+ saturation_mask,
+ dtype=np.bool_,
+ )
+ if not mask_c.flags.c_contiguous:
+ mask_c = np.ascontiguousarray(mask_c)
+
+ if mask_c.shape != base_c.shape:
+ raise RuntimeError(
+ f"saturation_mask shape inválido: "
+ f"{mask_c.shape} != {base_c.shape}"
+ )
+
+ mode_code = 1 if sat_mode == "fade_strength" else 2
+
+ out = np.empty(
+ base_c.shape,
+ dtype=np.float32,
+ )
+
+ t_numba0 = time.perf_counter()
+
+ _apply_flat_gain_guard_numba(
+ base_c,
+ gain_c,
+ mask_c,
+ out,
+ int(base_c.shape[0]),
+ int(base_c.shape[1]),
+ float(strength),
+ float(gain_min_runtime),
+ float(gain_max_runtime),
+ float(sat_soft_start),
+ float(sat_hard),
+ bool(clip_output),
+ int(mode_code),
+ )
+
+ t_total_ms = (
+ time.perf_counter()
+ - t_total0
+ ) * 1000.0
+
+ if not hasattr(
+ self,
+ "_last_flat_ch_perf_log_ts",
+ ):
+ self._last_flat_ch_perf_log_ts = 0.0
+
+ now = time.time()
+
+ if (
+ now
+ - self._last_flat_ch_perf_log_ts
+ >= 1.0
+ ):
+ self._last_flat_ch_perf_log_ts = now
+
+ print(
+ "[PERF][FLAT_CH_NUMBA] "
+ f"ch={ch} "
+ f"shape={base_c.shape} "
+ f"total={t_total_ms:.2f}ms "
+ f"mode={sat_mode} "
+ f"strength={strength:.2f}"
+ )
+
+ return out
+
+ # ============================================================
+ # Fallback NumPy: calcula ganho efetivo.
# ============================================================
t0 = time.perf_counter()
@@ -3382,8 +5082,14 @@ class RawProcessorCore:
result["warnings"].append("missing_frame_controls")
self.last_radiometric_normalization_result = result
- if str(cfg.get("missing_controls_policy", "skip")).lower() == "raise":
- raise RuntimeError("radiometric_normalization ativo, mas meta.frame_controls ausente.")
+ if (
+ getattr(self, "strict_product_contract", False)
+ or str(cfg.get("missing_controls_policy", "skip")).lower() == "raise"
+ ):
+ raise RuntimeError(
+ "radiometric_normalization ativo, mas controles reais "
+ "da captura estão ausentes."
+ )
return decoded
@@ -3422,8 +5128,14 @@ class RawProcessorCore:
f"{role}:invalid_factor actual={actual_factor:.6g} ref={ref_factor:.6g}"
)
- if str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise":
- raise RuntimeError(f"Controles radiométricos inválidos para role={role}: {actual_ctrl}")
+ if (
+ getattr(self, "strict_product_contract", False)
+ or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise"
+ ):
+ raise RuntimeError(
+ f"Controles radiométricos inválidos para role={role}: "
+ f"{actual_ctrl}"
+ )
continue
@@ -3487,53 +5199,87 @@ class RawProcessorCore:
self.last_radiometric_normalization_result = result
return normalized
- def _extract_frame_controls_from_meta_by_role(self, meta: dict | None) -> tuple[dict, dict]:
+ def _extract_frame_controls_from_meta_by_role(
+ self,
+ meta: dict | None,
+ ) -> tuple[dict, dict]:
"""
- Retorna:
- controls_by_role = {
- "rgb": {...},
- "re": {...},
- "nir": {...}
- }
+ Resolve controles reais da captura com compatibilidade histórica.
- controls_by_cam = {
- "CAM_A": {...},
- "CAM_B": {...},
- "CAM_C": {...}
- }
+ Prioridade:
+ 1. frame_controls
+ 2. actual_camera_controls
+ 3. camera_controls
+ 4. startup_camera_controls
- Fonte principal:
- meta["frame_controls"]
-
- Também aceita:
- meta["stream_meta"]["frame_controls"]
+ Aceita chaves CAM_A/B/C ou rgb/re/nir.
"""
if not isinstance(meta, dict):
return {}, {}
- stream_meta = meta.get("stream_meta") if isinstance(meta.get("stream_meta"), dict) else None
+ stream_meta = (
+ meta.get("stream_meta")
+ if isinstance(meta.get("stream_meta"), dict)
+ else {}
+ )
- frame_controls = meta.get("frame_controls")
- if not isinstance(frame_controls, dict) and stream_meta is not None:
- frame_controls = stream_meta.get("frame_controls")
+ controls = None
+ source_key = None
- if not isinstance(frame_controls, dict) or not frame_controls:
+ for key in (
+ "frame_controls",
+ "actual_camera_controls",
+ "camera_controls",
+ "startup_camera_controls",
+ ):
+ candidate = meta.get(key)
+
+ if not isinstance(candidate, dict) and stream_meta:
+ candidate = stream_meta.get(key)
+
+ if isinstance(candidate, dict) and candidate:
+ controls = candidate
+ source_key = key
+ break
+
+ if not isinstance(controls, dict) or not controls:
return {}, {}
camera_info = meta.get("camera_info")
- if not isinstance(camera_info, dict) and stream_meta is not None:
+ if not isinstance(camera_info, dict):
camera_info = stream_meta.get("camera_info")
camera_info = camera_info if isinstance(camera_info, dict) else {}
controls_by_cam = {}
controls_by_role = {}
+ role_to_cam = {}
- for cam_id, ctrl in frame_controls.items():
+ for cam_id, info in camera_info.items():
+ if not isinstance(info, dict):
+ continue
+ role = str(info.get("role", "")).lower()
+ if role in ("rgb", "re", "nir"):
+ role_to_cam[role] = str(cam_id)
+
+ for key, ctrl in controls.items():
if not isinstance(ctrl, dict):
continue
- cam_id = str(cam_id)
+ key_str = str(key)
+ key_lower = key_str.lower()
+
+ if key_lower in ("rgb", "re", "nir"):
+ role = key_lower
+ cam_id = role_to_cam.get(role)
+
+ if cam_id:
+ controls_by_cam[cam_id] = dict(ctrl)
+
+ controls_by_role[role] = dict(ctrl)
+ continue
+
+ cam_id = key_str
controls_by_cam[cam_id] = dict(ctrl)
role = str(
@@ -3541,12 +5287,12 @@ class RawProcessorCore:
).lower()
if not role:
- # fallback defensivo caso algum meta antigo venha sem camera_info
role = self._role_from_cam_id_fallback(cam_id)
if role:
controls_by_role[role] = dict(ctrl)
+ self._last_frame_controls_source = source_key
return controls_by_role, controls_by_cam
def _role_from_cam_id_fallback(self, cam_id: str) -> str:
@@ -4256,17 +6002,53 @@ class RawProcessorCore:
- def _direct_fusion_get_target_size_fast(self, ref_size):
+ def _direct_fusion_get_target_size_fast(
+ self,
+ ref_size,
+ target_size_override=None,
+ ):
"""
- Resolve target_size final como (target_w, target_h).
- Usa fusion_config.target_size se existir, senão usa tamanho de referência.
+ Resolve o target final como (W,H).
+
+ Prioridade:
+ 1. target_size_override do caller (treino/inferência);
+ 2. fusion_config.target_size;
+ 3. tamanho RGB/ref.
+
+ Isso evita fuse -> resize extra no caminho canônico RAW_BRUTO.
"""
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
- fusion = getattr(self, "fusion_config", {}) or {}
- target_size = fusion.get("target_size", None)
- if isinstance(target_size, (list, tuple)) and len(target_size) == 2:
- return int(target_size[0]), int(target_size[1])
+ if (
+ isinstance(target_size_override, (list, tuple))
+ and len(target_size_override) == 2
+ ):
+ tw = int(target_size_override[0])
+ th = int(target_size_override[1])
+
+ if tw <= 0 or th <= 0:
+ raise RuntimeError(
+ f"target_size_override inválido: {target_size_override}"
+ )
+
+ return tw, th
+
+ fusion = getattr(self, "fusion_config", {}) or {}
+ target_size = fusion.get("target_size")
+
+ if (
+ isinstance(target_size, (list, tuple))
+ and len(target_size) == 2
+ ):
+ tw = int(target_size[0])
+ th = int(target_size[1])
+
+ if tw <= 0 or th <= 0:
+ raise RuntimeError(
+ f"fusion_config.target_size inválido: {target_size}"
+ )
+
+ return tw, th
return int(ref_w), int(ref_h)
@@ -4285,48 +6067,85 @@ class RawProcessorCore:
return (0, 0, ref_w, ref_h), False
- def _direct_fusion_crop_to_target_matrix_fast(self, crop_box, target_size):
+ def _direct_fusion_crop_to_target_matrix_fast(
+ self,
+ crop_box,
+ target_size,
+ ):
"""
- Matriz C que leva coordenadas do espaço RGB/ref para a saída final.
+ Matriz C: RGB/reference -> target final.
- crop_box está no espaço da imagem de referência:
- x0,y0,x1,y1
+ Replica crop + cv2.resize com convenção de centro de pixel:
- Queremos:
- x=x0 -> 0
- x=x1 -> target_w
- y=y0 -> 0
- y=y1 -> target_h
-
- Retorna C_ref_to_target.
+ x_crop = x_ref - x0
+ x_target = (x_crop + 0.5) * sx - 0.5
"""
- x0, y0, x1, y1 = [float(v) for v in crop_box]
- target_w, target_h = int(target_size[0]), int(target_size[1])
+ x0, y0, x1, y1 = [
+ float(v)
+ for v in crop_box
+ ]
- crop_w = max(1.0, x1 - x0)
- crop_h = max(1.0, y1 - y0)
+ target_w, target_h = [
+ int(v)
+ for v in target_size
+ ]
- sx = float(target_w) / crop_w
- sy = float(target_h) / crop_h
+ crop_w = max(
+ 1.0,
+ x1 - x0,
+ )
+ crop_h = max(
+ 1.0,
+ y1 - y0,
+ )
- C = np.array(
+ sx = (
+ float(target_w)
+ / crop_w
+ )
+ sy = (
+ float(target_h)
+ / crop_h
+ )
+
+ tx = (
+ -x0 * sx
+ + 0.5 * sx
+ - 0.5
+ )
+ ty = (
+ -y0 * sy
+ + 0.5 * sy
+ - 0.5
+ )
+
+ return np.array(
[
- [sx, 0.0, -x0 * sx],
- [0.0, sy, -y0 * sy],
+ [sx, 0.0, tx],
+ [0.0, sy, ty],
[0.0, 0.0, 1.0],
],
dtype=np.float32,
)
- return C
-
- def _direct_fusion_scale_homography_for_ref_fast(self, H, meta, ref_size):
+ def _direct_fusion_scale_homography_for_ref_fast(
+ self,
+ H,
+ meta,
+ ref_size,
+ ):
"""
- Escala a homografia calibrada para o runtime da referência RGB.
+ Helper legado sem informação da source.
- Usa a própria função existente _scale_homography_to_runtime() se existir.
- Isso mantém compatibilidade com o contrato atual do core.
+ Em produto mixed-resolution ele é ambíguo e portanto proibido.
+ Use _direct_fusion_get_role_homography_fast(role, ..., source_size=...).
"""
+ if getattr(self, "strict_product_contract", False):
+ raise RuntimeError(
+ "_direct_fusion_scale_homography_for_ref_fast() não pode ser usado "
+ "no produto mixed-resolution porque não conhece source_size."
+ )
+
fusion = getattr(self, "fusion_config", {}) or {}
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
@@ -4337,118 +6156,137 @@ class RawProcessorCore:
or None
)
- H = np.asarray(H, dtype=np.float32)
-
- if hasattr(self, "_scale_homography_to_runtime"):
- try:
- return self._scale_homography_to_runtime(
- H,
- calib_size=calib_size,
- runtime_size=(ref_w, ref_h),
- ).astype(np.float32)
- except TypeError:
- try:
- return self._scale_homography_to_runtime(
- H,
- calib_size,
- (ref_w, ref_h),
- ).astype(np.float32)
- except Exception:
- pass
- except Exception:
- pass
-
- # Fallback local.
- if calib_size is None:
- if abs(float(H[2, 2])) > 1e-9:
- H = H / H[2, 2]
- return H.astype(np.float32)
-
- calib_w, calib_h = float(calib_size[0]), float(calib_size[1])
- if calib_w <= 0 or calib_h <= 0:
- return H.astype(np.float32)
-
- sx = float(ref_w) / calib_w
- sy = float(ref_h) / calib_h
-
- S = np.array([[sx, 0.0, 0.0], [0.0, sy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32)
- S_inv = np.array([[1.0 / sx, 0.0, 0.0], [0.0, 1.0 / sy, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32)
-
- H_runtime = S @ H @ S_inv
- if abs(float(H_runtime[2, 2])) > 1e-9:
- H_runtime = H_runtime / H_runtime[2, 2]
-
- return H_runtime.astype(np.float32)
-
- def _direct_fusion_get_role_homography_fast(self, role, meta, ref_size):
- """
- Retorna H_role_to_rgb escalada para o espaço da referência RGB.
-
- Suporta:
- - contrato antigo: fusion_config.homographies.re_to_rgb/nir_to_rgb
- - contrato novo: fusion_config.homography_profiles..homographies.*
- """
- role = str(role).lower()
-
- H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
-
- ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
-
- H_scaled = self._scale_homography_to_runtime(
+ return self._scale_homography_to_runtime(
H,
calib_size=calib_size,
runtime_size=(ref_w, ref_h),
+ ).astype(np.float32)
+
+ def _direct_fusion_get_role_homography_fast(
+ self,
+ role,
+ meta,
+ ref_size,
+ source_size=None,
+ ):
+ """
+ Retorna H_role_to_rgb no espaço REAL de runtime.
+
+ ref_size:
+ (ref_h, ref_w) da imagem RGB já decodificada.
+
+ source_size:
+ (src_h, src_w) da banda RE/NIR REAL, sem resize prévio.
+ """
+ role = str(role).lower()
+ entry = self._resolve_homography_geometry_entry_for_role(role)
+
+ ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
+
+ if source_size is None:
+ src_w, src_h = entry["source_calibration_size"]
+ else:
+ src_h, src_w = int(source_size[0]), int(source_size[1])
+
+ H_scaled = self._scale_homography_to_runtime(
+ entry["H"],
+ calib_source_size=entry["source_calibration_size"],
+ calib_reference_size=entry["reference_calibration_size"],
+ runtime_source_size=(int(src_w), int(src_h)),
+ runtime_reference_size=(int(ref_w), int(ref_h)),
)
if H_scaled is None or H_scaled.shape != (3, 3):
raise RuntimeError(
- f"Homografia inválida para role={role}, profile={profile_name}: "
+ f"Homografia inválida para role={role}, "
+ f"profile={entry['profile_name']}: "
f"shape={None if H_scaled is None else H_scaled.shape}"
)
return H_scaled.astype(np.float32)
- def _direct_fusion_resize_spec_to_ref_if_needed_fast(self, img, ref_size):
+ def _direct_fusion_resize_spec_to_ref_if_needed_fast(
+ self,
+ img,
+ ref_size,
+ ):
"""
- Mantém compatibilidade com o fluxo atual:
- se RE/NIR não estão no mesmo shape do RGB de referência, redimensiona para ref.
+ Compatibilidade legado.
+
+ No pipeline de produto mixed-resolution, pré-resize da banda antes da
+ homografia é proibido. O caminho ativo não chama mais este método.
+
+ Em MP legado, preserva o comportamento histórico.
"""
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
if img.shape[:2] == (ref_h, ref_w):
return img
+ if getattr(self, "strict_product_contract", False):
+ raise RuntimeError(
+ "Pré-resize de RE/NIR para o tamanho RGB é inválido no "
+ "contrato mixed-resolution de produto."
+ )
+
return cv2.resize(
img.astype(np.float32, copy=False),
(ref_w, ref_h),
interpolation=cv2.INTER_LINEAR,
)
- def _direct_fusion_compute_valid_masks_fast(self, decoded, role_to_cam, ref_size, meta):
+ def _direct_fusion_compute_valid_masks_fast(
+ self,
+ decoded,
+ role_to_cam,
+ ref_size,
+ meta,
+ ):
"""
- Calcula máscaras válidas no espaço RGB/ref para crop comum.
- Usa warpPerspective apenas em máscara uint8, que costuma ser barato.
+ Máscaras válidas no espaço RGB/ref para mixed-resolution.
+
+ A máscara de cada banda nasce no tamanho REAL da própria source.
"""
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
- valid_masks = [np.ones((ref_h, ref_w), dtype=np.uint8)]
-
- base = np.ones((ref_h, ref_w), dtype=np.uint8) * 255
+ valid_masks = [
+ np.ones((ref_h, ref_w), dtype=np.uint8)
+ ]
for role in ("re", "nir"):
- if role not in role_to_cam:
+ cam_id = role_to_cam.get(role)
+ if cam_id is None:
continue
- H = self._direct_fusion_get_role_homography_fast(role, meta, ref_size)
+ img = decoded[cam_id]["image"]
+
+ H = self._direct_fusion_get_role_homography_fast(
+ role,
+ meta,
+ ref_size,
+ source_size=img.shape[:2],
+ )
+
+ source_mask = (
+ np.ones(
+ img.shape[:2],
+ dtype=np.uint8,
+ )
+ * 255
+ )
+
mask = cv2.warpPerspective(
- base,
+ source_mask,
H,
(ref_w, ref_h),
flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT,
borderValue=0,
)
- valid_masks.append(mask)
+
+ valid_masks.append(
+ (mask > 0).astype(np.uint8)
+ )
return valid_masks
@@ -4475,23 +6313,34 @@ class RawProcessorCore:
tensor[1] = rgb_out[:, :, 1]
tensor[2] = rgb_out[:, :, 2]
- def _direct_fusion_write_spec_fast(self, tensor, channel_index, img, role, C_ref_to_target, ref_size, target_size, meta):
+ def _direct_fusion_write_spec_fast(
+ self,
+ tensor,
+ channel_index,
+ img,
+ role,
+ C_ref_to_target,
+ ref_size,
+ target_size,
+ meta,
+ ):
"""
- Escreve RE ou NIR direto no tensor final, compondo:
- M = C_ref_to_target @ H_role_to_rgb
+ RE/NIR native -> target final em um único warp.
- img é primeiro redimensionada para ref_size se necessário, para manter o mesmo
- comportamento geométrico do fluxo atual.
+ M = C_ref_to_target @ H_runtime(source_real -> RGB/ref_real)
"""
target_w, target_h = int(target_size[0]), int(target_size[1])
- img_ref = self._direct_fusion_resize_spec_to_ref_if_needed_fast(img, ref_size)
-
- H_role_to_rgb = self._direct_fusion_get_role_homography_fast(role, meta, ref_size)
+ H_role_to_rgb = self._direct_fusion_get_role_homography_fast(
+ role,
+ meta,
+ ref_size,
+ source_size=img.shape[:2],
+ )
M_role_to_target = (C_ref_to_target @ H_role_to_rgb).astype(np.float32)
out = cv2.warpPerspective(
- img_ref.astype(np.float32, copy=False),
+ img.astype(np.float32, copy=False),
M_role_to_target,
(target_w, target_h),
flags=cv2.INTER_LINEAR,
@@ -4501,9 +6350,18 @@ class RawProcessorCore:
tensor[int(channel_index)] = out
- def _fuse_multispec_direct_to_target_fast(self, decoded, meta, channels_expected):
+ def _fuse_multispec_direct_to_target_fast(
+ self,
+ decoded,
+ meta,
+ channels_expected,
+ target_size_override=None,
+ ):
"""
Fusão direta otimizada com cache de geometria fixa.
+
+ target_size_override permite ir diretamente ao tamanho final pedido
+ pelo treino/inferência, evitando um segundo resize posterior.
"""
channels_expected = self._validate_physical_channel_count(channels_expected)
t0 = time.perf_counter()
@@ -4518,7 +6376,10 @@ class RawProcessorCore:
ref_h, ref_w = rgb.shape[:2]
ref_size = (int(ref_h), int(ref_w))
- target_size = self._direct_fusion_get_target_size_fast(ref_size)
+ target_size = self._direct_fusion_get_target_size_fast(
+ ref_size,
+ target_size_override=target_size_override,
+ )
target_w, target_h = int(target_size[0]), int(target_size[1])
role_to_cam = {
@@ -4747,39 +6608,49 @@ class RawProcessorCore:
- def _direct_fusion_get_geometry_cache_key_fast(self, ref_size, target_size, role_to_cam):
+ def _direct_fusion_get_geometry_cache_key_fast(
+ self,
+ decoded,
+ ref_size,
+ target_size,
+ role_to_cam,
+ ):
"""
- Chave simples e estável para cache da geometria.
-
- Considera:
- - tamanho do RGB/ref
- - target final
- - roles presentes
- - crop/resize
- - homografia efetivamente selecionada por perfil
- - calibration_size efetivo por role
+ Mixed-resolution exige que o tamanho REAL de cada source faça parte
+ da chave, pois H_runtime depende de source_size e reference_size.
"""
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
target_w, target_h = int(target_size[0]), int(target_size[1])
fusion = getattr(self, "fusion_config", {}) or {}
- roles = tuple(sorted([str(r).lower() for r in role_to_cam.keys()]))
+ roles = tuple(sorted(str(r).lower() for r in role_to_cam.keys()))
+
+ role_shapes = []
+ for role in roles:
+ cam_id = role_to_cam.get(role)
+ if cam_id is None:
+ continue
+ img = decoded[cam_id].get("image")
+ if img is None:
+ continue
+ role_shapes.append((role, tuple(int(v) for v in img.shape[:2])))
def h_sig_for_role(role):
role = str(role).lower()
-
if role not in role_to_cam:
return None
try:
- H, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
+ e = self._resolve_homography_geometry_entry_for_role(role)
except Exception:
return None
- arr = np.asarray(H, dtype=np.float32).reshape(-1)
+ arr = np.asarray(e["H"], dtype=np.float32).reshape(-1)
return (
- str(profile_name),
- tuple(calib_size or []),
+ str(e["profile_name"]),
+ tuple(e["source_calibration_size"] or []),
+ tuple(e["reference_calibration_size"] or []),
+ str(e.get("coordinate_space") or ""),
tuple(np.round(arr, 8).tolist()),
)
@@ -4789,6 +6660,7 @@ class RawProcessorCore:
target_w,
target_h,
roles,
+ tuple(sorted(role_shapes)),
bool(fusion.get("crop_valid_common", False)),
bool(fusion.get("resize_after_crop", False)),
h_sig_for_role("re"),
@@ -4807,26 +6679,31 @@ class RawProcessorCore:
self._direct_fusion_remap_cache_hits = 0
self._direct_fusion_remap_cache_misses = 0
- def _direct_fusion_get_geometry_cached_fast(self, decoded, role_to_cam, ref_size, target_size, meta):
+ def _direct_fusion_get_geometry_cached_fast(
+ self,
+ decoded,
+ role_to_cam,
+ ref_size,
+ target_size,
+ meta,
+ ):
"""
- Retorna geometria cacheada para a fusão direta.
+ Geometria fixa cacheada.
- Saída:
- geom = {
- key,
- crop_box,
- crop_applied,
- C_ref_to_target,
- H_role_to_rgb: {re,nir},
- M_role_to_target: {re,nir},
- valid_masks, # opcional/debug
- prepare_cache_hit,
- }
+ H_role_to_rgb sempre mapeia:
+ banda no tamanho REAL atual -> RGB/ref atual.
+
+ Nenhum resize de RE/NIR acontece antes da homografia.
"""
if not hasattr(self, "_direct_fusion_geometry_cache"):
self.clear_direct_fusion_geometry_cache()
- key = self._direct_fusion_get_geometry_cache_key_fast(ref_size, target_size, role_to_cam)
+ key = self._direct_fusion_get_geometry_cache_key_fast(
+ decoded,
+ ref_size,
+ target_size,
+ role_to_cam,
+ )
cache = self._direct_fusion_geometry_cache
if key in cache:
@@ -4841,34 +6718,46 @@ class RawProcessorCore:
ref_h, ref_w = int(ref_size[0]), int(ref_size[1])
- # ------------------------------------------------------------
- # Homografias escaladas para runtime.
- # ------------------------------------------------------------
H_role_to_rgb = {}
homography_profiles_used = {}
- for role in ("re", "nir"):
- if role in role_to_cam:
- H_raw, calib_size, profile_name = self._resolve_homography_entry_for_role(role)
- homography_profiles_used[role] = {
- "profile": profile_name,
- "calib_size": list(calib_size) if calib_size is not None else None,
- }
- H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast(role, meta, ref_size)
-
- # ------------------------------------------------------------
- # Máscaras válidas e crop comum.
- # Essa era uma das partes caras e totalmente fixa.
- # ------------------------------------------------------------
- valid_masks = [np.ones((ref_h, ref_w), dtype=np.uint8)]
- base = np.ones((ref_h, ref_w), dtype=np.uint8) * 255
+ source_shapes = {}
for role in ("re", "nir"):
- if role not in H_role_to_rgb:
+ if role not in role_to_cam:
continue
+ cam_id = role_to_cam[role]
+ img = decoded[cam_id]["image"]
+ src_shape = tuple(int(v) for v in img.shape[:2])
+ source_shapes[role] = src_shape
+
+ entry = self._resolve_homography_geometry_entry_for_role(role)
+
+ homography_profiles_used[role] = {
+ "profile": entry["profile_name"],
+ "source_calibration_size": list(entry["source_calibration_size"]),
+ "reference_calibration_size": list(entry["reference_calibration_size"]),
+ "coordinate_space": entry.get("coordinate_space"),
+ "runtime_source_shape": list(src_shape),
+ "runtime_reference_shape": [ref_h, ref_w],
+ }
+
+ H_role_to_rgb[role] = self._direct_fusion_get_role_homography_fast(
+ role,
+ meta,
+ ref_size,
+ source_size=src_shape,
+ )
+
+ valid_masks = [np.ones((ref_h, ref_w), dtype=np.uint8)]
+
+ for role, H in H_role_to_rgb.items():
+ src_h, src_w = source_shapes[role]
+ source_mask = np.ones((src_h, src_w), dtype=np.uint8) * 255
+
mask = cv2.warpPerspective(
- base,
- H_role_to_rgb[role],
+ source_mask,
+ H,
(ref_w, ref_h),
flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT,
@@ -4876,19 +6765,20 @@ class RawProcessorCore:
)
valid_masks.append(mask)
- crop_box, crop_applied = self._direct_fusion_get_crop_box_fast(valid_masks, ref_size)
+ crop_box, crop_applied = self._direct_fusion_get_crop_box_fast(
+ valid_masks,
+ ref_size,
+ )
- # ------------------------------------------------------------
- # Matriz crop RGB/ref -> target final.
- # ------------------------------------------------------------
- C_ref_to_target = self._direct_fusion_crop_to_target_matrix_fast(crop_box, target_size)
+ C_ref_to_target = self._direct_fusion_crop_to_target_matrix_fast(
+ crop_box,
+ target_size,
+ )
- # ------------------------------------------------------------
- # Matrizes compostas role original/ref -> target.
- # ------------------------------------------------------------
- M_role_to_target = {}
- for role, H in H_role_to_rgb.items():
- M_role_to_target[role] = (C_ref_to_target @ H).astype(np.float32)
+ M_role_to_target = {
+ role: (C_ref_to_target @ H).astype(np.float32)
+ for role, H in H_role_to_rgb.items()
+ }
geom = {
"key": key,
@@ -4897,6 +6787,7 @@ class RawProcessorCore:
"C_ref_to_target": C_ref_to_target.astype(np.float32),
"H_role_to_rgb": H_role_to_rgb,
"M_role_to_target": M_role_to_target,
+ "source_shapes": source_shapes,
"valid_masks": valid_masks,
"prepare_cache_hit": False,
"cache_hits": int(self._direct_fusion_geometry_cache_hits),
@@ -4904,28 +6795,33 @@ class RawProcessorCore:
"homography_profiles_used": homography_profiles_used,
}
- # Cache pequeno: normalmente só uma geometria. Se mudar resolução/config,
- # evita crescimento infinito.
if len(cache) > 4:
cache.clear()
cache[key] = geom
return geom
- def _direct_fusion_write_spec_cached_fast(self, tensor, channel_index, img, role, geom, ref_size, target_size):
+ def _direct_fusion_write_spec_cached_fast(
+ self,
+ tensor,
+ channel_index,
+ img,
+ role,
+ geom,
+ ref_size,
+ target_size,
+ ):
"""
- Escreve RE/NIR usando matriz composta cacheada.
+ RE/NIR native -> target final em um único warp.
"""
target_w, target_h = int(target_size[0]), int(target_size[1])
- img_ref = self._direct_fusion_resize_spec_to_ref_if_needed_fast(img, ref_size)
-
M_role_to_target = geom["M_role_to_target"].get(str(role).lower())
if M_role_to_target is None:
raise RuntimeError(f"Matriz composta ausente para role={role}")
out = cv2.warpPerspective(
- img_ref.astype(np.float32, copy=False),
+ img.astype(np.float32, copy=False),
M_role_to_target,
(target_w, target_h),
flags=cv2.INTER_LINEAR,
@@ -5046,7 +6942,10 @@ class RawProcessorCore:
target_size: tuple,
):
"""
- Retorna mapas de remap cacheados para RGB, RE e NIR.
+ Retorna remaps cacheados.
+
+ M_role_to_target já nasce no espaço da source REAL.
+ Não existe resize/compensação posterior.
"""
if not hasattr(self, "_direct_fusion_remap_cache"):
self._direct_fusion_remap_cache = {}
@@ -5083,25 +6982,16 @@ class RawProcessorCore:
"cache_misses": int(self._direct_fusion_remap_cache_misses),
}
- # ------------------------------------------------------------
- # RGB: matriz C_ref_to_target leva RGB/ref -> target.
- # ------------------------------------------------------------
rgb_cam_id = role_to_cam.get("rgb")
if rgb_cam_id is not None:
rgb_img = decoded[rgb_cam_id]["image"]
- C_ref_to_target = geom["C_ref_to_target"]
-
remap["maps"]["rgb"] = self._direct_fusion_build_remap_from_src_to_dst_fast(
- M_src_to_dst=C_ref_to_target,
+ M_src_to_dst=geom["C_ref_to_target"],
src_shape=rgb_img.shape[:2],
dst_size=(target_w, target_h),
ref_shape_for_scaled_src=None,
)
- # ------------------------------------------------------------
- # RE/NIR: matriz composta M_role_to_target leva role/ref -> target.
- # Se a imagem fonte não tiver o mesmo tamanho do ref, escalamos o mapa.
- # ------------------------------------------------------------
for role in ("re", "nir"):
cam_id = role_to_cam.get(role)
if cam_id is None:
@@ -5117,7 +7007,7 @@ class RawProcessorCore:
M_src_to_dst=M_role_to_target,
src_shape=img.shape[:2],
dst_size=(target_w, target_h),
- ref_shape_for_scaled_src=ref_size,
+ ref_shape_for_scaled_src=None,
)
if len(cache) > 4:
@@ -5199,30 +7089,24 @@ class RawProcessorCore:
return "default"
- def _resolve_homography_entry_for_role(self, role: str):
+ def _resolve_homography_geometry_entry_for_role(self, role: str) -> dict:
"""
- Resolve a homografia no contrato novo ou antigo.
+ Resolve H preservando os espaços de calibração de origem e referência.
Contrato novo:
- fusion_config.homography_profiles..homographies._to_rgb
-
- Contrato antigo:
- fusion_config.homographies._to_rgb
-
- Retorna:
- H, calib_size, profile_name
+ fusion_config.homography_profiles.
+ .reference_size
+ .source_size_by_role[re|nir]
+ .homographies._to_rgb
"""
role = str(role).lower()
+ if role not in ("re", "nir"):
+ raise ValueError(f"Role espectral inválida: {role}")
+
fusion = getattr(self, "fusion_config", {}) or {}
-
key = f"{role}_to_rgb"
-
selected_profile = self._resolve_homography_profile_name_for_role(role)
- # ------------------------------------------------------------
- # Futuro: auto por profundidade.
- # Por enquanto, cai em media/default de forma explícita.
- # ------------------------------------------------------------
if selected_profile == "auto":
profiles = fusion.get("homography_profiles", {}) or {}
if "media" in profiles:
@@ -5232,15 +7116,12 @@ class RawProcessorCore:
else:
selected_profile = ""
- # ------------------------------------------------------------
- # Contrato novo: homography_profiles
- # ------------------------------------------------------------
profiles = fusion.get("homography_profiles", {}) or {}
+
if isinstance(profiles, dict) and selected_profile:
profile = profiles.get(selected_profile)
if profile is None:
- # tolera nomes com caixa diferente
for name, item in profiles.items():
if str(name).lower() == selected_profile:
profile = item
@@ -5248,45 +7129,109 @@ class RawProcessorCore:
break
if isinstance(profile, dict):
- profile_homographies = profile.get("homographies", {}) or {}
- H = profile_homographies.get(key)
-
+ homographies = profile.get("homographies", {}) or {}
+ H = homographies.get(key)
if H is None:
- # fallback curto: "re" ou "nir"
- H = profile_homographies.get(role)
+ H = homographies.get(role)
if H is not None:
- calib_size = (
- profile.get("homography_calibration_size")
+ reference_size = (
+ profile.get("reference_size")
+ or profile.get("homography_calibration_size")
or profile.get("calibration_size")
or fusion.get("homography_calibration_size")
or fusion.get("calibration_size")
- or None
)
- return H, calib_size, selected_profile
- # ------------------------------------------------------------
- # Contrato antigo: homographies direto
- # ------------------------------------------------------------
+ source_sizes = profile.get("source_size_by_role", {}) or {}
+ source_size = (
+ source_sizes.get(role)
+ or profile.get("source_size")
+ or reference_size
+ )
+
+ if reference_size is None or source_size is None:
+ raise RuntimeError(
+ f"Perfil {selected_profile!r} sem tamanhos geométricos "
+ f"suficientes para role={role}."
+ )
+
+ if len(reference_size) != 2 or len(source_size) != 2:
+ raise RuntimeError(
+ f"Tamanhos inválidos no perfil {selected_profile!r}: "
+ f"source={source_size}, reference={reference_size}"
+ )
+
+ coordinate_space = (
+ profile.get("coordinate_space")
+ or fusion.get("coordinate_space")
+ or "native_stream_no_external_undistort"
+ )
+
+ H_arr = np.asarray(H, dtype=np.float32)
+ if H_arr.shape != (3, 3) or not np.all(np.isfinite(H_arr)):
+ raise RuntimeError(
+ f"Homografia inválida no perfil={selected_profile} role={role}"
+ )
+
+ return {
+ "H": H_arr,
+ "source_calibration_size": [
+ int(source_size[0]),
+ int(source_size[1]),
+ ],
+ "reference_calibration_size": [
+ int(reference_size[0]),
+ int(reference_size[1]),
+ ],
+ "profile_name": selected_profile,
+ "coordinate_space": str(coordinate_space),
+ }
+
+ # Compatibilidade legado, onde as duas grades tinham o mesmo tamanho.
homographies = fusion.get("homographies", {}) or {}
H = homographies.get(key)
-
if H is None:
H = homographies.get(role)
if H is not None:
- calib_size = (
+ common_size = (
fusion.get("homography_calibration_size")
or fusion.get("calibration_size")
- or None
+ or [self.sensor_width, self.sensor_height]
)
- return H, calib_size, "legacy"
+
+ H_arr = np.asarray(H, dtype=np.float32)
+ if H_arr.shape != (3, 3):
+ raise RuntimeError(f"Homografia legacy inválida para {role}: {H_arr.shape}")
+
+ return {
+ "H": H_arr,
+ "source_calibration_size": [int(common_size[0]), int(common_size[1])],
+ "reference_calibration_size": [int(common_size[0]), int(common_size[1])],
+ "profile_name": "legacy",
+ "coordinate_space": "legacy_common_pixel_space",
+ }
raise RuntimeError(
- f"Homografia ausente para role={role}. "
- f"Procurei profile='{selected_profile}' em "
- f"fusion_config.homography_profiles.*.homographies.{key} "
- f"e fallback fusion_config.homographies.{key}"
+ f"Homografia ausente para role={role}; profile={selected_profile!r}."
+ )
+
+ def _resolve_homography_entry_for_role(self, role: str):
+ """
+ Compatibilidade com callers antigos.
+
+ Retorna:
+ H, reference_calibration_size, profile_name
+
+ A geometria mixed-resolution usa internamente
+ _resolve_homography_geometry_entry_for_role().
+ """
+ entry = self._resolve_homography_geometry_entry_for_role(role)
+ return (
+ entry["H"],
+ entry["reference_calibration_size"],
+ entry["profile_name"],
)
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_1_focus_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_1_focus_calibration_tool.py
new file mode 100644
index 000000000..516328ade
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/utils/_1_focus_calibration_tool.py
@@ -0,0 +1,3990 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+focus_calibration_production.py
+===============================
+
+Ferramenta FINAL de produção/QC para ajuste manual de foco do módulo OAK-FFC-3P.
+
+Topologia oficial do produto
+----------------------------
+ CAM_A = RGB = OV9782 (1280x800) OU AR0234 (1920x1200)
+ CAM_B = RE = OV9282 (1280x800)
+ CAM_C = NIR = OV9282 (1280x800)
+
+Objetivos
+---------
+- Detectar e validar o hardware antes de permitir a calibração.
+- Suportar OV9782 e AR0234 na CAM_A.
+- Mostrar RGB + RE + NIR simultaneamente.
+- Deixar AE estabilizar e depois congelar exposição/ISO.
+- Medir foco SOMENTE quando chega frame novo.
+- Auxiliar o operador a atravessar e reencontrar o pico de foco.
+- Exigir estabilidade próxima ao pico antes do ACCEPT.
+- Medir centro + 4 regiões periféricas para detectar tilt/decentering.
+- Salvar evidência auditável por sessão.
+- Nunca alterar module_params nem qualquer calibração de runtime.
+- Nunca sobrescrever um resultado ativo aprovado com uma sessão FAIL/CANCELLED.
+
+Natureza do artefato
+--------------------
+Foco é um ajuste FÍSICO da lente. Portanto:
+ runtime_effect = "none"
+ module_params_fragment = null
+
+O JSON produzido por esta ferramenta é um relatório de QC/rastreabilidade.
+O assembler final do module_params pode ignorá-lo.
+
+Target recomendado
+------------------
+Para o modo de produção, use um alvo plano com DETALHE FINO distribuído por
+todo o FOV, perpendicular ao eixo óptico. Ex.: chart de foco / Siemens stars /
+checkerboard denso / textura impressa de alta frequência.
+
+Evite avaliar FIELD QA em cena natural com regiões sem textura. A ferramenta
+bloqueia acceptance se alguma das 5 zonas não tiver textura suficiente.
+
+Fluxo
+-----
+1) PRE-FLIGHT
+ - target de foco preenche o FOV
+ - AE estabiliza
+ - valida saturação, escuridão e textura
+ - ENTER congela EXP/ISO das 3 câmeras
+
+2) FOCUS RGB
+ - gire a lente atravessando o pico
+ - volte para >= limiar do melhor score
+ - score deve estabilizar por N frames NOVOS
+ - FIELD QA centro + cantos precisa passar
+ - A solicita acceptance
+
+3) FOCUS RE
+
+4) FOCUS NIR
+
+5) Resultado
+ - PASS: salva candidate + promove relatório QC ativo
+ - FAIL/CANCEL: candidate preservado, ativo anterior mantido
+
+Teclas durante ajuste
+---------------------
+1 / 2 / 3 -> seleciona RGB / RE / NIR
+M -> troca métrica principal
+D -> informa sentido atual (rosqueando/desrosqueando)
+C -> centraliza ROI principal
+L -> trava/destrava ROI
+E -> equalize apenas para diagnóstico; ao mudar, reseta histórico
+R -> reseta sweep/score da câmera ativa
+A -> tenta ACCEPT da câmera ativa
+S -> snapshot de diagnóstico
+Q / ESC -> cancela sessão
+
+IMPORTANTE SOBRE ACCEPTANCE
+---------------------------
+O "best" é relativo à sessão. Para evitar aceitar um foco ruim sem ter
+atravessado o pico, o modo de produção exige:
+ - ter observado um best;
+ - ter caído X% abaixo desse best APÓS o pico (peak bracket);
+ - ter retornado para perto do best;
+ - manter estabilidade por N frames novos;
+ - field QA aprovado.
+
+Há um override de engenharia:
+ --engineering-override
+que permite aceitar sem bracket/field QA, mas isso fica registrado no JSON
+e o resultado não é considerado homologação normal.
+
+Saídas padrão
+-------------
+calibration/focus_candidates//
+ report.json
+ PASS.txt / FAIL.txt / CANCELLED.txt
+ snapshots/
+ rgb_accepted_full.png
+ rgb_accepted_board.png
+ ...
+ re_...
+ nir_...
+
+Se PASS:
+ calibration/focus_qc_active.json
+
+Nenhum NPZ é gerado.
+Nenhuma chave do module_params é alterada.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import shutil
+import time
+from collections import deque
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Optional, Tuple, List, Any
+
+import cv2
+import depthai as dai
+import numpy as np
+
+
+# ============================================================
+# Contrato do produto
+# ============================================================
+
+ROLES = ("rgb", "re", "nir")
+METHODS = ("tenengrad_norm", "laplacian_norm", "tenengrad", "laplacian", "brenner")
+
+PRODUCT_TOPOLOGY = {
+ "rgb": {
+ "socket": "CAM_A",
+ "allowed_sensors": ("OV9782", "AR0234"),
+ },
+ "re": {
+ "socket": "CAM_B",
+ "allowed_sensors": ("OV9282",),
+ },
+ "nir": {
+ "socket": "CAM_C",
+ "allowed_sensors": ("OV9282",),
+ },
+}
+
+RGB_SENSOR_MODES = {
+ "OV9782": {
+ "resolution_enum": "THE_800_P",
+ "width": 1280,
+ "height": 800,
+ },
+ "AR0234": {
+ "resolution_enum": "THE_1200_P",
+ "width": 1920,
+ "height": 1200,
+ },
+}
+
+MONO_SENSOR_MODES = {
+ "OV9282": {
+ "resolution_enum": "THE_800_P",
+ "width": 1280,
+ "height": 800,
+ }
+}
+
+SCHEMA = "multispec_focus_qc_v3"
+
+
+# ============================================================
+# Limiares de produção
+# ============================================================
+
+DEFAULT_QA = {
+ # Preflight imagem
+ "preflight_sat_warning_pct": 0.20,
+ "preflight_sat_bad_pct": 1.00,
+ "preflight_dark_warning_pct": 5.00,
+ "preflight_dark_bad_pct": 15.00,
+
+ # Textura mínima usando std no ROI/zone em escala 0..1
+ "min_texture_std": 0.035,
+ "min_texture_std_warning": 0.050,
+
+ # Bracket do pico
+ "peak_drop_required_pct": 4.0,
+
+ # Se um novo best superar o anterior só por menos que este valor,
+ # tratamos como refinamento/ruído e preservamos o bracket já observado.
+ # Um salto maior significa que achamos uma região de foco nova e exige
+ # atravessar o pico novamente.
+ "new_best_rebracket_pct": 0.020,
+
+ # Retorno ao pico
+ "accept_near_best_ratio": 0.985,
+
+ # Estabilidade do score no lock
+ "accept_stable_frames": 12,
+ "accept_stable_cv": 0.018,
+ "accept_stable_range_pct": 0.050,
+
+ # Field QA é agregado sobre vários frames NOVOS.
+ "field_stable_frames": 8,
+
+ # Field QA, razão do sharpness normalizado da zona / centro
+ "field_corner_ratio_warning": 0.72,
+ "field_corner_ratio_bad": 0.58,
+
+ # Assimetria entre cantos opostos / extremos
+ "field_spread_warning": 0.35,
+ "field_spread_bad": 0.50,
+
+ # Controle manual confirmado
+ "exposure_rel_tolerance": 0.01,
+ "exposure_abs_tolerance_us": 20,
+ "iso_tolerance": 5,
+
+ # FPS
+ "fps_min_ratio": 0.65,
+}
+
+
+# ============================================================
+# Helpers
+# ============================================================
+
+def now_str() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def file_stamp() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+
+
+def ensure_dir(path: str | Path):
+ Path(path).mkdir(parents=True, exist_ok=True)
+
+
+def save_json_atomic(path: str | Path, data: dict):
+ p = Path(path)
+ ensure_dir(p.parent)
+
+ tmp = p.with_suffix(p.suffix + ".tmp")
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.flush()
+ os.fsync(f.fileno())
+
+ os.replace(tmp, p)
+
+
+def sha256_file(path: str | Path) -> str:
+ h = hashlib.sha256()
+ with open(path, "rb") as f:
+ while True:
+ b = f.read(1024 * 1024)
+ if not b:
+ break
+ h.update(b)
+ return h.hexdigest()
+
+
+def safe_int(v, default=None):
+ try:
+ if v is None:
+ return default
+ return int(v)
+ except Exception:
+ return default
+
+
+def safe_float(v, default=None):
+ try:
+ if v is None:
+ return default
+ return float(v)
+ except Exception:
+ return default
+
+
+def status_rank(s: str) -> int:
+ return {"good": 0, "warning": 1, "bad": 2}.get(str(s).lower(), 2)
+
+
+def merge_status(a: str, b: str) -> str:
+ return a if status_rank(a) >= status_rank(b) else b
+
+
+def overlay_hud(
+ img,
+ lines,
+ x=12,
+ y=24,
+ font_scale=0.50,
+ line_step=20,
+ color=(255, 255, 255),
+ shadow=True,
+):
+ yy = int(y)
+ h = img.shape[0]
+
+ for line in lines:
+ if yy > h - 6:
+ break
+
+ text = str(line)
+
+ if shadow:
+ cv2.putText(
+ img,
+ text,
+ (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale,
+ (0, 0, 0),
+ 3,
+ cv2.LINE_AA,
+ )
+
+ cv2.putText(
+ img,
+ text,
+ (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale,
+ color,
+ 1,
+ cv2.LINE_AA,
+ )
+
+ yy += int(line_step)
+
+
+def build_empty_panel(shape_hw, title, subtitle="sem frame"):
+ h, w = shape_hw
+ img = np.zeros((h, w, 3), dtype=np.uint8)
+ overlay_hud(
+ img,
+ [title, subtitle],
+ x=20,
+ y=46,
+ font_scale=0.72,
+ line_step=30,
+ )
+ return img
+
+
+def socket_name(socket) -> str:
+ name = getattr(socket, "name", None)
+ if name:
+ return str(name)
+
+ s = str(socket)
+ for candidate in ("CAM_A", "CAM_B", "CAM_C", "CAM_D"):
+ if candidate in s:
+ return candidate
+
+ return s
+
+
+def get_socket(name: str):
+ name = str(name).upper().strip()
+
+ mapping = {
+ "CAM_A": dai.CameraBoardSocket.CAM_A,
+ "CAM_B": dai.CameraBoardSocket.CAM_B,
+ "CAM_C": dai.CameraBoardSocket.CAM_C,
+ }
+
+ if name not in mapping:
+ raise ValueError(f"Socket inválido: {name}")
+
+ return mapping[name]
+
+
+def supported_type_strings(feature) -> List[str]:
+ return [
+ str(v).upper()
+ for v in (getattr(feature, "supportedTypes", []) or [])
+ ]
+
+
+def feature_is_color(feature) -> bool:
+ types = supported_type_strings(feature)
+ sensor = str(getattr(feature, "sensorName", "") or "").upper()
+
+ if any("COLOR" in t for t in types):
+ return True
+
+ if any("MONO" in t for t in types):
+ return False
+
+ return sensor in {"OV9782", "AR0234"}
+
+
+def feature_is_mono(feature) -> bool:
+ types = supported_type_strings(feature)
+
+ if any("MONO" in t for t in types):
+ return True
+
+ if any("COLOR" in t for t in types):
+ return False
+
+ return not feature_is_color(feature)
+
+
+def resize_panel(img: np.ndarray, target_hw: Tuple[int, int]) -> np.ndarray:
+ th, tw = target_hw
+ return cv2.resize(img, (tw, th), interpolation=cv2.INTER_AREA)
+
+
+def to_gray01(img_bgr: np.ndarray) -> Optional[np.ndarray]:
+ if img_bgr is None:
+ return None
+
+ if img_bgr.ndim == 2:
+ gray = img_bgr
+
+ elif img_bgr.ndim == 3 and img_bgr.shape[2] == 1:
+ gray = img_bgr[:, :, 0]
+
+ else:
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
+
+ return np.clip(gray.astype(np.float32) / 255.0, 0.0, 1.0)
+
+
+def default_roi_for_shape(shape_hw, frac=0.36):
+ h, w = shape_hw
+
+ rw = max(16, int(round(w * frac)))
+ rh = max(16, int(round(h * frac)))
+
+ x0 = (w - rw) // 2
+ y0 = (h - rh) // 2
+
+ return (x0, y0, x0 + rw, y0 + rh)
+
+
+def sanitize_roi(rect, shape_hw):
+ if rect is None:
+ return None
+
+ h, w = shape_hw
+ x0, y0, x1, y1 = rect
+
+ x0, x1 = sorted((int(x0), int(x1)))
+ y0, y1 = sorted((int(y0), int(y1)))
+
+ x0 = max(0, min(w - 1, x0))
+ x1 = max(1, min(w, x1))
+ y0 = max(0, min(h - 1, y0))
+ y1 = max(1, min(h, y1))
+
+ if x1 - x0 < 12 or y1 - y0 < 12:
+ return None
+
+ return (x0, y0, x1, y1)
+
+
+def crop_rect(img, rect):
+ if img is None:
+ return None
+
+ r = sanitize_roi(rect, img.shape[:2])
+ if r is None:
+ return None
+
+ x0, y0, x1, y1 = r
+ return img[y0:y1, x0:x1]
+
+
+def rect_from_norm(shape_hw, x0, y0, x1, y1):
+ h, w = shape_hw
+ return (
+ int(round(x0 * w)),
+ int(round(y0 * h)),
+ int(round(x1 * w)),
+ int(round(y1 * h)),
+ )
+
+
+# ============================================================
+# Descoberta e contrato de hardware
+# ============================================================
+
+@dataclass
+class SensorSpec:
+ role: str
+ socket_name: str
+ sensor_name: str
+ feature_width: int
+ feature_height: int
+ configured_width: int
+ configured_height: int
+ resolution_name: str
+ stream_name: str
+ control_name: str
+ is_color: bool
+ source: str
+
+
+def discover_cameras(mx_id: Optional[str]):
+ device_info = dai.DeviceInfo(mx_id) if mx_id else None
+
+ ctx = dai.Device(device_info) if device_info is not None else dai.Device()
+
+ with ctx as device:
+ features = list(device.getConnectedCameraFeatures())
+
+ actual_mx = None
+ for attr in ("getMxId", "getDeviceId"):
+ fn = getattr(device, attr, None)
+ if callable(fn):
+ try:
+ v = fn()
+ if v:
+ actual_mx = str(v)
+ break
+ except Exception:
+ pass
+
+ usb_speed = None
+ try:
+ usb_speed = str(device.getUsbSpeed())
+ except Exception:
+ pass
+
+ rows = []
+
+ for f in features:
+ rows.append({
+ "socket_obj": f.socket,
+ "socket_name": socket_name(f.socket),
+ "sensor_name": str(getattr(f, "sensorName", "") or "").upper(),
+ "width": int(getattr(f, "width", 0) or 0),
+ "height": int(getattr(f, "height", 0) or 0),
+ "supported_types": supported_type_strings(f),
+ "is_color": feature_is_color(f),
+ "is_mono": feature_is_mono(f),
+ "has_autofocus_ic": int(getattr(f, "hasAutofocusIC", 0) or 0),
+ })
+
+ return rows, actual_mx, usb_speed
+
+
+def enum_if_exists(enum_cls, name: str):
+ return getattr(enum_cls, name, None)
+
+
+def product_sensor_mode(sensor_name: str, is_color: bool):
+ sensor = str(sensor_name).upper()
+
+ table = RGB_SENSOR_MODES if is_color else MONO_SENSOR_MODES
+
+ if sensor not in table:
+ raise RuntimeError(
+ f"Sensor não homologado para esta role: {sensor}"
+ )
+
+ return table[sensor]
+
+
+def validate_product_topology(camera_rows: list[dict], relaxed=False):
+ by_socket = {
+ row["socket_name"]: row
+ for row in camera_rows
+ }
+
+ errors = []
+
+ for role in ROLES:
+ contract = PRODUCT_TOPOLOGY[role]
+ sock = contract["socket"]
+
+ row = by_socket.get(sock)
+
+ if row is None:
+ errors.append(f"{role.upper()}: {sock} ausente")
+ continue
+
+ sensor = row["sensor_name"].upper()
+
+ if sensor not in contract["allowed_sensors"]:
+ errors.append(
+ f"{role.upper()}: {sock} sensor={sensor}, "
+ f"permitidos={contract['allowed_sensors']}"
+ )
+
+ if role == "rgb" and not row["is_color"]:
+ errors.append(f"RGB: {sock}/{sensor} não anunciado como COLOR")
+
+ if role in ("re", "nir") and not row["is_mono"]:
+ errors.append(f"{role.upper()}: {sock}/{sensor} não anunciado como MONO")
+
+ if errors and not relaxed:
+ raise RuntimeError(
+ "Topologia do módulo não corresponde ao produto:\n - "
+ + "\n - ".join(errors)
+ )
+
+ return {
+ "valid": len(errors) == 0,
+ "relaxed": bool(relaxed),
+ "errors": errors,
+ }
+
+
+def build_specs(camera_rows) -> Dict[str, SensorSpec]:
+ by_socket = {
+ row["socket_name"]: row
+ for row in camera_rows
+ }
+
+ specs = {}
+
+ for role in ROLES:
+ socket = PRODUCT_TOPOLOGY[role]["socket"]
+ row = by_socket.get(socket)
+
+ if row is None:
+ continue
+
+ is_color = role == "rgb"
+
+ mode = product_sensor_mode(
+ row["sensor_name"],
+ is_color=is_color,
+ )
+
+ # A dimensão anunciada pelo DepthAI deve ser coerente quando disponível.
+ if row["width"] and row["height"]:
+ if (row["width"], row["height"]) != (mode["width"], mode["height"]):
+ raise RuntimeError(
+ f"{socket}/{row['sensor_name']}: dimensão anunciada "
+ f"{row['width']}x{row['height']} != homologada "
+ f"{mode['width']}x{mode['height']}"
+ )
+
+ specs[role] = SensorSpec(
+ role=role,
+ socket_name=socket,
+ sensor_name=row["sensor_name"],
+ feature_width=row["width"],
+ feature_height=row["height"],
+ configured_width=mode["width"],
+ configured_height=mode["height"],
+ resolution_name=mode["resolution_enum"],
+ stream_name=f"focus_{role}",
+ control_name=f"focus_ctrl_{role}",
+ is_color=is_color,
+ source="ISP" if is_color else "MONO_OUT",
+ )
+
+ missing = [r for r in ROLES if r not in specs]
+ if missing:
+ raise RuntimeError(f"Não foi possível montar specs das roles: {missing}")
+
+ return specs
+
+
+# ============================================================
+# Pipeline e controles
+# ============================================================
+
+def try_set_initial_isp_controls(cam, args) -> dict:
+ """
+ ISP determinístico na RGB.
+
+ Esses parâmetros afetam diretamente energia de borda, portanto a ferramenta
+ tenta fixá-los. Se a versão do DepthAI não expuser algum controle, por
+ padrão a calibração aborta. --allow-isp-defaults relaxa isso.
+ """
+ result = {
+ "sharpness": None,
+ "luma_denoise": None,
+ "chroma_denoise": None,
+ "errors": [],
+ }
+
+ operations = (
+ ("sharpness", "setSharpness", int(args.isp_sharpness)),
+ ("luma_denoise", "setLumaDenoise", int(args.isp_luma_denoise)),
+ ("chroma_denoise", "setChromaDenoise", int(args.isp_chroma_denoise)),
+ )
+
+ for key, method_name, value in operations:
+ fn = getattr(cam.initialControl, method_name, None)
+
+ if not callable(fn):
+ result["errors"].append(f"{method_name}:unsupported")
+ continue
+
+ try:
+ fn(value)
+ result[key] = value
+ except Exception as exc:
+ result["errors"].append(f"{method_name}:{exc}")
+
+ if result["errors"] and not args.allow_isp_defaults:
+ raise RuntimeError(
+ "Não foi possível fixar controles ISP necessários para repetibilidade: "
+ + "; ".join(result["errors"])
+ + ". Use --allow-isp-defaults somente como override de engenharia."
+ )
+
+ return result
+
+
+def build_pipeline(specs: Dict[str, SensorSpec], args):
+ pipeline = dai.Pipeline()
+
+ isp_settings = None
+
+ for role in ROLES:
+ spec = specs[role]
+ socket = get_socket(spec.socket_name)
+
+ if spec.is_color:
+ cam = pipeline.createColorCamera()
+ cam.setBoardSocket(socket)
+
+ enum_value = enum_if_exists(
+ dai.ColorCameraProperties.SensorResolution,
+ spec.resolution_name,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ f"DepthAI sem ColorCamera {spec.resolution_name}"
+ )
+
+ cam.setResolution(enum_value)
+ cam.setFps(float(args.fps))
+ cam.setInterleaved(False)
+
+ try:
+ cam.setColorOrder(
+ dai.ColorCameraProperties.ColorOrder.BGR
+ )
+ except Exception:
+ pass
+
+ # AE/AWB ativos somente para preflight.
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+
+ try:
+ cam.initialControl.setAutoWhiteBalanceLock(False)
+ except Exception:
+ pass
+
+ isp_settings = try_set_initial_isp_controls(cam, args)
+
+ output = cam.isp
+
+ else:
+ cam = pipeline.createMonoCamera()
+ cam.setBoardSocket(socket)
+
+ enum_value = enum_if_exists(
+ dai.MonoCameraProperties.SensorResolution,
+ spec.resolution_name,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ f"DepthAI sem MonoCamera {spec.resolution_name}"
+ )
+
+ cam.setResolution(enum_value)
+ cam.setFps(float(args.fps))
+
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+
+ output = cam.out
+
+ xout = pipeline.createXLinkOut()
+ xout.setStreamName(spec.stream_name)
+ output.link(xout.input)
+
+ xin = pipeline.createXLinkIn()
+ xin.setStreamName(spec.control_name)
+
+ if not hasattr(cam, "inputControl"):
+ raise RuntimeError(
+ f"{spec.socket_name}/{spec.sensor_name} sem inputControl"
+ )
+
+ xin.out.link(cam.inputControl)
+
+ return pipeline, isp_settings
+
+
+@dataclass
+class LockedControl:
+ role: str
+ exposure_time_us: int
+ sensitivity_iso: int
+ source: str
+
+
+def packet_controls(packet) -> dict:
+ exp_us = None
+ iso = None
+ color_temp = None
+ seq = None
+ timestamp = None
+
+ try:
+ exp = packet.getExposureTime()
+ if hasattr(exp, "total_seconds"):
+ exp_us = int(round(exp.total_seconds() * 1_000_000))
+ else:
+ exp_us = int(exp)
+ except Exception:
+ pass
+
+ try:
+ iso = int(packet.getSensitivity())
+ except Exception:
+ pass
+
+ try:
+ color_temp = int(packet.getColorTemperature())
+ except Exception:
+ pass
+
+ try:
+ seq = int(packet.getSequenceNum())
+ except Exception:
+ pass
+
+ for method in ("getTimestampDevice", "getTimestamp"):
+ fn = getattr(packet, method, None)
+ if callable(fn):
+ try:
+ ts = fn()
+ if hasattr(ts, "total_seconds"):
+ timestamp = float(ts.total_seconds())
+ else:
+ timestamp = float(ts)
+ break
+ except Exception:
+ pass
+
+ return {
+ "exposure_time_us": exp_us,
+ "sensitivity_iso": iso,
+ "color_temperature_k": color_temp,
+ "sequence_num": seq,
+ "timestamp_s": timestamp,
+ }
+
+
+def send_manual_control(queue, lock: LockedControl, rgb=False):
+ ctrl = dai.CameraControl()
+
+ ctrl.setManualExposure(
+ int(lock.exposure_time_us),
+ int(lock.sensitivity_iso),
+ )
+
+ if rgb:
+ try:
+ ctrl.setAutoWhiteBalanceLock(True)
+ except Exception:
+ pass
+
+ queue.send(ctrl)
+
+
+def controls_match(actual: dict, target: LockedControl, qa: dict):
+ exp = safe_int(actual.get("exposure_time_us"), None)
+ iso = safe_int(actual.get("sensitivity_iso"), None)
+
+ if exp is None or iso is None:
+ return False
+
+ exp_tol = max(
+ int(qa["exposure_abs_tolerance_us"]),
+ int(round(target.exposure_time_us * qa["exposure_rel_tolerance"])),
+ )
+
+ if abs(exp - target.exposure_time_us) > exp_tol:
+ return False
+
+ if abs(iso - target.sensitivity_iso) > int(qa["iso_tolerance"]):
+ return False
+
+ return True
+
+
+# ============================================================
+# Métricas de foco
+# ============================================================
+
+def preprocess_gray(gray01: np.ndarray, equalize=False):
+ g = np.clip(gray01 * 255.0, 0, 255).astype(np.uint8)
+
+ if equalize:
+ g = cv2.equalizeHist(g)
+
+ return g
+
+
+def raw_focus_metrics_from_gray(gray01: np.ndarray, equalize=False):
+ if gray01 is None or gray01.size < 64:
+ return {
+ "valid": False,
+ "laplacian": 0.0,
+ "tenengrad": 0.0,
+ "brenner": 0.0,
+ "laplacian_norm": 0.0,
+ "tenengrad_norm": 0.0,
+ "mean": 0.0,
+ "std": 0.0,
+ "p95": 0.0,
+ "pct_saturated": 0.0,
+ "pct_dark": 0.0,
+ "pixels": 0,
+ }
+
+ arr01 = np.clip(gray01.astype(np.float32), 0.0, 1.0)
+ g = preprocess_gray(arr01, equalize=equalize)
+
+ lap = cv2.Laplacian(g, cv2.CV_64F, ksize=3)
+ lap_var = float(lap.var())
+
+ sx = cv2.Sobel(g, cv2.CV_64F, 1, 0, ksize=3)
+ sy = cv2.Sobel(g, cv2.CV_64F, 0, 1, ksize=3)
+ ten = float(np.mean(sx * sx + sy * sy))
+
+ arr_u8 = g.astype(np.float32)
+
+ if arr_u8.shape[1] >= 3:
+ diff = arr_u8[:, 2:] - arr_u8[:, :-2]
+ brenner = float(np.mean(diff * diff))
+ else:
+ brenner = 0.0
+
+ std01 = float(arr01.std())
+
+ # Normalização por contraste local.
+ # Evita que uma zona ganhe score só porque o desenho impresso é mais contrastado.
+ variance_u8 = max(float(np.var(arr_u8)), 1.0)
+
+ return {
+ "valid": True,
+ "laplacian": lap_var,
+ "tenengrad": ten,
+ "brenner": brenner,
+ "laplacian_norm": float(lap_var / variance_u8),
+ "tenengrad_norm": float(ten / variance_u8),
+ "mean": float(arr01.mean()),
+ "std": std01,
+ "p95": float(np.percentile(arr01, 95)),
+ "pct_saturated": float((arr01 >= 0.98).mean() * 100.0),
+ "pct_dark": float((arr01 <= 0.02).mean() * 100.0),
+ "pixels": int(arr01.size),
+ }
+
+
+def compute_focus_metrics(img_bgr, roi_rect, equalize=False):
+ gray = to_gray01(img_bgr)
+
+ if gray is None:
+ return raw_focus_metrics_from_gray(None)
+
+ roi = crop_rect(gray, roi_rect)
+
+ return raw_focus_metrics_from_gray(
+ roi,
+ equalize=equalize,
+ )
+
+
+def metric_value(metrics: dict, method: str) -> float:
+ return float(metrics.get(method, 0.0) or 0.0)
+
+
+def smoothed_score(history, window: int) -> float:
+ if not history:
+ return 0.0
+
+ vals = [
+ float(x["score"])
+ for x in list(history)[-max(1, int(window)):]
+ ]
+
+ return float(np.mean(vals))
+
+
+def stable_stats(history, frames_count: int) -> dict:
+ n = int(frames_count)
+
+ if len(history) < n:
+ return {
+ "ready": False,
+ "count": len(history),
+ "needed": n,
+ "mean": 0.0,
+ "std": 0.0,
+ "cv": float("inf"),
+ "range_pct": float("inf"),
+ }
+
+ vals = np.array(
+ [float(x["smooth"]) for x in list(history)[-n:]],
+ dtype=np.float64,
+ )
+
+ mean = float(np.mean(vals))
+ std = float(np.std(vals))
+
+ cv = std / max(abs(mean), 1e-12)
+ range_pct = float((np.max(vals) - np.min(vals)) / max(abs(mean), 1e-12))
+
+ return {
+ "ready": True,
+ "count": n,
+ "needed": n,
+ "mean": mean,
+ "std": std,
+ "cv": float(cv),
+ "range_pct": range_pct,
+ }
+
+
+# ============================================================
+# Sweep / bracket do pico
+# ============================================================
+
+@dataclass
+class SweepState:
+ best_score: float = 0.0
+ best_raw_score: float = 0.0
+ best_timestamp: Optional[str] = None
+ best_seq: Optional[int] = None
+ best_metrics: Optional[dict] = None
+ best_roi: Optional[List[int]] = None
+
+ peak_drop_observed: bool = False
+ lowest_ratio_after_best: float = 1.0
+
+ frames_scored: int = 0
+
+
+def update_sweep(
+ sweep: SweepState,
+ score: float,
+ smooth: float,
+ metrics: dict,
+ roi,
+ sequence_num,
+ drop_required_pct: float,
+ new_best_rebracket_pct: float,
+):
+ sweep.frames_scored += 1
+
+ if smooth > sweep.best_score:
+ previous_best = float(sweep.best_score)
+ had_bracket = bool(sweep.peak_drop_observed)
+ previous_lowest_ratio = float(sweep.lowest_ratio_after_best)
+
+ improvement = (
+ float("inf")
+ if previous_best <= 0
+ else (float(smooth) / previous_best) - 1.0
+ )
+
+ sweep.best_score = float(smooth)
+ sweep.best_raw_score = float(score)
+ sweep.best_timestamp = now_str()
+ sweep.best_seq = safe_int(sequence_num, None)
+ sweep.best_metrics = dict(metrics)
+ sweep.best_roi = list(map(int, roi)) if roi else None
+
+ # Primeiro best obviamente não tem bracket.
+ if previous_best <= 0:
+ sweep.lowest_ratio_after_best = 1.0
+ sweep.peak_drop_observed = False
+
+ # Pequeno refinamento do mesmo pico: preserva bracket já demonstrado.
+ elif (
+ had_bracket
+ and improvement <= float(new_best_rebracket_pct)
+ ):
+ sweep.lowest_ratio_after_best = previous_lowest_ratio
+ sweep.peak_drop_observed = True
+
+ # Novo patamar significativamente melhor: precisa bracketar de novo.
+ else:
+ sweep.lowest_ratio_after_best = 1.0
+ sweep.peak_drop_observed = False
+
+ return
+
+ if sweep.best_score <= 0:
+ return
+
+ ratio = float(smooth / sweep.best_score)
+ sweep.lowest_ratio_after_best = min(
+ sweep.lowest_ratio_after_best,
+ ratio,
+ )
+
+ required_ratio = 1.0 - float(drop_required_pct) / 100.0
+
+ if ratio <= required_ratio:
+ sweep.peak_drop_observed = True
+
+
+def analyze_trend(
+ history,
+ sweep: SweepState,
+ direction_name: str,
+ drop_warn_pct=3.0,
+):
+ if len(history) < 6:
+ return {
+ "status": "coletando",
+ "instruction": "gire devagar",
+ "pct_of_best": 0.0,
+ "delta": 0.0,
+ }
+
+ recent = [
+ float(x["smooth"])
+ for x in list(history)[-5:]
+ ]
+
+ if len(history) >= 12:
+ old = [
+ float(x["smooth"])
+ for x in list(history)[-12:-7]
+ ]
+ else:
+ old = [
+ float(x["smooth"])
+ for x in list(history)[:5]
+ ]
+
+ recent_mean = float(np.mean(recent))
+ old_mean = float(np.mean(old))
+ delta = recent_mean - old_mean
+
+ best = float(sweep.best_score)
+
+ ratio = 0.0 if best <= 0 else recent_mean / best
+ drop_pct = 100.0 * (1.0 - ratio)
+
+ if best > 0 and drop_pct >= float(drop_warn_pct):
+ return {
+ "status": "passou_do_pico",
+ "instruction": f"volte: contrario de {direction_name}",
+ "pct_of_best": ratio * 100.0,
+ "delta": delta,
+ }
+
+ eps = max(best * 0.002, 1e-6)
+
+ if delta > eps:
+ status = "melhorando"
+ instruction = f"continue {direction_name}"
+
+ elif delta < -eps:
+ status = "piorando"
+ instruction = f"inverta: contrario de {direction_name}"
+
+ else:
+ status = "estavel"
+ instruction = "ajuste fino"
+
+ return {
+ "status": status,
+ "instruction": instruction,
+ "pct_of_best": ratio * 100.0,
+ "delta": delta,
+ }
+
+
+# ============================================================
+# FIELD QA
+# ============================================================
+
+FIELD_ZONES_NORM = {
+ "center": (0.34, 0.34, 0.66, 0.66),
+
+ # Evita os extremos absolutos do FOV.
+ "top_left": (0.08, 0.08, 0.30, 0.30),
+ "top_right": (0.70, 0.08, 0.92, 0.30),
+ "bottom_left": (0.08, 0.70, 0.30, 0.92),
+ "bottom_right": (0.70, 0.70, 0.92, 0.92),
+}
+
+
+def compute_field_qa(
+ img_bgr: np.ndarray,
+ qa: dict,
+ equalize=False,
+):
+ gray = to_gray01(img_bgr)
+
+ if gray is None:
+ return {
+ "status": "bad",
+ "reasons": ["sem_frame"],
+ "zones": {},
+ }
+
+ zones = {}
+
+ for name, nrect in FIELD_ZONES_NORM.items():
+ rect = rect_from_norm(
+ gray.shape[:2],
+ *nrect,
+ )
+
+ roi = crop_rect(gray, rect)
+
+ metrics = raw_focus_metrics_from_gray(
+ roi,
+ equalize=equalize,
+ )
+
+ texture = float(metrics.get("std", 0.0))
+ sharp = float(metrics.get("tenengrad_norm", 0.0))
+
+ zones[name] = {
+ "rect": list(rect),
+ "texture_std": texture,
+ "sharpness": sharp,
+ "metrics": metrics,
+ }
+
+ status = "good"
+ reasons = []
+
+ # Textura suficiente em todas as zonas.
+ for name, item in zones.items():
+ texture = item["texture_std"]
+
+ if texture < qa["min_texture_std"]:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"{name}:insufficient_texture:{texture:.4f}"
+ )
+
+ elif texture < qa["min_texture_std_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"{name}:low_texture:{texture:.4f}"
+ )
+
+ center = max(
+ float(zones["center"]["sharpness"]),
+ 1e-9,
+ )
+
+ corner_names = (
+ "top_left",
+ "top_right",
+ "bottom_left",
+ "bottom_right",
+ )
+
+ corner_ratios = {}
+
+ for name in corner_names:
+ ratio = float(
+ zones[name]["sharpness"] / center
+ )
+
+ corner_ratios[name] = ratio
+ zones[name]["ratio_to_center"] = ratio
+
+ if ratio < qa["field_corner_ratio_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"{name}:corner_ratio_bad:{ratio:.3f}"
+ )
+
+ elif ratio < qa["field_corner_ratio_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"{name}:corner_ratio_warn:{ratio:.3f}"
+ )
+
+ vals = np.array(
+ [corner_ratios[x] for x in corner_names],
+ dtype=np.float64,
+ )
+
+ spread = float(
+ (np.max(vals) - np.min(vals))
+ / max(float(np.median(vals)), 1e-9)
+ )
+
+ if spread > qa["field_spread_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"corner_spread_bad:{spread:.3f}"
+ )
+
+ elif spread > qa["field_spread_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"corner_spread_warn:{spread:.3f}"
+ )
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "zones": zones,
+ "corner_ratios": corner_ratios,
+ "corner_spread": spread,
+ "samples": 1,
+ }
+
+
+def aggregate_field_qa(
+ reports,
+ qa: dict,
+):
+ """
+ Agrega FIELD QA por mediana sobre vários frames NOVOS.
+
+ Isso evita aprovar/reprovar tilt/decentering por ruído de um frame isolado.
+ """
+ reports = list(reports or [])
+
+ needed = int(qa["field_stable_frames"])
+
+ if len(reports) < needed:
+ return {
+ "status": "collecting",
+ "reasons": [
+ f"field_samples:{len(reports)}/{needed}"
+ ],
+ "zones": {},
+ "corner_ratios": {},
+ "corner_spread": None,
+ "samples": len(reports),
+ "needed": needed,
+ }
+
+ recent = reports[-needed:]
+
+ zone_names = tuple(FIELD_ZONES_NORM.keys())
+ zones = {}
+
+ for name in zone_names:
+ textures = []
+ sharpness = []
+ ratios = []
+
+ for report in recent:
+ item = report.get("zones", {}).get(name, {})
+
+ if item.get("texture_std") is not None:
+ textures.append(
+ float(item["texture_std"])
+ )
+
+ if item.get("sharpness") is not None:
+ sharpness.append(
+ float(item["sharpness"])
+ )
+
+ if item.get("ratio_to_center") is not None:
+ ratios.append(
+ float(item["ratio_to_center"])
+ )
+
+ zones[name] = {
+ "texture_std": (
+ float(np.median(textures))
+ if textures
+ else 0.0
+ ),
+ "sharpness": (
+ float(np.median(sharpness))
+ if sharpness
+ else 0.0
+ ),
+ }
+
+ if name == "center":
+ zones[name]["ratio_to_center"] = 1.0
+ elif ratios:
+ zones[name]["ratio_to_center"] = float(
+ np.median(ratios)
+ )
+
+ status = "good"
+ reasons = []
+
+ for name, item in zones.items():
+ texture = float(
+ item.get("texture_std", 0.0)
+ )
+
+ if texture < qa["min_texture_std"]:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"{name}:insufficient_texture:{texture:.4f}"
+ )
+
+ elif texture < qa["min_texture_std_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"{name}:low_texture:{texture:.4f}"
+ )
+
+ corner_names = (
+ "top_left",
+ "top_right",
+ "bottom_left",
+ "bottom_right",
+ )
+
+ corner_ratios = {}
+
+ for name in corner_names:
+ ratio = float(
+ zones[name].get("ratio_to_center", 0.0)
+ )
+
+ corner_ratios[name] = ratio
+
+ if ratio < qa["field_corner_ratio_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"{name}:corner_ratio_bad:{ratio:.3f}"
+ )
+
+ elif ratio < qa["field_corner_ratio_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"{name}:corner_ratio_warn:{ratio:.3f}"
+ )
+
+ vals = np.array(
+ list(corner_ratios.values()),
+ dtype=np.float64,
+ )
+
+ spread = float(
+ (np.max(vals) - np.min(vals))
+ / max(float(np.median(vals)), 1e-9)
+ )
+
+ if spread > qa["field_spread_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"corner_spread_bad:{spread:.3f}"
+ )
+
+ elif spread > qa["field_spread_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"corner_spread_warn:{spread:.3f}"
+ )
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "zones": zones,
+ "corner_ratios": corner_ratios,
+ "corner_spread": spread,
+ "samples": needed,
+ "needed": needed,
+ }
+
+
+# ============================================================
+# Preflight
+# ============================================================
+
+def image_preflight(img_bgr, qa: dict):
+ gray = to_gray01(img_bgr)
+
+ if gray is None:
+ return {
+ "status": "bad",
+ "reasons": ["sem_frame"],
+ }
+
+ arr = gray.reshape(-1)
+
+ mean = float(np.mean(arr))
+ std = float(np.std(arr))
+ p95 = float(np.percentile(arr, 95))
+ sat_pct = float((arr >= 0.98).mean() * 100.0)
+ dark_pct = float((arr <= 0.02).mean() * 100.0)
+
+ status = "good"
+ reasons = []
+
+ if sat_pct > qa["preflight_sat_bad_pct"]:
+ status = "bad"
+ reasons.append(f"sat_bad:{sat_pct:.2f}%")
+
+ elif sat_pct > qa["preflight_sat_warning_pct"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"sat_warn:{sat_pct:.2f}%")
+
+ if dark_pct > qa["preflight_dark_bad_pct"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"dark_bad:{dark_pct:.2f}%")
+
+ elif dark_pct > qa["preflight_dark_warning_pct"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"dark_warn:{dark_pct:.2f}%")
+
+ # Full frame precisa ter alguma textura.
+ if std < qa["min_texture_std"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"texture_bad:{std:.4f}")
+
+ elif std < qa["min_texture_std_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"texture_warn:{std:.4f}")
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "mean": mean,
+ "std": std,
+ "p95": p95,
+ "sat_pct": sat_pct,
+ "dark_pct": dark_pct,
+ }
+
+
+def merge_preflight_by_role(frames, qa):
+ result = {
+ "status": "good",
+ "roles": {},
+ "reasons": [],
+ }
+
+ for role in ROLES:
+ r = image_preflight(frames.get(role), qa)
+ result["roles"][role] = r
+ result["status"] = merge_status(
+ result["status"],
+ r["status"],
+ )
+ result["reasons"].extend(
+ [f"{role}:{x}" for x in r.get("reasons", [])]
+ )
+
+ return result
+
+
+# ============================================================
+# Estado de runtime
+# ============================================================
+
+class FocusRuntime:
+ def __init__(
+ self,
+ device,
+ specs: Dict[str, SensorSpec],
+ args,
+ qa,
+ ):
+ self.device = device
+ self.specs = specs
+ self.args = args
+ self.qa = qa
+
+ self.output_queues = {
+ role: device.getOutputQueue(
+ name=spec.stream_name,
+ maxSize=2,
+ blocking=False,
+ )
+ for role, spec in specs.items()
+ }
+
+ self.control_queues = {
+ role: device.getInputQueue(
+ spec.control_name
+ )
+ for role, spec in specs.items()
+ }
+
+ self.frames = {
+ role: None
+ for role in ROLES
+ }
+
+ self.controls = {
+ role: {}
+ for role in ROLES
+ }
+
+ self.last_seq = {
+ role: None
+ for role in ROLES
+ }
+
+ self.frame_counts = {
+ role: 0
+ for role in ROLES
+ }
+
+ self.fps = {
+ role: 0.0
+ for role in ROLES
+ }
+
+ self._fps_count = {
+ role: 0
+ for role in ROLES
+ }
+
+ self._fps_t0 = {
+ role: time.time()
+ for role in ROLES
+ }
+
+ def poll(self) -> set[str]:
+ """
+ Retorna as roles que receberam FRAME NOVO nesta iteração.
+
+ Esta informação é usada pelo score. O mesmo frame NUNCA é adicionado
+ duas vezes ao histórico de foco.
+ """
+ fresh = set()
+
+ for role in ROLES:
+ packet = self.output_queues[role].tryGet()
+
+ if packet is None:
+ continue
+
+ ctrl = packet_controls(packet)
+ seq = ctrl.get("sequence_num")
+
+ if seq is not None and seq == self.last_seq[role]:
+ continue
+
+ try:
+ img = packet.getCvFrame()
+ except Exception as exc:
+ raise RuntimeError(
+ f"{role}: getCvFrame falhou: {exc}"
+ )
+
+ if img is None:
+ continue
+
+ if img.ndim == 2:
+ img = cv2.cvtColor(
+ img,
+ cv2.COLOR_GRAY2BGR,
+ )
+
+ self.frames[role] = img
+ self.controls[role] = ctrl
+ self.last_seq[role] = seq
+ self.frame_counts[role] += 1
+
+ fresh.add(role)
+
+ self._fps_count[role] += 1
+
+ dt = time.time() - self._fps_t0[role]
+
+ if dt >= 1.0:
+ self.fps[role] = (
+ self._fps_count[role] / dt
+ )
+ self._fps_count[role] = 0
+ self._fps_t0[role] = time.time()
+
+ return fresh
+
+ def wait_all(self, timeout=8.0):
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ self.poll()
+
+ if all(
+ self.frames[r] is not None
+ for r in ROLES
+ ):
+ return
+
+ time.sleep(0.003)
+
+ missing = [
+ r for r in ROLES
+ if self.frames[r] is None
+ ]
+
+ raise TimeoutError(
+ f"Timeout aguardando câmeras: {missing}"
+ )
+
+ def send_manual(self, locked):
+ for role in ROLES:
+ send_manual_control(
+ self.control_queues[role],
+ locked[role],
+ rgb=(role == "rgb"),
+ )
+
+ def verify_manual(self, locked, settle_frames):
+ matched = {
+ r: 0
+ for r in ROLES
+ }
+
+ observed_seq = dict(self.last_seq)
+ t0 = time.time()
+
+ while time.time() - t0 < 10.0:
+ fresh = self.poll()
+
+ for role in fresh:
+ seq = self.last_seq[role]
+
+ if seq == observed_seq.get(role):
+ continue
+
+ observed_seq[role] = seq
+
+ if controls_match(
+ self.controls[role],
+ locked[role],
+ self.qa,
+ ):
+ matched[role] += 1
+
+ else:
+ matched[role] = 0
+
+ if all(
+ matched[r] >= int(settle_frames)
+ for r in ROLES
+ ):
+ return
+
+ time.sleep(0.002)
+
+ raise RuntimeError(
+ "Não foi possível confirmar EXP/ISO manual nas três câmeras. "
+ f"matched={matched}"
+ )
+
+
+# ============================================================
+# Seleção de controles
+# ============================================================
+
+def manual_override_from_args(args, role: str):
+ exp = getattr(args, f"{role}_exp_us")
+ iso = getattr(args, f"{role}_iso")
+
+ if exp is None and iso is None:
+ return None
+
+ if exp is None or iso is None:
+ raise ValueError(
+ f"Informe ambos --{role}-exp-us e --{role}-iso"
+ )
+
+ return LockedControl(
+ role=role,
+ exposure_time_us=int(exp),
+ sensitivity_iso=int(iso),
+ source="cli_manual",
+ )
+
+
+def sample_ae_controls(
+ runtime: FocusRuntime,
+ args,
+):
+ locked = {}
+
+ for role in ROLES:
+ override = manual_override_from_args(
+ args,
+ role,
+ )
+
+ if override is not None:
+ locked[role] = override
+ continue
+
+ exp_values = []
+ iso_values = []
+
+ t0 = time.time()
+
+ while time.time() - t0 < 0.8:
+ fresh = runtime.poll()
+
+ if role not in fresh:
+ time.sleep(0.003)
+ continue
+
+ c = runtime.controls[role]
+
+ exp = safe_int(
+ c.get("exposure_time_us"),
+ None,
+ )
+
+ iso = safe_int(
+ c.get("sensitivity_iso"),
+ None,
+ )
+
+ if exp is not None and exp > 0:
+ exp_values.append(exp)
+
+ if iso is not None and iso > 0:
+ iso_values.append(iso)
+
+ if not exp_values or not iso_values:
+ raise RuntimeError(
+ f"Sem metadados EXP/ISO suficientes para {role.upper()}"
+ )
+
+ locked[role] = LockedControl(
+ role=role,
+ exposure_time_us=int(
+ round(np.median(exp_values))
+ ),
+ sensitivity_iso=int(
+ round(np.median(iso_values))
+ ),
+ source="ae_snapshot",
+ )
+
+ return locked
+
+
+# ============================================================
+# Desenho
+# ============================================================
+
+def draw_roi(
+ panel,
+ roi_src,
+ src_shape_hw,
+ color=(0, 255, 255),
+ label="FOCUS ROI",
+ thickness=2,
+):
+ if roi_src is None:
+ return
+
+ ph, pw = panel.shape[:2]
+ sh, sw = src_shape_hw
+
+ x0, y0, x1, y1 = roi_src
+
+ px0 = int(x0 * pw / max(sw, 1))
+ px1 = int(x1 * pw / max(sw, 1))
+ py0 = int(y0 * ph / max(sh, 1))
+ py1 = int(y1 * ph / max(sh, 1))
+
+ cv2.rectangle(
+ panel,
+ (px0, py0),
+ (px1, py1),
+ color,
+ thickness,
+ )
+
+ cv2.putText(
+ panel,
+ label,
+ (px0 + 4, max(18, py0 - 5)),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.40,
+ color,
+ 1,
+ cv2.LINE_AA,
+ )
+
+
+def draw_field_zones(panel, src_shape_hw, field_qa=None):
+ color_good = (0, 220, 0)
+ color_warn = (0, 200, 255)
+ color_bad = (0, 0, 255)
+
+ for name, nrect in FIELD_ZONES_NORM.items():
+ rect = rect_from_norm(
+ src_shape_hw,
+ *nrect,
+ )
+
+ color = (150, 150, 150)
+ label = name.upper()
+
+ if field_qa:
+ item = field_qa.get("zones", {}).get(name, {})
+ ratio = item.get("ratio_to_center")
+
+ if name == "center":
+ color = color_good
+ label += " 1.00"
+
+ elif ratio is not None:
+ if ratio < DEFAULT_QA["field_corner_ratio_bad"]:
+ color = color_bad
+
+ elif ratio < DEFAULT_QA["field_corner_ratio_warning"]:
+ color = color_warn
+
+ else:
+ color = color_good
+
+ label += f" {ratio:.2f}"
+
+ draw_roi(
+ panel,
+ rect,
+ src_shape_hw,
+ color=color,
+ label=label,
+ thickness=1,
+ )
+
+
+def draw_history_graph(
+ panel,
+ history,
+ x,
+ y,
+ w,
+ h,
+ best_score,
+):
+ cv2.rectangle(
+ panel,
+ (x, y),
+ (x + w, y + h),
+ (35, 35, 35),
+ -1,
+ )
+
+ cv2.rectangle(
+ panel,
+ (x, y),
+ (x + w, y + h),
+ (110, 110, 110),
+ 1,
+ )
+
+ if len(history) < 2:
+ return
+
+ vals = np.array(
+ [
+ float(item["smooth"])
+ for item in history
+ ],
+ dtype=np.float32,
+ )
+
+ vals = vals[-max(2, w):]
+
+ max_val = max(
+ float(np.max(vals)),
+ float(best_score),
+ 1e-6,
+ )
+
+ min_val = min(
+ float(np.min(vals)),
+ max_val * 0.88,
+ )
+
+ span = max(
+ max_val - min_val,
+ 1e-6,
+ )
+
+ pts = []
+
+ for i, v in enumerate(vals):
+ px = x + int(
+ i / max(1, len(vals) - 1)
+ * (w - 1)
+ )
+
+ py = y + h - 1 - int(
+ (float(v) - min_val)
+ / span
+ * (h - 1)
+ )
+
+ pts.append((px, py))
+
+ for a, b in zip(pts[:-1], pts[1:]):
+ cv2.line(
+ panel,
+ a,
+ b,
+ (255, 255, 255),
+ 2,
+ cv2.LINE_AA,
+ )
+
+
+def build_board(
+ runtime: FocusRuntime,
+ specs,
+ selected_role,
+ roi_rects,
+ state,
+ field_qa_by_role,
+ args,
+ qa,
+ status_message="",
+):
+ ph = int(args.panel_height)
+ pw = int(args.panel_width)
+
+ panels = {}
+
+ for role in ROLES:
+ img = runtime.frames[role]
+ spec = specs[role]
+
+ if img is None:
+ panels[role] = build_empty_panel(
+ (ph, pw),
+ role.upper(),
+ )
+ continue
+
+ view = img.copy()
+
+ if role == "re":
+ gray = cv2.cvtColor(
+ view,
+ cv2.COLOR_BGR2GRAY,
+ )
+ z = np.zeros_like(gray)
+ view = np.dstack([z, z, gray])
+
+ elif role == "nir":
+ gray = cv2.cvtColor(
+ view,
+ cv2.COLOR_BGR2GRAY,
+ )
+ z = np.zeros_like(gray)
+ view = np.dstack([gray, gray, z])
+
+ panel = resize_panel(
+ view,
+ (ph, pw),
+ )
+
+ active = role == selected_role
+
+ draw_roi(
+ panel,
+ roi_rects[role],
+ img.shape[:2],
+ color=(0, 255, 255) if active else (0, 170, 255),
+ label="FOCUS ROI",
+ thickness=2 if active else 1,
+ )
+
+ draw_field_zones(
+ panel,
+ img.shape[:2],
+ field_qa_by_role.get(role),
+ )
+
+ c = runtime.controls[role]
+
+ overlay_hud(
+ panel,
+ [
+ f"{role.upper()} | {spec.socket_name} | {spec.sensor_name}",
+ f"{img.shape[1]}x{img.shape[0]} | {'ATIVA' if active else ''}",
+ f"EXP={c.get('exposure_time_us')}us ISO={c.get('sensitivity_iso')}",
+ f"FPS={runtime.fps[role]:.1f}",
+ ],
+ x=10,
+ y=20,
+ font_scale=0.40,
+ line_step=17,
+ )
+
+ panels[role] = panel
+
+ data = np.zeros((ph, pw, 3), dtype=np.uint8)
+
+ role_state = state[selected_role]
+ sweep = role_state["sweep"]
+ history = role_state["history"]
+
+ best = float(sweep.best_score)
+ current = float(role_state.get("smooth", 0.0))
+ ratio = 0.0 if best <= 0 else current / best
+
+ stable = role_state.get("stable", {})
+ field = field_qa_by_role.get(selected_role) or {}
+
+ lines = [
+ "FOCUS CALIBRATION - PRODUCTION",
+ f"ativa={selected_role.upper()} | metodo={args.method}",
+ f"score={role_state.get('score', 0.0):.3f}",
+ f"smooth={current:.3f}",
+ f"best={best:.3f} | atual/best={ratio*100:.2f}%",
+ f"frames NOVOS medidos={sweep.frames_scored}",
+ "",
+ f"peak_bracket={'SIM' if sweep.peak_drop_observed else 'NAO'}",
+ f"stable={'SIM' if stable.get('pass') else 'NAO'} "
+ f"CV={stable.get('cv', 0)*100:.2f}% "
+ f"range={stable.get('range_pct', 0)*100:.2f}%",
+ f"FIELD QA={field.get('status', 'aguardando').upper()}",
+ f"camera accepted={'SIM' if role_state.get('accepted') else 'NAO'}",
+ "",
+ f"trend={role_state.get('trend', {}).get('status', 'coletando')}",
+ f"acao={role_state.get('trend', {}).get('instruction', 'gire devagar')}",
+ f"sentido={role_state.get('direction_name')}",
+ "",
+ "A tenta ACCEPT | R reset sweep",
+ "1/2/3 câmera | M métrica | D sentido",
+ "mouse ROI | C centraliza | L trava",
+ "E equalize | S snapshot | Q cancela",
+ ]
+
+ overlay_hud(
+ data,
+ lines,
+ x=14,
+ y=22,
+ font_scale=0.40,
+ line_step=17,
+ )
+
+ draw_history_graph(
+ data,
+ history,
+ x=16,
+ y=max(260, ph - 130),
+ w=max(100, pw - 32),
+ h=90,
+ best_score=best,
+ )
+
+ board = np.vstack([
+ np.hstack([
+ panels["rgb"],
+ panels["re"],
+ ]),
+ np.hstack([
+ panels["nir"],
+ data,
+ ]),
+ ])
+
+ if status_message:
+ cv2.putText(
+ board,
+ status_message,
+ (18, board.shape[0] - 14),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.55,
+ (0, 255, 0),
+ 2,
+ cv2.LINE_AA,
+ )
+
+ return board
+
+
+# ============================================================
+# Reset e acceptance
+# ============================================================
+
+def new_role_state(args):
+ return {
+ "history": deque(
+ maxlen=int(args.history)
+ ),
+ "sweep": SweepState(),
+ "score": 0.0,
+ "smooth": 0.0,
+ "metrics": None,
+ "trend": {},
+ "stable": {},
+ "accepted": False,
+ "accepted_at": None,
+ "acceptance": None,
+ "direction_idx": 0,
+ "direction_name": "rosqueando",
+ "last_scored_seq": None,
+ }
+
+
+def reset_role_state(
+ state,
+ role,
+ args,
+):
+ accepted = state[role].get("accepted", False)
+
+ state[role] = new_role_state(args)
+
+ # Mudar metodologia/ROI invalida o acceptance anterior dessa role.
+ if accepted:
+ state[role]["accepted"] = False
+
+
+def evaluate_stability(
+ history,
+ sweep,
+ qa,
+):
+ stats = stable_stats(
+ history,
+ qa["accept_stable_frames"],
+ )
+
+ if not stats["ready"]:
+ stats["pass"] = False
+ return stats
+
+ near_best = (
+ stats["mean"]
+ >= sweep.best_score
+ * qa["accept_near_best_ratio"]
+ )
+
+ cv_ok = (
+ stats["cv"]
+ <= qa["accept_stable_cv"]
+ )
+
+ range_ok = (
+ stats["range_pct"]
+ <= qa["accept_stable_range_pct"]
+ )
+
+ stats["near_best"] = bool(near_best)
+ stats["cv_ok"] = bool(cv_ok)
+ stats["range_ok"] = bool(range_ok)
+ stats["pass"] = bool(
+ near_best and cv_ok and range_ok
+ )
+
+ return stats
+
+
+def attempt_accept(
+ role,
+ state,
+ field_qa_by_role,
+ runtime,
+ roi_rects,
+ args,
+ qa,
+):
+ s = state[role]
+ sweep = s["sweep"]
+ stable = s.get("stable") or {}
+ field = field_qa_by_role.get(role) or {}
+
+ reasons = []
+
+ if sweep.frames_scored < max(20, qa["accept_stable_frames"]):
+ reasons.append(
+ f"poucos_frames:{sweep.frames_scored}"
+ )
+
+ if not sweep.peak_drop_observed:
+ reasons.append("peak_nao_bracketado")
+
+ if not stable.get("pass"):
+ reasons.append("score_nao_estavel_perto_do_best")
+
+ field_status = field.get("status")
+
+ if field_status == "bad":
+ reasons.append("field_qa_bad")
+
+ elif (
+ field_status == "warning"
+ and not args.allow_warning_field
+ ):
+ reasons.append("field_qa_warning")
+
+ if field_status not in ("good", "warning"):
+ reasons.append("field_qa_ausente")
+
+ engineering_override_used = False
+
+ if reasons and args.engineering_override:
+ engineering_override_used = True
+ reasons_for_record = list(reasons)
+ reasons = []
+ else:
+ reasons_for_record = list(reasons)
+
+ accepted = len(reasons) == 0
+
+ result = {
+ "accepted": accepted,
+ "requested_at": now_str(),
+ "engineering_override_used": engineering_override_used,
+ "blocked_reasons": reasons_for_record,
+ "method": args.method,
+ "equalize": bool(args.equalize_runtime),
+ "best_score": float(sweep.best_score),
+ "accepted_score": float(s.get("smooth", 0.0)),
+ "accepted_ratio_to_best": (
+ 0.0
+ if sweep.best_score <= 0
+ else float(
+ s.get("smooth", 0.0)
+ / sweep.best_score
+ )
+ ),
+ "frames_scored": int(sweep.frames_scored),
+ "peak_drop_observed": bool(
+ sweep.peak_drop_observed
+ ),
+ "lowest_ratio_after_best": float(
+ sweep.lowest_ratio_after_best
+ ),
+ "stability": dict(stable),
+ "field_qa": field,
+ "roi": (
+ list(map(int, roi_rects[role]))
+ if roi_rects[role] is not None
+ else None
+ ),
+ "frame_controls": dict(
+ runtime.controls[role]
+ ),
+ "sequence_num": safe_int(
+ runtime.last_seq[role],
+ None,
+ ),
+ }
+
+ if accepted:
+ s["accepted"] = True
+ s["accepted_at"] = now_str()
+ s["acceptance"] = result
+
+ return result
+
+
+# ============================================================
+# Snapshots
+# ============================================================
+
+def save_role_snapshot(
+ role,
+ runtime,
+ board,
+ out_dir,
+ label,
+):
+ ensure_dir(out_dir)
+
+ stamp = file_stamp()
+
+ frame_path = Path(out_dir) / (
+ f"{role}_{label}_full_{stamp}.png"
+ )
+
+ board_path = Path(out_dir) / (
+ f"{role}_{label}_board_{stamp}.png"
+ )
+
+ img = runtime.frames.get(role)
+
+ if img is not None:
+ cv2.imwrite(
+ str(frame_path),
+ img,
+ )
+
+ if board is not None:
+ cv2.imwrite(
+ str(board_path),
+ board,
+ )
+
+ return {
+ "frame_png": (
+ str(frame_path)
+ if frame_path.exists()
+ else None
+ ),
+ "board_png": (
+ str(board_path)
+ if board_path.exists()
+ else None
+ ),
+ }
+
+
+# ============================================================
+# Preflight UI
+# ============================================================
+
+def run_preflight(
+ runtime: FocusRuntime,
+ specs,
+ args,
+ qa,
+):
+ runtime.wait_all()
+
+ print("")
+ print("[PRE-FLIGHT] Use alvo de foco plano, detalhado e preenchendo o FOV.")
+ print("[PRE-FLIGHT] ENTER congela exposição/ISO quando aprovado.")
+
+ last_report = None
+
+ while True:
+ runtime.poll()
+
+ last_report = merge_preflight_by_role(
+ runtime.frames,
+ qa,
+ )
+
+ ph = int(args.panel_height)
+ pw = int(args.panel_width)
+
+ panels = []
+
+ for role in ROLES:
+ img = runtime.frames[role]
+
+ if img is None:
+ panel = build_empty_panel(
+ (ph, pw),
+ role.upper(),
+ )
+ else:
+ panel = resize_panel(
+ img,
+ (ph, pw),
+ )
+
+ r = last_report["roles"][role]
+
+ overlay_hud(
+ panel,
+ [
+ f"{role.upper()} | {specs[role].sensor_name}",
+ f"PREFLIGHT={r['status'].upper()}",
+ f"std={r.get('std', 0):.3f}",
+ f"sat={r.get('sat_pct', 0):.2f}% dark={r.get('dark_pct', 0):.2f}%",
+ f"EXP={runtime.controls[role].get('exposure_time_us')}us "
+ f"ISO={runtime.controls[role].get('sensitivity_iso')}",
+ ],
+ x=10,
+ y=22,
+ font_scale=0.42,
+ line_step=18,
+ )
+
+ panels.append(panel)
+
+ data = np.zeros(
+ (ph, pw, 3),
+ dtype=np.uint8,
+ )
+
+ lines = [
+ "FOCUS QC - PRE-FLIGHT",
+ "",
+ f"STATUS GLOBAL: {last_report['status'].upper()}",
+ "",
+ "Use chart DETALHADO em todo o campo.",
+ "Módulo perpendicular ao alvo.",
+ "Evite reflexos e saturação.",
+ "",
+ "ENTER = congelar EXP/ISO",
+ "Q/ESC = cancelar",
+ ]
+
+ for reason in last_report["reasons"][:8]:
+ lines.append(f"! {reason}")
+
+ overlay_hud(
+ data,
+ lines,
+ x=14,
+ y=30,
+ font_scale=0.45,
+ line_step=20,
+ )
+
+ board = np.vstack([
+ np.hstack([
+ panels[0],
+ panels[1],
+ ]),
+ np.hstack([
+ panels[2],
+ data,
+ ]),
+ ])
+
+ cv2.imshow(
+ "Focus Calibration - Production",
+ board,
+ )
+
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (ord("q"), ord("Q"), 27):
+ raise KeyboardInterrupt(
+ "Cancelado no preflight."
+ )
+
+ if k in (13, 10):
+ status = last_report["status"]
+
+ allowed = (
+ status == "good"
+ or (
+ status == "warning"
+ and args.allow_warning_preflight
+ )
+ or args.engineering_override
+ )
+
+ if not allowed:
+ print(
+ f"[BLOCK] PREFLIGHT={status.upper()}. "
+ "Corrija alvo/luz antes de continuar."
+ )
+ continue
+
+ locked = sample_ae_controls(
+ runtime,
+ args,
+ )
+
+ runtime.send_manual(locked)
+
+ runtime.verify_manual(
+ locked,
+ settle_frames=args.lock_settle_frames,
+ )
+
+ return last_report, locked
+
+ time.sleep(0.002)
+
+
+# ============================================================
+# Report
+# ============================================================
+
+def state_to_report(state):
+ out = {}
+
+ for role in ROLES:
+ s = state[role]
+ sweep = s["sweep"]
+
+ out[role] = {
+ "accepted": bool(s.get("accepted")),
+ "accepted_at": s.get("accepted_at"),
+ "acceptance": s.get("acceptance"),
+ "sweep": asdict(sweep),
+ }
+
+ return out
+
+
+def build_base_report(
+ args,
+ camera_rows,
+ specs,
+ actual_mx,
+ usb_speed,
+ topology_report,
+ isp_settings,
+ qa,
+ candidate_dir,
+):
+ return {
+ "schema": SCHEMA,
+ "created_at": now_str(),
+ "status": "running",
+ "runtime_effect": "none",
+ "module_params_fragment": None,
+ "module_params_merge_required": False,
+ "purpose": (
+ "physical_optical_focus_qc_only"
+ ),
+ "depthai_version": getattr(
+ dai,
+ "__version__",
+ "unknown",
+ ),
+ "device_mx_id": actual_mx,
+ "usb_speed": usb_speed,
+ "topology": topology_report,
+ "camera_inventory": [
+ {
+ k: v
+ for k, v in row.items()
+ if k != "socket_obj"
+ }
+ for row in camera_rows
+ ],
+ "resolved_setup": {
+ role: asdict(spec)
+ for role, spec in specs.items()
+ },
+ "isp_measurement_settings": isp_settings,
+ "measurement": {
+ "method_initial": args.method,
+ "fps_requested": args.fps,
+ "panel_size": [
+ args.panel_width,
+ args.panel_height,
+ ],
+ "smooth_window": args.smooth_window,
+ "history": args.history,
+ "equalize_initial": bool(
+ args.equalize
+ ),
+ "target_requirement": (
+ "flat_high_detail_target_across_entire_fov"
+ ),
+ },
+ "qa_thresholds": qa,
+ "policy": {
+ "allow_warning_preflight": bool(
+ args.allow_warning_preflight
+ ),
+ "allow_warning_field": bool(
+ args.allow_warning_field
+ ),
+ "engineering_override": bool(
+ args.engineering_override
+ ),
+ "allow_isp_defaults": bool(
+ args.allow_isp_defaults
+ ),
+ },
+ "traceability": {
+ "module_id": args.module_id or None,
+ "operator": args.operator or None,
+ "target_distance_mm": args.target_distance_mm,
+ "lens_by_role": {
+ "rgb": args.lens_rgb or None,
+ "re": args.lens_re or None,
+ "nir": args.lens_nir or None,
+ },
+ },
+ "notes": args.notes or "",
+ "candidate_dir": str(candidate_dir),
+ "preflight": None,
+ "locked_controls": None,
+ "results_by_role": {},
+ "snapshots": [],
+ "promoted": False,
+ }
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=(
+ "Ferramenta FINAL de produção para ajuste/QC de foco "
+ "OV9782 ou AR0234 + 2x OV9282."
+ ),
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ # Hardware
+ parser.add_argument(
+ "--mx-id",
+ default=None,
+ )
+
+ parser.add_argument(
+ "--fps",
+ type=float,
+ default=20.0,
+ )
+
+ parser.add_argument(
+ "--relaxed-topology",
+ action="store_true",
+ help=(
+ "Override de engenharia. Produto normal exige "
+ "CAM_A=RGB, CAM_B=RE, CAM_C=NIR."
+ ),
+ )
+
+ # ISP determinístico RGB
+ parser.add_argument(
+ "--isp-sharpness",
+ type=int,
+ default=0,
+ )
+
+ parser.add_argument(
+ "--isp-luma-denoise",
+ type=int,
+ default=0,
+ )
+
+ parser.add_argument(
+ "--isp-chroma-denoise",
+ type=int,
+ default=0,
+ )
+
+ parser.add_argument(
+ "--allow-isp-defaults",
+ action="store_true",
+ help=(
+ "Permite continuar caso esta versão do DepthAI "
+ "não exponha sharpness/denoise no initialControl."
+ ),
+ )
+
+ # UI / métrica
+ parser.add_argument(
+ "--panel-width",
+ type=int,
+ default=640,
+ )
+
+ parser.add_argument(
+ "--panel-height",
+ type=int,
+ default=400,
+ )
+
+ parser.add_argument(
+ "--method",
+ default="tenengrad_norm",
+ choices=list(METHODS),
+ )
+
+ parser.add_argument(
+ "--history",
+ type=int,
+ default=320,
+ )
+
+ parser.add_argument(
+ "--smooth-window",
+ type=int,
+ default=5,
+ )
+
+ parser.add_argument(
+ "--drop-warn-pct",
+ type=float,
+ default=3.0,
+ )
+
+ parser.add_argument(
+ "--equalize",
+ action="store_true",
+ )
+
+ parser.add_argument(
+ "--lock-settle-frames",
+ type=int,
+ default=8,
+ )
+
+ # Controles manuais opcionais
+ for role in ROLES:
+ parser.add_argument(
+ f"--{role}-exp-us",
+ type=int,
+ default=None,
+ )
+
+ parser.add_argument(
+ f"--{role}-iso",
+ type=int,
+ default=None,
+ )
+
+ # Política
+ parser.add_argument(
+ "--allow-warning-preflight",
+ action="store_true",
+ )
+
+ parser.add_argument(
+ "--allow-warning-field",
+ action="store_true",
+ help=(
+ "Permite aceitar FIELD QA=WARNING. Por padrão produto exige GOOD."
+ ),
+ )
+
+ parser.add_argument(
+ "--engineering-override",
+ action="store_true",
+ help=(
+ "Permite acceptance mesmo sem todos os critérios. "
+ "Fica explicitamente registrado e não representa fluxo normal."
+ ),
+ )
+
+ # Output
+ parser.add_argument(
+ "--candidate-root",
+ default="calibration/focus_candidates",
+ )
+
+ parser.add_argument(
+ "--active-json",
+ default="calibration/focus_qc_active.json",
+ )
+
+ parser.add_argument(
+ "--module-id",
+ default="",
+ help="Identificador/NS do módulo óptico, se houver.",
+ )
+
+ parser.add_argument(
+ "--operator",
+ default="",
+ help="Operador/técnico responsável pela calibração.",
+ )
+
+ parser.add_argument(
+ "--target-distance-mm",
+ type=float,
+ default=None,
+ help="Distância lente-alvo usada no QC de foco.",
+ )
+
+ parser.add_argument(
+ "--lens-rgb",
+ default="",
+ help="PN/ID/lote da lente RGB.",
+ )
+
+ parser.add_argument(
+ "--lens-re",
+ default="",
+ help="PN/ID/lote da lente RE.",
+ )
+
+ parser.add_argument(
+ "--lens-nir",
+ default="",
+ help="PN/ID/lote da lente NIR.",
+ )
+
+ parser.add_argument(
+ "--notes",
+ default="",
+ )
+
+ args = parser.parse_args()
+
+ # Campo mutável apenas em runtime para E/M.
+ args.equalize_runtime = bool(args.equalize)
+
+ qa = dict(DEFAULT_QA)
+
+ sid = file_stamp()
+ candidate_dir = (
+ Path(args.candidate_root)
+ / sid
+ )
+ snapshot_dir = (
+ candidate_dir
+ / "snapshots"
+ )
+ candidate_report = (
+ candidate_dir
+ / "report.json"
+ )
+
+ ensure_dir(snapshot_dir)
+
+ camera_rows = []
+ specs = {}
+ actual_mx = None
+ usb_speed = None
+ report = None
+ device_ctx = None
+ state = None
+
+ cv2.namedWindow(
+ "Focus Calibration - Production",
+ cv2.WINDOW_NORMAL,
+ )
+
+ try:
+ # ----------------------------------------------------
+ # Descoberta
+ # ----------------------------------------------------
+
+ (
+ camera_rows,
+ actual_mx,
+ usb_speed,
+ ) = discover_cameras(
+ args.mx_id
+ )
+
+ topology_report = validate_product_topology(
+ camera_rows,
+ relaxed=args.relaxed_topology,
+ )
+
+ specs = build_specs(
+ camera_rows
+ )
+
+ print("=" * 82)
+ print("FOCUS CALIBRATION - PRODUCTION")
+ print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}")
+ print(f"MX ID : {actual_mx}")
+ print(f"USB : {usb_speed}")
+ print("-" * 82)
+
+ for role in ROLES:
+ spec = specs[role]
+
+ print(
+ f"{spec.socket_name} -> "
+ f"{role.upper():3s} | "
+ f"{spec.sensor_name:8s} | "
+ f"{spec.configured_width}x{spec.configured_height} | "
+ f"{spec.resolution_name} | "
+ f"{spec.source}"
+ )
+
+ print("=" * 82)
+
+ # ----------------------------------------------------
+ # Pipeline
+ # ----------------------------------------------------
+
+ pipeline, isp_settings = build_pipeline(
+ specs,
+ args,
+ )
+
+ device_info = (
+ dai.DeviceInfo(actual_mx)
+ if actual_mx
+ else None
+ )
+
+ device_ctx = (
+ dai.Device(
+ pipeline,
+ device_info,
+ )
+ if device_info is not None
+ else dai.Device(pipeline)
+ )
+
+ report = build_base_report(
+ args,
+ camera_rows,
+ specs,
+ actual_mx,
+ usb_speed,
+ topology_report,
+ isp_settings,
+ qa,
+ candidate_dir,
+ )
+
+ with device_ctx as device:
+ runtime = FocusRuntime(
+ device,
+ specs,
+ args,
+ qa,
+ )
+
+ # ------------------------------------------------
+ # Preflight + lock
+ # ------------------------------------------------
+
+ preflight, locked = run_preflight(
+ runtime,
+ specs,
+ args,
+ qa,
+ )
+
+ report["preflight"] = preflight
+
+ report["locked_controls"] = {
+ role: asdict(locked[role])
+ for role in ROLES
+ }
+
+ print("[LOCK] Exposição congelada:")
+ for role in ROLES:
+ c = locked[role]
+ print(
+ f" {role.upper()}: "
+ f"{c.exposure_time_us}us "
+ f"ISO={c.sensitivity_iso}"
+ )
+
+ # ------------------------------------------------
+ # Estado de foco
+ # ------------------------------------------------
+
+ state = {
+ role: new_role_state(args)
+ for role in ROLES
+ }
+
+ roi_rects = {
+ role: None
+ for role in ROLES
+ }
+
+ roi_locked = False
+ selected_role = "rgb"
+
+ field_qa_by_role = {
+ role: None
+ for role in ROLES
+ }
+
+ field_history_by_role = {
+ role: deque(
+ maxlen=max(
+ 16,
+ int(qa["field_stable_frames"]) * 2,
+ )
+ )
+ for role in ROLES
+ }
+
+ last_board = None
+ last_message = ""
+ last_message_t = 0.0
+
+ panel_rects = {
+ "rgb": None,
+ "re": None,
+ "nir": None,
+ "data": None,
+ }
+
+ # ------------------------------------------------
+ # Mouse ROI
+ # ------------------------------------------------
+
+ dragging = False
+ drag_start = None
+
+ def message(text):
+ nonlocal last_message, last_message_t
+ last_message = str(text)
+ last_message_t = time.time()
+
+ def inside(rect, x, y):
+ if rect is None:
+ return False
+
+ x0, y0, x1, y1 = rect
+ return (
+ x0 <= x < x1
+ and y0 <= y < y1
+ )
+
+ def display_to_source(
+ role,
+ x,
+ y,
+ ):
+ rect = panel_rects.get(role)
+ img = runtime.frames.get(role)
+
+ if rect is None or img is None:
+ return None
+
+ x0, y0, x1, y1 = rect
+ pw = max(1, x1 - x0)
+ ph = max(1, y1 - y0)
+
+ sh, sw = img.shape[:2]
+
+ lx = max(
+ 0,
+ min(
+ pw - 1,
+ int(x - x0),
+ ),
+ )
+
+ ly = max(
+ 0,
+ min(
+ ph - 1,
+ int(y - y0),
+ ),
+ )
+
+ sx = int(lx * sw / pw)
+ sy = int(ly * sh / ph)
+
+ return (
+ max(0, min(sw - 1, sx)),
+ max(0, min(sh - 1, sy)),
+ )
+
+ def on_mouse(
+ event,
+ x,
+ y,
+ flags,
+ param,
+ ):
+ nonlocal dragging, drag_start
+
+ if roi_locked:
+ return
+
+ rect = panel_rects.get(
+ selected_role
+ )
+
+ if (
+ rect is None
+ or not inside(rect, x, y)
+ ):
+ return
+
+ pt = display_to_source(
+ selected_role,
+ x,
+ y,
+ )
+
+ if pt is None:
+ return
+
+ if event == cv2.EVENT_LBUTTONDOWN:
+ dragging = True
+ drag_start = pt
+
+ elif (
+ event == cv2.EVENT_MOUSEMOVE
+ and dragging
+ and drag_start is not None
+ ):
+ x0, y0 = drag_start
+ x1, y1 = pt
+
+ roi_rects[selected_role] = (
+ x0,
+ y0,
+ x1,
+ y1,
+ )
+
+ elif (
+ event == cv2.EVENT_LBUTTONUP
+ and dragging
+ and drag_start is not None
+ ):
+ x0, y0 = drag_start
+ x1, y1 = pt
+
+ img = runtime.frames[
+ selected_role
+ ]
+
+ r = sanitize_roi(
+ (
+ x0,
+ y0,
+ x1,
+ y1,
+ ),
+ img.shape[:2],
+ )
+
+ dragging = False
+ drag_start = None
+
+ if r is not None:
+ roi_rects[selected_role] = r
+
+ reset_role_state(
+ state,
+ selected_role,
+ args,
+ )
+ field_history_by_role[selected_role].clear()
+ field_qa_by_role[selected_role] = None
+
+ message(
+ f"ROI alterada: "
+ f"{selected_role.upper()} | sweep resetado"
+ )
+
+ cv2.setMouseCallback(
+ "Focus Calibration - Production",
+ on_mouse,
+ )
+
+ # ------------------------------------------------
+ # Loop principal
+ # ------------------------------------------------
+
+ print("")
+ print("[FOCUS] Ajuste RGB, depois RE e NIR.")
+ print("[FOCUS] atravesse o pico, volte ao melhor e pressione A.")
+
+ while True:
+ fresh_roles = runtime.poll()
+
+ # Inicializa ROIs quando os frames existem.
+ for role in ROLES:
+ img = runtime.frames[role]
+
+ if (
+ img is not None
+ and roi_rects[role] is None
+ ):
+ roi_rects[role] = (
+ default_roi_for_shape(
+ img.shape[:2],
+ frac=0.36,
+ )
+ )
+
+ # ------------------------------------------------
+ # SCORE SOMENTE EM FRAME NOVO
+ # ------------------------------------------------
+
+ if (
+ selected_role in fresh_roles
+ and runtime.frames[selected_role] is not None
+ and roi_rects[selected_role] is not None
+ ):
+ s = state[selected_role]
+
+ seq = runtime.last_seq[
+ selected_role
+ ]
+
+ # Defesa extra contra duplicate score.
+ if (
+ seq is None
+ or seq != s["last_scored_seq"]
+ ):
+ img = runtime.frames[
+ selected_role
+ ]
+
+ metrics = compute_focus_metrics(
+ img,
+ roi_rects[selected_role],
+ equalize=args.equalize_runtime,
+ )
+
+ score = metric_value(
+ metrics,
+ args.method,
+ )
+
+ hist = s["history"]
+
+ hist.append({
+ "timestamp": now_str(),
+ "sequence_num": safe_int(
+ seq,
+ None,
+ ),
+ "score": float(score),
+ "smooth": float(score),
+ })
+
+ smooth = smoothed_score(
+ hist,
+ args.smooth_window,
+ )
+
+ hist[-1]["smooth"] = float(
+ smooth
+ )
+
+ update_sweep(
+ s["sweep"],
+ score=float(score),
+ smooth=float(smooth),
+ metrics=metrics,
+ roi=roi_rects[selected_role],
+ sequence_num=seq,
+ drop_required_pct=qa[
+ "peak_drop_required_pct"
+ ],
+ new_best_rebracket_pct=qa[
+ "new_best_rebracket_pct"
+ ],
+ )
+
+ s["score"] = float(score)
+ s["smooth"] = float(smooth)
+ s["metrics"] = metrics
+
+ s["trend"] = analyze_trend(
+ hist,
+ s["sweep"],
+ s["direction_name"],
+ drop_warn_pct=args.drop_warn_pct,
+ )
+
+ s["stable"] = evaluate_stability(
+ hist,
+ s["sweep"],
+ qa,
+ )
+
+ # FIELD QA entra no histórico apenas em frame novo.
+ field_history_by_role[
+ selected_role
+ ].append(
+ compute_field_qa(
+ img,
+ qa,
+ equalize=args.equalize_runtime,
+ )
+ )
+
+ field_qa_by_role[
+ selected_role
+ ] = aggregate_field_qa(
+ field_history_by_role[
+ selected_role
+ ],
+ qa,
+ )
+
+ s["last_scored_seq"] = seq
+
+ # Atualiza FIELD QA das outras câmeras somente com frames novos,
+ # sem alimentar o histórico do score principal.
+ for role in (
+ fresh_roles
+ - {selected_role}
+ ):
+ img = runtime.frames[role]
+
+ if img is not None:
+ field_history_by_role[
+ role
+ ].append(
+ compute_field_qa(
+ img,
+ qa,
+ equalize=args.equalize_runtime,
+ )
+ )
+
+ field_qa_by_role[
+ role
+ ] = aggregate_field_qa(
+ field_history_by_role[
+ role
+ ],
+ qa,
+ )
+
+ # ------------------------------------------------
+ # FPS watchdog
+ # ------------------------------------------------
+
+ fps_warn = []
+
+ for role in ROLES:
+ fps = runtime.fps[role]
+
+ if (
+ fps > 0
+ and fps
+ < args.fps
+ * qa["fps_min_ratio"]
+ ):
+ fps_warn.append(
+ f"{role.upper()} FPS baixo={fps:.1f}"
+ )
+
+ if fps_warn:
+ message(
+ " | ".join(fps_warn)
+ )
+
+ # ------------------------------------------------
+ # Board
+ # ------------------------------------------------
+
+ ph = int(args.panel_height)
+ pw = int(args.panel_width)
+
+ panel_rects["rgb"] = (
+ 0,
+ 0,
+ pw,
+ ph,
+ )
+
+ panel_rects["re"] = (
+ pw,
+ 0,
+ pw * 2,
+ ph,
+ )
+
+ panel_rects["nir"] = (
+ 0,
+ ph,
+ pw,
+ ph * 2,
+ )
+
+ panel_rects["data"] = (
+ pw,
+ ph,
+ pw * 2,
+ ph * 2,
+ )
+
+ msg = (
+ last_message
+ if (
+ last_message
+ and time.time()
+ - last_message_t
+ < 3.0
+ )
+ else ""
+ )
+
+ last_board = build_board(
+ runtime,
+ specs,
+ selected_role,
+ roi_rects,
+ state,
+ field_qa_by_role,
+ args,
+ qa,
+ status_message=msg,
+ )
+
+ cv2.imshow(
+ "Focus Calibration - Production",
+ last_board,
+ )
+
+ # ------------------------------------------------
+ # Teclas
+ # ------------------------------------------------
+
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (
+ ord("q"),
+ ord("Q"),
+ 27,
+ ):
+ raise KeyboardInterrupt(
+ "Sessão de foco cancelada."
+ )
+
+ elif k == ord("1"):
+ selected_role = "rgb"
+ message("Selecionada RGB")
+
+ elif k == ord("2"):
+ selected_role = "re"
+ message("Selecionada RE")
+
+ elif k == ord("3"):
+ selected_role = "nir"
+ message("Selecionada NIR")
+
+ elif k in (
+ ord("m"),
+ ord("M"),
+ ):
+ idx = METHODS.index(
+ args.method
+ )
+
+ args.method = METHODS[
+ (idx + 1)
+ % len(METHODS)
+ ]
+
+ # Score mudou de domínio: reset de todos os sweeps.
+ for role in ROLES:
+ reset_role_state(
+ state,
+ role,
+ args,
+ )
+ field_history_by_role[role].clear()
+ field_qa_by_role[role] = None
+
+ message(
+ f"Métrica -> {args.method} | sweeps resetados"
+ )
+
+ elif k in (
+ ord("d"),
+ ord("D"),
+ ):
+ s = state[selected_role]
+
+ s["direction_idx"] = (
+ 1 - s["direction_idx"]
+ )
+
+ s["direction_name"] = (
+ "rosqueando"
+ if s["direction_idx"] == 0
+ else "desrosqueando"
+ )
+
+ message(
+ f"Sentido -> {s['direction_name']}"
+ )
+
+ elif k in (
+ ord("e"),
+ ord("E"),
+ ):
+ args.equalize_runtime = (
+ not args.equalize_runtime
+ )
+
+ for role in ROLES:
+ reset_role_state(
+ state,
+ role,
+ args,
+ )
+ field_history_by_role[role].clear()
+ field_qa_by_role[role] = None
+
+ message(
+ f"Equalize -> "
+ f"{'ON' if args.equalize_runtime else 'OFF'} | "
+ "sweeps resetados"
+ )
+
+ elif k in (
+ ord("l"),
+ ord("L"),
+ ):
+ roi_locked = not roi_locked
+
+ message(
+ f"ROI -> "
+ f"{'travada' if roi_locked else 'editavel'}"
+ )
+
+ elif k in (
+ ord("c"),
+ ord("C"),
+ ):
+ img = runtime.frames[
+ selected_role
+ ]
+
+ if img is not None:
+ roi_rects[selected_role] = (
+ default_roi_for_shape(
+ img.shape[:2],
+ frac=0.36,
+ )
+ )
+
+ reset_role_state(
+ state,
+ selected_role,
+ args,
+ )
+ field_history_by_role[selected_role].clear()
+ field_qa_by_role[selected_role] = None
+
+ message(
+ f"ROI centralizada {selected_role.upper()} | "
+ "sweep resetado"
+ )
+
+ elif k in (
+ ord("r"),
+ ord("R"),
+ ):
+ reset_role_state(
+ state,
+ selected_role,
+ args,
+ )
+ field_history_by_role[selected_role].clear()
+ field_qa_by_role[selected_role] = None
+
+ message(
+ f"Sweep resetado: {selected_role.upper()}"
+ )
+
+ elif k in (
+ ord("s"),
+ ord("S"),
+ ):
+ paths = save_role_snapshot(
+ selected_role,
+ runtime,
+ last_board,
+ snapshot_dir,
+ "diagnostic",
+ )
+
+ report["snapshots"].append({
+ "timestamp": now_str(),
+ "role": selected_role,
+ "type": "diagnostic",
+ "paths": paths,
+ })
+
+ message(
+ f"Snapshot salvo: {selected_role.upper()}"
+ )
+
+ elif k in (
+ ord("a"),
+ ord("A"),
+ ):
+ acceptance = attempt_accept(
+ selected_role,
+ state,
+ field_qa_by_role,
+ runtime,
+ roi_rects,
+ args,
+ qa,
+ )
+
+ if acceptance["accepted"]:
+ paths = save_role_snapshot(
+ selected_role,
+ runtime,
+ last_board,
+ snapshot_dir,
+ "accepted",
+ )
+
+ acceptance[
+ "snapshot_paths"
+ ] = paths
+
+ state[selected_role][
+ "acceptance"
+ ] = acceptance
+
+ message(
+ f"{selected_role.upper()} ACCEPTED ✅"
+ )
+
+ print(
+ f"[ACCEPT] {selected_role.upper()} "
+ f"ratio={acceptance['accepted_ratio_to_best']*100:.2f}% "
+ f"field={acceptance['field_qa']['status']}"
+ )
+
+ # Avança automaticamente para próxima não aceita.
+ pending = [
+ r for r in ROLES
+ if not state[r]["accepted"]
+ ]
+
+ if pending:
+ selected_role = pending[0]
+
+ else:
+ break
+
+ else:
+ message(
+ "ACCEPT bloqueado: "
+ + ", ".join(
+ acceptance[
+ "blocked_reasons"
+ ]
+ )
+ )
+
+ time.sleep(0.001)
+
+ # ------------------------------------------------
+ # Final PASS
+ # ------------------------------------------------
+
+ all_accepted = all(
+ state[r]["accepted"]
+ for r in ROLES
+ )
+
+ if not all_accepted:
+ raise RuntimeError(
+ "Loop finalizou sem todas as câmeras aceitas."
+ )
+
+ report["results_by_role"] = (
+ state_to_report(state)
+ )
+
+ report["finished_at"] = now_str()
+ report["status"] = "pass"
+
+ # Se engineering override foi usado em qualquer role,
+ # continua PASS operacional, mas fica marcado como não-homologação normal.
+ override_roles = [
+ role
+ for role in ROLES
+ if (
+ state[role]
+ .get("acceptance", {})
+ .get(
+ "engineering_override_used",
+ False,
+ )
+ )
+ ]
+
+ report["engineering_override_roles"] = (
+ override_roles
+ )
+
+ report["normal_production_homologation"] = (
+ len(override_roles) == 0
+ )
+
+ # Override de engenharia pode concluir a sessão para diagnóstico,
+ # mas NUNCA substitui o último QC homologado.
+ report["promoted"] = bool(
+ report["normal_production_homologation"]
+ )
+
+ if report["promoted"]:
+ report["promoted_at"] = now_str()
+
+ save_json_atomic(
+ candidate_report,
+ report,
+ )
+
+ marker_name = (
+ "PASS.txt"
+ if report["normal_production_homologation"]
+ else "PASS_ENGINEERING_OVERRIDE.txt"
+ )
+
+ (
+ candidate_dir
+ / marker_name
+ ).write_text(
+ (
+ f"status=pass\n"
+ f"created_at={report['created_at']}\n"
+ f"finished_at={report['finished_at']}\n"
+ f"normal_production_homologation="
+ f"{report['normal_production_homologation']}\n"
+ f"promoted={report['promoted']}\n"
+ ),
+ encoding="utf-8",
+ )
+
+ if report["promoted"]:
+ active = Path(args.active_json)
+
+ if active.exists():
+ backup = active.with_name(
+ active.stem
+ + f".backup_{sid}"
+ + active.suffix
+ )
+
+ shutil.copy2(
+ active,
+ backup,
+ )
+
+ save_json_atomic(
+ active,
+ report,
+ )
+
+ print("")
+ print("=" * 82)
+
+ if report["promoted"]:
+ print("[PASS] FOCUS QC APROVADO E HOMOLOGADO")
+ print(f"[ACTIVE QC] {args.active_json}")
+ else:
+ print("[PASS OVERRIDE] Sessão concluída somente para engenharia")
+ print("[SAFE] QC ativo homologado anterior foi preservado.")
+
+ print(f"[CANDIDATE] {candidate_dir}")
+ print("[RUNTIME] Nenhum parâmetro do module_params foi alterado.")
+ print("=" * 82)
+
+ final = np.zeros(
+ (720, 1280, 3),
+ dtype=np.uint8,
+ )
+
+ lines = [
+ "FOCUS QC - PASS",
+ "",
+ f"RGB: {specs['rgb'].sensor_name} ACCEPTED",
+ f"RE : {specs['re'].sensor_name} ACCEPTED",
+ f"NIR: {specs['nir'].sensor_name} ACCEPTED",
+ "",
+ f"Homologacao normal: "
+ f"{'SIM' if report['normal_production_homologation'] else 'NAO - override usado'}",
+ "",
+ "Nenhum parametro de runtime foi modificado.",
+ (
+ f"Relatorio ativo: {args.active_json}"
+ if report["promoted"]
+ else f"Candidate: {candidate_report}"
+ ),
+ "",
+ "Qualquer tecla fecha.",
+ ]
+
+ overlay_hud(
+ final,
+ lines,
+ x=46,
+ y=70,
+ font_scale=0.72,
+ line_step=34,
+ )
+
+ cv2.imshow(
+ "Focus Calibration - Production",
+ final,
+ )
+
+ cv2.waitKey(0)
+
+ except KeyboardInterrupt as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "created_at": now_str(),
+ "runtime_effect": "none",
+ "module_params_fragment": None,
+ }
+
+ if state is not None:
+ report["results_by_role"] = state_to_report(state)
+
+ report["status"] = "cancelled"
+ report["finished_at"] = now_str()
+ report["error"] = str(exc)
+ report["promoted"] = False
+
+ try:
+ save_json_atomic(
+ candidate_report,
+ report,
+ )
+
+ (
+ candidate_dir
+ / "CANCELLED.txt"
+ ).write_text(
+ (
+ f"status=cancelled\n"
+ f"reason={exc}\n"
+ ),
+ encoding="utf-8",
+ )
+ except Exception:
+ pass
+
+ print(f"[CANCELADO] {exc}")
+ print("[SAFE] QC ativo anterior preservado.")
+
+ except Exception as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "created_at": now_str(),
+ "runtime_effect": "none",
+ "module_params_fragment": None,
+ }
+
+ if state is not None:
+ report["results_by_role"] = state_to_report(state)
+
+ report["status"] = "error"
+ report["finished_at"] = now_str()
+ report["error"] = (
+ f"{type(exc).__name__}: {exc}"
+ )
+ report["promoted"] = False
+
+ try:
+ save_json_atomic(
+ candidate_report,
+ report,
+ )
+
+ (
+ candidate_dir
+ / "FAIL.txt"
+ ).write_text(
+ (
+ f"status=error\n"
+ f"error={type(exc).__name__}: {exc}\n"
+ ),
+ encoding="utf-8",
+ )
+ except Exception:
+ pass
+
+ print("")
+ print("=" * 82)
+ print("[ERRO] FOCUS QC ABORTADO")
+ print(f"{type(exc).__name__}: {exc}")
+ print("[SAFE] QC ativo anterior preservado.")
+ print("=" * 82)
+
+ raise
+
+ finally:
+ cv2.destroyAllWindows()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_2_intrinsics_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_2_intrinsics_calibration_tool.py
new file mode 100644
index 000000000..37c64d52a
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/utils/_2_intrinsics_calibration_tool.py
@@ -0,0 +1,2088 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+intrinsics_calibration_production.py
+
+Calibração intrínseca + distorção de lente, nível produto, para:
+
+ CAM_A = RGB = OV9782 1280x800 OU AR0234 1920x1200
+ CAM_B = RE = OV9282 1280x800
+ CAM_C = NIR = OV9282 1280x800
+
+Domínio:
+ RGB = ISP full-resolution
+ RE/NIR = MONO full-resolution
+ sem resize
+ sem undistort externo
+
+Gera:
+ calibration/intrinsics_calibration_v1.json
+
+O artefato contém:
+ module_params_fragment.intrinsics_config
+
+IMPORTANTE:
+ runtime_undistort.enabled = false por padrão.
+ Se no futuro ativarmos undistort no runtime, a homografia precisa ser
+ recalibrada no mesmo espaço geométrico undistorted.
+
+Fluxo:
+ 1) Mostre o ChArUco nas 3 câmeras.
+ 2) ENTER trava EXP/ISO.
+ 3) G captura views variadas.
+ 4) Varie X/Y, distância, yaw, pitch e roll.
+ 5) Ideal: 18-25 views.
+ 6) A calibra e executa acceptance.
+ 7) PASS promove o JSON ativo.
+
+Teclas:
+ G captura view
+ A calibra/avalia
+ V limpa views
+ S snapshot
+ 1/2/3 seleciona câmera do preview
+ U liga/desliga preview undistorted após avaliação
+ Q/ESC cancela
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import shutil
+import time
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+import cv2
+import depthai as dai
+import numpy as np
+
+
+ROLES = ("rgb", "re", "nir")
+SCHEMA = "multispec_intrinsics_calibration_v1"
+CALIBRATION_SPACE = "native_stream_no_external_undistort"
+
+TOPOLOGY = {
+ "rgb": ("CAM_A", ("OV9782", "AR0234")),
+ "re": ("CAM_B", ("OV9282",)),
+ "nir": ("CAM_C", ("OV9282",)),
+}
+
+RGB_MODES = {
+ "OV9782": ("THE_800_P", 1280, 800),
+ "AR0234": ("THE_1200_P", 1920, 1200),
+}
+MONO_MODES = {
+ "OV9282": ("THE_800_P", 1280, 800),
+}
+
+QA = {
+ "min_markers": 4,
+ "min_corners": 10,
+ "min_views": 14,
+ "recommended_views": 20,
+ "min_retained_views": 12,
+ "min_unique_ids": 24,
+
+ "duplicate_center_norm": 0.035,
+ "duplicate_area_norm": 0.055,
+
+ "coverage_span_x_warn": 0.72,
+ "coverage_span_x_bad": 0.60,
+ "coverage_span_y_warn": 0.66,
+ "coverage_span_y_bad": 0.54,
+ "coverage_hull_warn": 0.36,
+ "coverage_hull_bad": 0.25,
+
+ "area_range_warn": 0.12,
+ "area_range_bad": 0.07,
+ "center_std_x_warn": 0.18,
+ "center_std_x_bad": 0.11,
+ "center_std_y_warn": 0.16,
+ "center_std_y_bad": 0.10,
+
+ "rms_warn": 0.85,
+ "rms_bad": 1.40,
+ "fit_med_warn": 0.55,
+ "fit_med_bad": 0.90,
+ "fit_p95_warn": 1.20,
+ "fit_p95_bad": 2.00,
+
+ "cv_med_warn": 0.85,
+ "cv_med_bad": 1.35,
+ "cv_p95_warn": 1.80,
+ "cv_p95_bad": 3.00,
+ "cv_view_med_warn": 1.60,
+ "cv_view_med_bad": 2.60,
+
+ "reject_view_med": 1.80,
+ "reject_view_p95": 3.20,
+ "max_reject_rounds": 3,
+
+ "fx_fy_ratio_warn": 1.08,
+ "fx_fy_ratio_bad": 1.18,
+ "principal_margin_frac": 0.12,
+ "focal_min_frac_w": 0.18,
+ "focal_max_frac_w": 8.0,
+
+ "brown_coeff_warn": 1.5,
+ "brown_coeff_bad": 5.0,
+ "rational_coeff_warn": 5.0,
+ "rational_coeff_bad": 20.0,
+
+ "rational_min_cv_improvement": 0.10,
+
+ "edge_shift_warn_px": 8.0,
+ "edge_shift_high_px": 20.0,
+
+ "exp_rel_tol": 0.01,
+ "exp_abs_tol_us": 20,
+ "iso_tol": 5,
+}
+
+
+def now_str():
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def stamp():
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+
+
+def ensure_dir(p):
+ Path(p).mkdir(parents=True, exist_ok=True)
+
+
+def save_json_atomic(path, data):
+ p = Path(path)
+ ensure_dir(p.parent)
+ tmp = p.with_suffix(p.suffix + ".tmp")
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp, p)
+
+
+def status_rank(s):
+ return {"good": 0, "warning": 1, "bad": 2}.get(str(s), 2)
+
+
+def merge_status(a, b):
+ return a if status_rank(a) >= status_rank(b) else b
+
+
+def safe_int(v, default=None):
+ try:
+ return int(v) if v is not None else default
+ except Exception:
+ return default
+
+
+def overlay(img, lines, x=12, y=24, scale=0.45, step=19):
+ yy = y
+ for line in lines:
+ if yy > img.shape[0] - 6:
+ break
+ s = str(line)
+ cv2.putText(img, s, (x, yy), cv2.FONT_HERSHEY_SIMPLEX,
+ scale, (0, 0, 0), 3, cv2.LINE_AA)
+ cv2.putText(img, s, (x, yy), cv2.FONT_HERSHEY_SIMPLEX,
+ scale, (255, 255, 255), 1, cv2.LINE_AA)
+ yy += step
+
+
+def socket_name(sock):
+ name = getattr(sock, "name", None)
+ if name:
+ return str(name)
+ text = str(sock)
+ for x in ("CAM_A", "CAM_B", "CAM_C", "CAM_D"):
+ if x in text:
+ return x
+ return text
+
+
+def get_socket(name):
+ return {
+ "CAM_A": dai.CameraBoardSocket.CAM_A,
+ "CAM_B": dai.CameraBoardSocket.CAM_B,
+ "CAM_C": dai.CameraBoardSocket.CAM_C,
+ }[name]
+
+
+def feature_types(f):
+ return [str(x).upper() for x in (getattr(f, "supportedTypes", []) or [])]
+
+
+def feature_is_color(f):
+ types = feature_types(f)
+ sensor = str(getattr(f, "sensorName", "") or "").upper()
+ if any("COLOR" in x for x in types):
+ return True
+ if any("MONO" in x for x in types):
+ return False
+ return sensor in {"OV9782", "AR0234"}
+
+
+def feature_is_mono(f):
+ types = feature_types(f)
+ if any("MONO" in x for x in types):
+ return True
+ if any("COLOR" in x for x in types):
+ return False
+ return not feature_is_color(f)
+
+
+@dataclass
+class CameraSpec:
+ role: str
+ socket: str
+ sensor: str
+ width: int
+ height: int
+ resolution_name: str
+ stream: str
+ control: str
+ is_color: bool
+ source: str
+
+
+def discover(mx_id: Optional[str]):
+ info = dai.DeviceInfo(mx_id) if mx_id else None
+ ctx = dai.Device(info) if info is not None else dai.Device()
+
+ with ctx as dev:
+ actual_mx = None
+ for method in ("getMxId", "getDeviceId"):
+ fn = getattr(dev, method, None)
+ if callable(fn):
+ try:
+ actual_mx = str(fn())
+ if actual_mx:
+ break
+ except Exception:
+ pass
+
+ try:
+ usb = str(dev.getUsbSpeed())
+ except Exception:
+ usb = None
+
+ rows = []
+ for f in dev.getConnectedCameraFeatures():
+ rows.append({
+ "socket": socket_name(f.socket),
+ "sensor": str(f.sensorName or "").upper(),
+ "width": int(getattr(f, "width", 0) or 0),
+ "height": int(getattr(f, "height", 0) or 0),
+ "is_color": feature_is_color(f),
+ "is_mono": feature_is_mono(f),
+ "supported_types": feature_types(f),
+ })
+
+ return rows, actual_mx, usb
+
+
+def validate_specs(rows):
+ by_socket = {r["socket"]: r for r in rows}
+ specs = {}
+ errors = []
+
+ for role in ROLES:
+ sock, allowed = TOPOLOGY[role]
+ row = by_socket.get(sock)
+
+ if row is None:
+ errors.append(f"{role}: {sock} ausente")
+ continue
+
+ sensor = row["sensor"]
+ if sensor not in allowed:
+ errors.append(f"{role}: {sock}={sensor}, esperado={allowed}")
+ continue
+
+ if role == "rgb":
+ mode, w, h = RGB_MODES[sensor]
+ if not row["is_color"]:
+ errors.append(f"{sock}/{sensor} não COLOR")
+ else:
+ mode, w, h = MONO_MODES[sensor]
+ if not row["is_mono"]:
+ errors.append(f"{sock}/{sensor} não MONO")
+
+ if row["width"] and row["height"]:
+ if (row["width"], row["height"]) != (w, h):
+ errors.append(
+ f"{sock}/{sensor}: {row['width']}x{row['height']} != {w}x{h}"
+ )
+
+ specs[role] = CameraSpec(
+ role=role,
+ socket=sock,
+ sensor=sensor,
+ width=w,
+ height=h,
+ resolution_name=mode,
+ stream=f"intr_{role}",
+ control=f"intr_ctrl_{role}",
+ is_color=(role == "rgb"),
+ source="ISP" if role == "rgb" else "MONO_OUT",
+ )
+
+ if errors:
+ raise RuntimeError("Topologia inválida:\n - " + "\n - ".join(errors))
+
+ return specs
+
+
+def hardware_signature(specs):
+ return {
+ role: {
+ "socket": s.socket,
+ "sensor": s.sensor,
+ "size": [s.width, s.height],
+ "stream_source": s.source,
+ }
+ for role, s in specs.items()
+ }
+
+
+def build_pipeline(specs, fps, sharpness, luma, chroma):
+ p = dai.Pipeline()
+ isp = {"sharpness": None, "luma_denoise": None, "chroma_denoise": None,
+ "errors": []}
+
+ for role in ROLES:
+ s = specs[role]
+ sock = get_socket(s.socket)
+
+ if s.is_color:
+ cam = p.createColorCamera()
+ cam.setBoardSocket(sock)
+ enum = getattr(dai.ColorCameraProperties.SensorResolution,
+ s.resolution_name, None)
+ if enum is None:
+ raise RuntimeError(f"DepthAI sem {s.resolution_name}")
+ cam.setResolution(enum)
+ cam.setFps(float(fps))
+ cam.setInterleaved(False)
+ try:
+ cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.BGR)
+ cam.setIspScale(1, 1)
+ except Exception:
+ pass
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+ try:
+ cam.initialControl.setAutoWhiteBalanceLock(False)
+ except Exception:
+ pass
+
+ for key, method, value in (
+ ("sharpness", "setSharpness", sharpness),
+ ("luma_denoise", "setLumaDenoise", luma),
+ ("chroma_denoise", "setChromaDenoise", chroma),
+ ):
+ fn = getattr(cam.initialControl, method, None)
+ if callable(fn):
+ try:
+ fn(int(value))
+ isp[key] = int(value)
+ except Exception as exc:
+ isp["errors"].append(f"{method}:{exc}")
+ else:
+ isp["errors"].append(f"{method}:unsupported")
+
+ output = cam.isp
+ else:
+ cam = p.createMonoCamera()
+ cam.setBoardSocket(sock)
+ enum = getattr(dai.MonoCameraProperties.SensorResolution,
+ s.resolution_name, None)
+ if enum is None:
+ raise RuntimeError(f"DepthAI sem {s.resolution_name}")
+ cam.setResolution(enum)
+ cam.setFps(float(fps))
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+ output = cam.out
+
+ xo = p.createXLinkOut()
+ xo.setStreamName(s.stream)
+ output.link(xo.input)
+
+ xi = p.createXLinkIn()
+ xi.setStreamName(s.control)
+ xi.out.link(cam.inputControl)
+
+ return p, isp
+
+
+@dataclass
+class LockedControl:
+ role: str
+ exposure_time_us: int
+ sensitivity_iso: int
+
+
+def packet_controls(pkt):
+ exp = iso = seq = None
+ try:
+ v = pkt.getExposureTime()
+ exp = int(round(v.total_seconds() * 1e6)) if hasattr(v, "total_seconds") else int(v)
+ except Exception:
+ pass
+ try:
+ iso = int(pkt.getSensitivity())
+ except Exception:
+ pass
+ try:
+ seq = int(pkt.getSequenceNum())
+ except Exception:
+ pass
+ return {
+ "exposure_time_us": exp,
+ "sensitivity_iso": iso,
+ "sequence_num": seq,
+ }
+
+
+def controls_match(actual, target):
+ exp = safe_int(actual.get("exposure_time_us"))
+ iso = safe_int(actual.get("sensitivity_iso"))
+ if exp is None or iso is None:
+ return False
+ exp_tol = max(
+ QA["exp_abs_tol_us"],
+ int(round(target.exposure_time_us * QA["exp_rel_tol"])),
+ )
+ return (
+ abs(exp - target.exposure_time_us) <= exp_tol
+ and abs(iso - target.sensitivity_iso) <= QA["iso_tol"]
+ )
+
+
+class Runtime:
+ def __init__(self, dev, specs):
+ self.specs = specs
+ self.oq = {
+ r: dev.getOutputQueue(name=specs[r].stream, maxSize=3, blocking=False)
+ for r in ROLES
+ }
+ self.cq = {
+ r: dev.getInputQueue(specs[r].control)
+ for r in ROLES
+ }
+ self.frames = {r: None for r in ROLES}
+ self.ctrl = {r: {} for r in ROLES}
+ self.seq = {r: None for r in ROLES}
+
+ def poll(self):
+ fresh = set()
+ for r in ROLES:
+ pkt = self.oq[r].tryGet()
+ if pkt is None:
+ continue
+
+ c = packet_controls(pkt)
+ seq = c["sequence_num"]
+
+ if seq is not None and seq == self.seq[r]:
+ continue
+
+ img = pkt.getCvFrame()
+ if img is None:
+ continue
+
+ if img.shape[:2] != (self.specs[r].height, self.specs[r].width):
+ raise RuntimeError(
+ f"{r}: shape={img.shape[:2]}, esperado="
+ f"{(self.specs[r].height, self.specs[r].width)}"
+ )
+
+ self.frames[r] = np.ascontiguousarray(img)
+ self.ctrl[r] = c
+ self.seq[r] = seq
+ fresh.add(r)
+
+ return fresh
+
+ def wait_all(self, timeout=8.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ self.poll()
+ if all(self.frames[r] is not None for r in ROLES):
+ return
+ time.sleep(0.003)
+ raise TimeoutError("Timeout aguardando câmeras.")
+
+ def fresh_triplet(self, timeout=3.0):
+ start = dict(self.seq)
+ frames, ctrls = {}, {}
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ fresh = self.poll()
+ for r in fresh:
+ if r not in frames and self.seq[r] != start.get(r):
+ frames[r] = self.frames[r].copy()
+ ctrls[r] = dict(self.ctrl[r])
+
+ if len(frames) == 3:
+ return {"frames": frames, "controls": ctrls}
+
+ time.sleep(0.001)
+
+ raise TimeoutError("Triplet incompleto.")
+
+ def send_manual(self, locked):
+ for r in ROLES:
+ c = dai.CameraControl()
+ c.setManualExposure(
+ int(locked[r].exposure_time_us),
+ int(locked[r].sensitivity_iso),
+ )
+ if r == "rgb":
+ try:
+ c.setAutoWhiteBalanceLock(True)
+ except Exception:
+ pass
+ self.cq[r].send(c)
+
+ def verify_manual(self, locked, settle_frames=8, timeout=10.0):
+ seen = dict(self.seq)
+ ok = {r: 0 for r in ROLES}
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ fresh = self.poll()
+ for r in fresh:
+ if self.seq[r] == seen.get(r):
+ continue
+ seen[r] = self.seq[r]
+ ok[r] = ok[r] + 1 if controls_match(self.ctrl[r], locked[r]) else 0
+
+ if all(ok[r] >= settle_frames for r in ROLES):
+ return
+
+ time.sleep(0.002)
+
+ raise RuntimeError(f"EXP/ISO não estabilizou: {ok}")
+
+
+def lock_current_ae(runtime):
+ locked = {}
+ for role in ROLES:
+ exps, isos = [], []
+ t0 = time.time()
+
+ while time.time() - t0 < 0.8:
+ fresh = runtime.poll()
+ if role not in fresh:
+ time.sleep(0.002)
+ continue
+
+ e = safe_int(runtime.ctrl[role].get("exposure_time_us"))
+ i = safe_int(runtime.ctrl[role].get("sensitivity_iso"))
+
+ if e and e > 0:
+ exps.append(e)
+ if i and i > 0:
+ isos.append(i)
+
+ if not exps or not isos:
+ raise RuntimeError(f"Sem EXP/ISO em {role}")
+
+ locked[role] = LockedControl(
+ role=role,
+ exposure_time_us=int(round(np.median(exps))),
+ sensitivity_iso=int(round(np.median(isos))),
+ )
+
+ runtime.send_manual(locked)
+ runtime.verify_manual(locked)
+ return locked
+
+
+# ============================================================
+# ChArUco
+# ============================================================
+
+def require_aruco():
+ if not hasattr(cv2, "aruco"):
+ raise RuntimeError("Use opencv-contrib-python; cv2.aruco não existe.")
+
+
+def aruco_dictionary(name):
+ require_aruco()
+ if not hasattr(cv2.aruco, name):
+ raise ValueError(f"Dicionário inválido: {name}")
+ enum = getattr(cv2.aruco, name)
+ if hasattr(cv2.aruco, "getPredefinedDictionary"):
+ return cv2.aruco.getPredefinedDictionary(enum)
+ return cv2.aruco.Dictionary_get(enum)
+
+
+def make_board(nx, ny, square, marker, dictionary):
+ if hasattr(cv2.aruco, "CharucoBoard"):
+ try:
+ return cv2.aruco.CharucoBoard(
+ (int(nx), int(ny)), float(square), float(marker), dictionary
+ )
+ except Exception:
+ pass
+
+ if hasattr(cv2.aruco, "CharucoBoard_create"):
+ return cv2.aruco.CharucoBoard_create(
+ int(nx), int(ny), float(square), float(marker), dictionary
+ )
+
+ raise RuntimeError("CharucoBoard incompatível com este OpenCV.")
+
+
+def board_object_points(board):
+ if hasattr(board, "getChessboardCorners"):
+ pts = board.getChessboardCorners()
+ else:
+ pts = getattr(board, "chessboardCorners", None)
+
+ if pts is None:
+ raise RuntimeError("Sem chessboard corners no board.")
+
+ return np.asarray(pts, dtype=np.float32).reshape(-1, 3)
+
+
+def detector_params():
+ if hasattr(cv2.aruco, "DetectorParameters"):
+ p = cv2.aruco.DetectorParameters()
+ else:
+ p = cv2.aruco.DetectorParameters_create()
+
+ try:
+ p.cornerRefinementMethod = getattr(cv2.aruco, "CORNER_REFINE_SUBPIX", 1)
+ p.cornerRefinementWinSize = 5
+ p.cornerRefinementMaxIterations = 40
+ p.cornerRefinementMinAccuracy = 0.01
+ except Exception:
+ pass
+
+ return p
+
+
+def detect_charuco(img, board, dictionary, params):
+ gray = img if img.ndim == 2 else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+
+ if hasattr(cv2.aruco, "ArucoDetector"):
+ det = cv2.aruco.ArucoDetector(dictionary, params)
+ mc, mi, _ = det.detectMarkers(gray)
+ else:
+ mc, mi, _ = cv2.aruco.detectMarkers(gray, dictionary, parameters=params)
+
+ markers = 0 if mi is None else len(mi)
+ if markers == 0:
+ return {"markers": 0, "corners": 0, "points": {}}
+
+ cc = ci = None
+
+ if hasattr(cv2.aruco, "CharucoDetector"):
+ try:
+ cd = cv2.aruco.CharucoDetector(board)
+ out = cd.detectBoard(
+ image=gray,
+ markerCorners=mc,
+ markerIds=mi,
+ )
+ cc, ci = out[0], out[1]
+ except Exception:
+ pass
+
+ if cc is None and hasattr(cv2.aruco, "interpolateCornersCharuco"):
+ ret, cc, ci = cv2.aruco.interpolateCornersCharuco(
+ markerCorners=mc,
+ markerIds=mi,
+ image=gray,
+ board=board,
+ )
+ if ret is None or ret <= 0:
+ cc = ci = None
+
+ if cc is None or ci is None:
+ return {"markers": markers, "corners": 0, "points": {}}
+
+ pts = np.asarray(cc, dtype=np.float32).reshape(-1, 2)
+ ids = np.asarray(ci).reshape(-1)
+
+ try:
+ cv2.cornerSubPix(
+ gray,
+ pts.reshape(-1, 1, 2),
+ (4, 4),
+ (-1, -1),
+ (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 40, 0.01),
+ )
+ except Exception:
+ pass
+
+ return {
+ "markers": int(markers),
+ "corners": int(len(ids)),
+ "points": {
+ int(cid): [float(pt[0]), float(pt[1])]
+ for cid, pt in zip(ids, pts)
+ },
+ }
+
+
+def detect_triplet(triplet, board, dictionary, params):
+ out = {}
+ reasons = []
+
+ for role in ROLES:
+ d = detect_charuco(triplet["frames"][role], board, dictionary, params)
+ out[role] = d
+
+ if d["markers"] < QA["min_markers"]:
+ reasons.append(f"{role}:markers={d['markers']}")
+ if d["corners"] < QA["min_corners"]:
+ reasons.append(f"{role}:corners={d['corners']}")
+
+ return {
+ "valid": not reasons,
+ "reasons": reasons,
+ "detections": out,
+ }
+
+
+# ============================================================
+# Pose/cobertura
+# ============================================================
+
+def pose_signature(points, shape_hw):
+ pts = np.asarray(list(points.values()), dtype=np.float32)
+ h, w = shape_hw
+
+ x0, x1 = float(pts[:, 0].min()), float(pts[:, 0].max())
+ y0, y1 = float(pts[:, 1].min()), float(pts[:, 1].max())
+
+ return {
+ "cx": ((x0 + x1) * 0.5) / w,
+ "cy": ((y0 + y1) * 0.5) / h,
+ "area": ((x1 - x0) * (y1 - y0)) / float(w * h),
+ }
+
+
+def duplicate_pose(sig, views):
+ if not views:
+ return False
+
+ # Rejeita somente se parecer repetida nas três câmeras.
+ repeated = 0
+
+ for role in ROLES:
+ s = sig[role]
+ role_dup = False
+
+ for v in views:
+ p = v["pose"][role]
+ d = math.hypot(s["cx"] - p["cx"], s["cy"] - p["cy"])
+ da = abs(s["area"] - p["area"])
+
+ if d < QA["duplicate_center_norm"] and da < QA["duplicate_area_norm"]:
+ role_dup = True
+ break
+
+ repeated += int(role_dup)
+
+ return repeated == 3
+
+
+def coverage(points, image_size):
+ pts = np.asarray(points, dtype=np.float32)
+ w, h = image_size
+
+ if len(pts) < 3:
+ return {"span_x": 0.0, "span_y": 0.0, "hull": 0.0}
+
+ hull = cv2.convexHull(pts.reshape(-1, 1, 2))
+
+ return {
+ "span_x": float((pts[:, 0].max() - pts[:, 0].min()) / w),
+ "span_y": float((pts[:, 1].max() - pts[:, 1].min()) / h),
+ "hull": float(cv2.contourArea(hull) / (w * h)),
+ }
+
+
+def diversity(views, role):
+ if not views:
+ return {}
+
+ cx = np.array([v["pose"][role]["cx"] for v in views], dtype=np.float64)
+ cy = np.array([v["pose"][role]["cy"] for v in views], dtype=np.float64)
+ area = np.array([v["pose"][role]["area"] for v in views], dtype=np.float64)
+
+ return {
+ "center_std_x": float(cx.std()),
+ "center_std_y": float(cy.std()),
+ "area_min": float(area.min()),
+ "area_max": float(area.max()),
+ "area_range": float(area.max() - area.min()),
+ }
+
+
+# ============================================================
+# Dataset / calibração
+# ============================================================
+
+def view_object_image(view, role, board_pts):
+ p = view["detections"][role]["points"]
+ ids = sorted(int(x) for x in p.keys())
+
+ obj = np.asarray([board_pts[i] for i in ids], dtype=np.float32).reshape(-1, 1, 3)
+ img = np.asarray([p[str(i)] if str(i) in p else p[i] for i in ids],
+ dtype=np.float32).reshape(-1, 1, 2)
+
+ return ids, obj, img
+
+
+def build_dataset(views, role, board_pts, retained=None):
+ allowed = None if retained is None else set(int(x) for x in retained)
+ objs, imgs, view_ids, charuco_ids = [], [], [], []
+
+ for v in views:
+ vid = int(v["view_id"])
+ if allowed is not None and vid not in allowed:
+ continue
+
+ ids, obj, img = view_object_image(v, role, board_pts)
+ if len(ids) < 4:
+ continue
+
+ objs.append(obj)
+ imgs.append(img)
+ view_ids.append(vid)
+ charuco_ids.extend(ids)
+
+ return {
+ "objects": objs,
+ "images": imgs,
+ "view_ids": view_ids,
+ "charuco_ids": charuco_ids,
+ }
+
+
+def model_flags(name):
+ if name == "brown5":
+ return cv2.CALIB_FIX_K4 | cv2.CALIB_FIX_K5 | cv2.CALIB_FIX_K6
+ if name == "rational8":
+ return cv2.CALIB_RATIONAL_MODEL
+ raise ValueError(name)
+
+
+def calibrate_model(dataset, image_size, model_name):
+ if len(dataset["objects"]) < 3:
+ raise RuntimeError("Poucas views.")
+
+ w, h = image_size
+ K0 = np.array([
+ [float(w), 0.0, w / 2.0],
+ [0.0, float(w), h / 2.0],
+ [0.0, 0.0, 1.0],
+ ], dtype=np.float64)
+
+ D0 = np.zeros((8 if model_name == "rational8" else 5, 1), dtype=np.float64)
+ flags = model_flags(model_name)
+ criteria = (
+ cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
+ 100,
+ 1e-10,
+ )
+
+ if hasattr(cv2, "calibrateCameraExtended"):
+ rms, K, D, rvecs, tvecs, std_i, _, per_view = cv2.calibrateCameraExtended(
+ dataset["objects"],
+ dataset["images"],
+ (w, h),
+ K0,
+ D0,
+ flags=flags,
+ criteria=criteria,
+ )
+ else:
+ rms, K, D, rvecs, tvecs = cv2.calibrateCamera(
+ dataset["objects"],
+ dataset["images"],
+ (w, h),
+ K0,
+ D0,
+ flags=flags,
+ criteria=criteria,
+ )
+ std_i = per_view = None
+
+ model = {
+ "name": model_name,
+ "rms": float(rms),
+ "K": np.asarray(K, dtype=np.float64),
+ "D": np.asarray(D, dtype=np.float64).reshape(-1),
+ "rvecs": [np.asarray(x).reshape(3) for x in rvecs],
+ "tvecs": [np.asarray(x).reshape(3) for x in tvecs],
+ "std_intrinsics": None if std_i is None else np.asarray(std_i).reshape(-1),
+ "opencv_per_view": None if per_view is None else np.asarray(per_view).reshape(-1),
+ }
+
+ return model
+
+
+def reprojection_errors(model, dataset):
+ per_view = []
+ all_e = []
+
+ for idx, (obj, img, rv, tv) in enumerate(zip(
+ dataset["objects"], dataset["images"], model["rvecs"], model["tvecs"]
+ )):
+ proj, _ = cv2.projectPoints(obj, rv, tv, model["K"], model["D"])
+ obs = np.asarray(img).reshape(-1, 2)
+ pred = np.asarray(proj).reshape(-1, 2)
+ e = np.linalg.norm(obs - pred, axis=1)
+
+ all_e.extend(e.tolist())
+ per_view.append({
+ "view_id": int(dataset["view_ids"][idx]),
+ "count": int(len(e)),
+ "mean_px": float(e.mean()),
+ "median_px": float(np.median(e)),
+ "p95_px": float(np.percentile(e, 95)),
+ "max_px": float(e.max()),
+ })
+
+ a = np.asarray(all_e, dtype=np.float64)
+
+ return {
+ "per_view": per_view,
+ "overall": {
+ "count": int(a.size),
+ "mean_px": float(a.mean()) if a.size else None,
+ "median_px": float(np.median(a)) if a.size else None,
+ "p95_px": float(np.percentile(a, 95)) if a.size else None,
+ "max_px": float(a.max()) if a.size else None,
+ },
+ }
+
+
+def iterative_reject(views, role, board_pts, image_size, model_name):
+ retained = [int(v["view_id"]) for v in views]
+ rejected = []
+ rounds = []
+
+ for round_idx in range(QA["max_reject_rounds"] + 1):
+ ds = build_dataset(views, role, board_pts, retained)
+
+ if len(ds["objects"]) < QA["min_retained_views"]:
+ break
+
+ model = calibrate_model(ds, image_size, model_name)
+ err = reprojection_errors(model, ds)
+
+ bad = [
+ x for x in err["per_view"]
+ if (
+ x["median_px"] > QA["reject_view_med"]
+ or x["p95_px"] > QA["reject_view_p95"]
+ )
+ ]
+
+ rounds.append({
+ "round": round_idx,
+ "retained_before": list(retained),
+ "per_view": err["per_view"],
+ "bad": [x["view_id"] for x in bad],
+ })
+
+ if not bad:
+ break
+
+ worst = max(bad, key=lambda x: x["median_px"])
+
+ if len(retained) - 1 < QA["min_retained_views"]:
+ break
+
+ retained.remove(worst["view_id"])
+ rejected.append(worst["view_id"])
+
+ return {
+ "retained": retained,
+ "rejected": rejected,
+ "rounds": rounds,
+ }
+
+
+def leave_one_out(views, role, board_pts, image_size, model_name, retained):
+ retained = sorted(set(int(x) for x in retained))
+ reports = []
+ all_e = []
+
+ if len(retained) < 6:
+ return {"valid": False, "views": [], "overall": {}}
+
+ for held in retained:
+ train_ids = [x for x in retained if x != held]
+ train = build_dataset(views, role, board_pts, train_ids)
+ test = build_dataset(views, role, board_pts, [held])
+
+ if len(train["objects"]) < 5 or len(test["objects"]) != 1:
+ continue
+
+ try:
+ model = calibrate_model(train, image_size, model_name)
+ obj = test["objects"][0]
+ img = test["images"][0]
+
+ ok, rv, tv = cv2.solvePnP(
+ obj, img, model["K"], model["D"],
+ flags=cv2.SOLVEPNP_ITERATIVE,
+ )
+ if not ok:
+ continue
+
+ proj, _ = cv2.projectPoints(obj, rv, tv, model["K"], model["D"])
+ obs = np.asarray(img).reshape(-1, 2)
+ pred = np.asarray(proj).reshape(-1, 2)
+ e = np.linalg.norm(obs - pred, axis=1)
+
+ all_e.extend(e.tolist())
+ reports.append({
+ "held_out_view_id": int(held),
+ "count": int(len(e)),
+ "mean_px": float(e.mean()),
+ "median_px": float(np.median(e)),
+ "p95_px": float(np.percentile(e, 95)),
+ "max_px": float(e.max()),
+ })
+ except Exception:
+ continue
+
+ a = np.asarray(all_e, dtype=np.float64)
+
+ return {
+ "valid": len(reports) == len(retained),
+ "views": reports,
+ "overall": {
+ "count": int(a.size),
+ "mean_px": float(a.mean()) if a.size else None,
+ "median_px": float(np.median(a)) if a.size else None,
+ "p95_px": float(np.percentile(a, 95)) if a.size else None,
+ "max_px": float(a.max()) if a.size else None,
+ },
+ }
+
+
+def distortion_shift(K, D, image_size):
+ w, h = image_size
+ xs = np.linspace(0, w - 1, 21)
+ ys = np.linspace(0, h - 1, 15)
+
+ pts = np.asarray([[x, y] for y in ys for x in xs],
+ dtype=np.float32).reshape(-1, 1, 2)
+
+ und = cv2.undistortPoints(pts, K, D, P=K).reshape(-1, 2)
+ orig = pts.reshape(-1, 2)
+ shift = np.linalg.norm(und - orig, axis=1)
+
+ edge = (
+ (orig[:, 0] < 0.15 * w)
+ | (orig[:, 0] > 0.85 * w)
+ | (orig[:, 1] < 0.15 * h)
+ | (orig[:, 1] > 0.85 * h)
+ )
+
+ e = shift[edge]
+
+ return {
+ "median_px": float(np.median(shift)),
+ "p95_px": float(np.percentile(shift, 95)),
+ "max_px": float(shift.max()),
+ "edge_median_px": float(np.median(e)),
+ "edge_p95_px": float(np.percentile(e, 95)),
+ "edge_max_px": float(e.max()),
+ }
+
+
+def undistort_products(K, D, image_size):
+ w, h = image_size
+ out = {}
+
+ for alpha in (0.0, 0.5, 1.0):
+ newK, roi = cv2.getOptimalNewCameraMatrix(
+ K, D, (w, h), alpha, (w, h), centerPrincipalPoint=False
+ )
+
+ out[str(alpha)] = {
+ "alpha": alpha,
+ "new_camera_matrix": np.asarray(newK).tolist(),
+ "valid_roi": [int(x) for x in roi],
+ }
+
+ return out
+
+
+def sanity(model, image_size):
+ w, h = image_size
+ K, D = model["K"], model["D"]
+
+ fx, fy = float(K[0, 0]), float(K[1, 1])
+ cx, cy = float(K[0, 2]), float(K[1, 2])
+
+ ratio = max(fx / max(fy, 1e-9), fy / max(fx, 1e-9))
+ margin_x = QA["principal_margin_frac"] * w
+ margin_y = QA["principal_margin_frac"] * h
+
+ status = "good"
+ reasons = []
+
+ if not (QA["focal_min_frac_w"] * w <= fx <= QA["focal_max_frac_w"] * w):
+ status = "bad"
+ reasons.append(f"fx_implausible:{fx:.1f}")
+
+ if not (QA["focal_min_frac_w"] * w <= fy <= QA["focal_max_frac_w"] * w):
+ status = "bad"
+ reasons.append(f"fy_implausible:{fy:.1f}")
+
+ if ratio > QA["fx_fy_ratio_bad"]:
+ status = "bad"
+ reasons.append(f"fx_fy_ratio_bad:{ratio:.3f}")
+ elif ratio > QA["fx_fy_ratio_warn"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"fx_fy_ratio_warn:{ratio:.3f}")
+
+ if not (-margin_x <= cx <= w + margin_x and -margin_y <= cy <= h + margin_y):
+ status = "bad"
+ reasons.append(f"principal_point_bad:{cx:.1f},{cy:.1f}")
+
+ max_abs = float(np.max(np.abs(D))) if len(D) else 0.0
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "fx": fx,
+ "fy": fy,
+ "cx": cx,
+ "cy": cy,
+ "fx_fy_ratio": ratio,
+ "max_abs_dist_coeff": max_abs,
+ }
+
+
+def evaluate_model(views, role, board_pts, image_size, model_name):
+ rej = iterative_reject(
+ views, role, board_pts, image_size, model_name
+ )
+
+ ds = build_dataset(
+ views, role, board_pts, rej["retained"]
+ )
+
+ if len(ds["objects"]) < QA["min_retained_views"]:
+ return {
+ "status": "bad",
+ "reasons": ["retained_views_insufficient"],
+ "model_name": model_name,
+ }
+
+ model = calibrate_model(ds, image_size, model_name)
+ fit = reprojection_errors(model, ds)
+ cv = leave_one_out(
+ views, role, board_pts, image_size, model_name, rej["retained"]
+ )
+
+ all_points = []
+ for im in ds["images"]:
+ all_points.extend(np.asarray(im).reshape(-1, 2).tolist())
+
+ cov = coverage(all_points, image_size)
+ retained_views = [v for v in views if int(v["view_id"]) in set(rej["retained"])]
+ div = diversity(retained_views, role)
+ sane = sanity(model, image_size)
+ shift = distortion_shift(model["K"], model["D"], image_size)
+ und = undistort_products(model["K"], model["D"], image_size)
+
+ status = sane["status"]
+ reasons = list(sane["reasons"])
+
+ def high(value, warn, bad, name):
+ nonlocal status
+ if value is None:
+ status = "bad"
+ reasons.append(f"{name}_missing")
+ elif value > bad:
+ status = "bad"
+ reasons.append(f"{name}_bad:{value:.4f}")
+ elif value > warn:
+ status = merge_status(status, "warning")
+ reasons.append(f"{name}_warn:{value:.4f}")
+
+ def low(value, warn, bad, name):
+ nonlocal status
+ if value < bad:
+ status = "bad"
+ reasons.append(f"{name}_bad:{value:.4f}")
+ elif value < warn:
+ status = merge_status(status, "warning")
+ reasons.append(f"{name}_warn:{value:.4f}")
+
+ high(model["rms"], QA["rms_warn"], QA["rms_bad"], "rms")
+ high(fit["overall"]["median_px"], QA["fit_med_warn"], QA["fit_med_bad"], "fit_med")
+ high(fit["overall"]["p95_px"], QA["fit_p95_warn"], QA["fit_p95_bad"], "fit_p95")
+
+ if not cv.get("valid"):
+ status = "bad"
+ reasons.append("cross_validation_invalid")
+ else:
+ high(cv["overall"].get("median_px"), QA["cv_med_warn"], QA["cv_med_bad"], "cv_med")
+ high(cv["overall"].get("p95_px"), QA["cv_p95_warn"], QA["cv_p95_bad"], "cv_p95")
+ medians = [x["median_px"] for x in cv["views"]]
+ high(max(medians) if medians else None,
+ QA["cv_view_med_warn"], QA["cv_view_med_bad"], "cv_worst_view_med")
+
+ low(cov["span_x"], QA["coverage_span_x_warn"], QA["coverage_span_x_bad"], "coverage_x")
+ low(cov["span_y"], QA["coverage_span_y_warn"], QA["coverage_span_y_bad"], "coverage_y")
+ low(cov["hull"], QA["coverage_hull_warn"], QA["coverage_hull_bad"], "coverage_hull")
+ low(div.get("area_range", 0), QA["area_range_warn"], QA["area_range_bad"], "area_range")
+ low(div.get("center_std_x", 0), QA["center_std_x_warn"], QA["center_std_x_bad"], "center_std_x")
+ low(div.get("center_std_y", 0), QA["center_std_y_warn"], QA["center_std_y_bad"], "center_std_y")
+
+ max_abs = sane["max_abs_dist_coeff"]
+ if model_name == "brown5":
+ warn, bad = QA["brown_coeff_warn"], QA["brown_coeff_bad"]
+ else:
+ warn, bad = QA["rational_coeff_warn"], QA["rational_coeff_bad"]
+
+ if max_abs > bad:
+ status = "bad"
+ reasons.append(f"dist_coeff_bad:{max_abs:.3f}")
+ elif max_abs > warn:
+ status = merge_status(status, "warning")
+ reasons.append(f"dist_coeff_warn:{max_abs:.3f}")
+
+ unique_ids = sorted(set(ds["charuco_ids"]))
+ if len(unique_ids) < QA["min_unique_ids"]:
+ status = "bad"
+ reasons.append(f"unique_ids_bad:{len(unique_ids)}")
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "model_name": model_name,
+ "image_size": list(image_size),
+ "camera_matrix": model["K"].tolist(),
+ "dist_coeffs": model["D"].tolist(),
+ "rms_px": model["rms"],
+ "std_intrinsics": None if model["std_intrinsics"] is None
+ else model["std_intrinsics"].tolist(),
+ "retained_view_ids": rej["retained"],
+ "rejected_view_ids": rej["rejected"],
+ "view_rejection": rej,
+ "fit_error": fit,
+ "cross_validation": cv,
+ "coverage": cov,
+ "diversity": div,
+ "unique_charuco_ids": len(unique_ids),
+ "sanity": sane,
+ "distortion_shift": shift,
+ "undistort_products": und,
+ }
+
+
+def choose_model(brown, rational, requested):
+ if requested == "brown5":
+ return brown, {"selected": "brown5", "reason": "forced"}
+ if requested == "rational8":
+ return rational, {"selected": "rational8", "reason": "forced"}
+
+ if rational["status"] == "bad":
+ return brown, {"selected": "brown5", "reason": "rational_bad"}
+ if brown["status"] == "bad":
+ return rational, {"selected": "rational8", "reason": "brown_bad"}
+
+ b = brown["cross_validation"]["overall"].get("p95_px")
+ r = rational["cross_validation"]["overall"].get("p95_px")
+
+ if b is None or r is None:
+ return brown, {"selected": "brown5", "reason": "prefer_simple"}
+
+ improvement = (b - r) / max(b, 1e-9)
+
+ if improvement >= QA["rational_min_cv_improvement"]:
+ return rational, {
+ "selected": "rational8",
+ "reason": "cv_materially_better",
+ "cv_p95_improvement_fraction": float(improvement),
+ }
+
+ return brown, {
+ "selected": "brown5",
+ "reason": "prefer_simple",
+ "cv_p95_improvement_fraction": float(improvement),
+ }
+
+
+def evaluate_all(views, board_pts, specs, requested):
+ cameras = {}
+ overall = "good"
+ reasons = []
+
+ for role in ROLES:
+ size = (specs[role].width, specs[role].height)
+
+ brown = evaluate_model(
+ views, role, board_pts, size, "brown5"
+ )
+ rational = evaluate_model(
+ views, role, board_pts, size, "rational8"
+ )
+
+ selected, selection = choose_model(
+ brown, rational, requested
+ )
+
+ edge = selected.get("distortion_shift", {}).get("edge_p95_px", 0.0)
+
+ if edge >= QA["edge_shift_high_px"]:
+ rec = "strongly_consider_runtime_undistort"
+ elif edge >= QA["edge_shift_warn_px"]:
+ rec = "consider_runtime_undistort"
+ else:
+ rec = "runtime_undistort_optional"
+
+ cameras[role] = {
+ "status": selected["status"],
+ "reasons": selected["reasons"],
+ "model_selection": selection,
+ "selected_model": selected,
+ "candidate_models": {
+ "brown5": brown,
+ "rational8": rational,
+ },
+ "runtime_geometry_recommendation": rec,
+ }
+
+ overall = merge_status(overall, selected["status"])
+ reasons.extend([f"{role}:{x}" for x in selected["reasons"]])
+
+ return {
+ "status": overall,
+ "reasons": reasons,
+ "cameras": cameras,
+ }
+
+
+def module_fragment(evaluation, specs):
+ cameras = {}
+
+ for role in ROLES:
+ cam = evaluation["cameras"][role]
+ m = cam["selected_model"]
+
+ cameras[role] = {
+ "socket": specs[role].socket,
+ "sensor": specs[role].sensor,
+ "stream_source": specs[role].source,
+ "image_size": [specs[role].width, specs[role].height],
+ "distortion_model": cam["model_selection"]["selected"],
+ "camera_matrix": m["camera_matrix"],
+ "dist_coeffs": m["dist_coeffs"],
+ "undistort_products": m["undistort_products"],
+ }
+
+ return {
+ "intrinsics_config": {
+ "enabled": True,
+ "calibration_space": CALIBRATION_SPACE,
+ "cameras": cameras,
+ "runtime_undistort": {
+ "enabled": False,
+ "alpha": 0.0,
+ "interpolation": "linear",
+ "reason": (
+ "Homography atual foi calibrada no espaço nativo distorcido. "
+ "Ative undistort somente após recalibrar a homografia no novo espaço."
+ ),
+ },
+ }
+ }
+
+
+# ============================================================
+# UI helpers
+# ============================================================
+
+def draw_points(panel, points, src_shape, color):
+ if not points:
+ return
+
+ ph, pw = panel.shape[:2]
+ sh, sw = src_shape
+
+ for idx, (cid, pt) in enumerate(sorted((int(k), v) for k, v in points.items())):
+ x = int(float(pt[0]) * pw / sw)
+ y = int(float(pt[1]) * ph / sh)
+ cv2.circle(panel, (x, y), 3, color, -1)
+ if idx < 40:
+ cv2.putText(panel, str(cid), (x + 3, y - 3),
+ cv2.FONT_HERSHEY_SIMPLEX, 0.3, color, 1, cv2.LINE_AA)
+
+
+def save_view_preview(path, frames, det):
+ panels = []
+ colors = {"rgb": (0, 255, 0), "re": (0, 255, 255), "nir": (255, 255, 0)}
+
+ for role in ROLES:
+ img = frames[role]
+ bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
+ p = cv2.resize(bgr, (480, 300), interpolation=cv2.INTER_AREA)
+ draw_points(p, det["detections"][role]["points"], img.shape[:2], colors[role])
+ overlay(p, [
+ role.upper(),
+ f"markers={det['detections'][role]['markers']}",
+ f"corners={det['detections'][role]['corners']}",
+ ], x=8, y=18, scale=0.36, step=16)
+ panels.append(p)
+
+ cv2.imwrite(str(path), np.hstack(panels))
+
+
+def undistorted(frame, cam_result, alpha):
+ m = cam_result["selected_model"]
+ K = np.asarray(m["camera_matrix"], dtype=np.float64)
+ D = np.asarray(m["dist_coeffs"], dtype=np.float64)
+ product = m["undistort_products"][str(float(alpha))]
+ newK = np.asarray(product["new_camera_matrix"], dtype=np.float64)
+ return cv2.undistort(frame, K, D, None, newK)
+
+
+def build_ui(runtime, specs, views, last_det, evaluation,
+ selected_role, show_undist, args, msg=""):
+ pw, ph = args.panel_width, args.panel_height
+ panels = {}
+ colors = {"rgb": (0, 255, 0), "re": (0, 255, 255), "nir": (255, 255, 0)}
+
+ for role in ROLES:
+ frame = runtime.frames[role]
+
+ if show_undist and evaluation is not None and role == selected_role:
+ frame = undistorted(
+ frame, evaluation["cameras"][role], args.preview_alpha
+ )
+
+ bgr = frame if frame.ndim == 3 else cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
+ p = cv2.resize(bgr, (pw, ph), interpolation=cv2.INTER_AREA)
+
+ if last_det is not None:
+ draw_points(
+ p,
+ last_det["detections"][role]["points"],
+ runtime.frames[role].shape[:2],
+ colors[role],
+ )
+
+ overlay(p, [
+ f"{role.upper()} | {specs[role].sensor}",
+ f"{specs[role].width}x{specs[role].height}",
+ "UNDIST PREVIEW" if (show_undist and role == selected_role) else "NATIVE",
+ f"EXP={runtime.ctrl[role].get('exposure_time_us')}us "
+ f"ISO={runtime.ctrl[role].get('sensitivity_iso')}",
+ ], x=8, y=18, scale=0.36, step=16)
+
+ panels[role] = p
+
+ data = np.zeros((ph, pw, 3), dtype=np.uint8)
+
+ lines = [
+ "INTRINSICS CALIBRATION - PRODUCTION",
+ f"views={len(views)} / min={QA['min_views']} rec={QA['recommended_views']}",
+ f"preview={selected_role.upper()} undist={'ON' if show_undist else 'OFF'}",
+ "",
+ "G capture | A calibrate",
+ "V clear | S snapshot",
+ "1/2/3 select | U undist preview",
+ "Q/ESC cancel",
+ ]
+
+ if evaluation is not None:
+ lines += ["", f"EVAL={evaluation['status'].upper()}"]
+
+ for role in ROLES:
+ c = evaluation["cameras"][role]
+ m = c["selected_model"]
+ cvmed = m["cross_validation"]["overall"].get("median_px")
+ lines.append(
+ f"{role.upper()} {c['model_selection']['selected']} "
+ f"RMS={m['rms_px']:.3f}px "
+ f"CV={0.0 if cvmed is None else cvmed:.3f}px "
+ f"edge={m['distortion_shift']['edge_p95_px']:.1f}px"
+ )
+
+ overlay(data, lines, x=12, y=20, scale=0.39, step=17)
+
+ board = np.vstack([
+ np.hstack([panels["rgb"], panels["re"]]),
+ np.hstack([panels["nir"], data]),
+ ])
+
+ if msg:
+ cv2.putText(
+ board, msg, (16, board.shape[0] - 14),
+ cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 255, 0), 2, cv2.LINE_AA,
+ )
+
+ return board
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ ap = argparse.ArgumentParser(
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter
+ )
+
+ ap.add_argument("--mx-id", default=None)
+ ap.add_argument("--fps", type=float, default=20.0)
+
+ ap.add_argument("--isp-sharpness", type=int, default=0)
+ ap.add_argument("--isp-luma-denoise", type=int, default=0)
+ ap.add_argument("--isp-chroma-denoise", type=int, default=0)
+
+ ap.add_argument("--charuco-dictionary", default="DICT_4X4_50")
+ ap.add_argument("--charuco-squares-x", type=int, default=13)
+ ap.add_argument("--charuco-squares-y", type=int, default=7)
+ ap.add_argument("--charuco-square-length", type=float, default=0.031)
+ ap.add_argument("--charuco-marker-length", type=float, default=0.023)
+
+ ap.add_argument(
+ "--distortion-model",
+ default="auto",
+ choices=["auto", "brown5", "rational8"],
+ )
+ ap.add_argument("--promote-warning", action="store_true")
+
+ ap.add_argument("--panel-width", type=int, default=640)
+ ap.add_argument("--panel-height", type=int, default=400)
+ ap.add_argument("--preview-alpha", type=float, default=0.0,
+ choices=[0.0, 0.5, 1.0])
+
+ ap.add_argument(
+ "--candidate-root",
+ default="calibration/intrinsics_candidates",
+ )
+ ap.add_argument(
+ "--active-json",
+ default="calibration/intrinsics_calibration_v1.json",
+ )
+
+ ap.add_argument("--module-id", default="")
+ ap.add_argument("--operator", default="")
+ ap.add_argument("--lens-rgb", default="")
+ ap.add_argument("--lens-re", default="")
+ ap.add_argument("--lens-nir", default="")
+ ap.add_argument("--notes", default="")
+
+ args = ap.parse_args()
+
+ sid = stamp()
+ candidate_dir = Path(args.candidate_root) / sid
+ views_dir = candidate_dir / "views"
+ snapshots_dir = candidate_dir / "snapshots"
+ report_path = candidate_dir / "report.json"
+
+ ensure_dir(views_dir)
+ ensure_dir(snapshots_dir)
+
+ dictionary = aruco_dictionary(args.charuco_dictionary)
+ board = make_board(
+ args.charuco_squares_x,
+ args.charuco_squares_y,
+ args.charuco_square_length,
+ args.charuco_marker_length,
+ dictionary,
+ )
+ board_pts = board_object_points(board)
+ params = detector_params()
+
+ views = []
+ report = None
+ evaluation = None
+ last_det = None
+
+ window = "Intrinsics Calibration - Production"
+ cv2.namedWindow(window, cv2.WINDOW_NORMAL)
+
+ try:
+ rows, actual_mx, usb = discover(args.mx_id)
+ specs = validate_specs(rows)
+
+ pipeline, isp = build_pipeline(
+ specs,
+ args.fps,
+ args.isp_sharpness,
+ args.isp_luma_denoise,
+ args.isp_chroma_denoise,
+ )
+
+ print("=" * 82)
+ print("INTRINSICS CALIBRATION - PRODUCTION")
+ print(f"MX ID: {actual_mx}")
+ print(f"USB : {usb}")
+
+ for role in ROLES:
+ s = specs[role]
+ print(
+ f"{s.socket} -> {role.upper():3s} | {s.sensor:8s} | "
+ f"{s.width}x{s.height} | {s.source}"
+ )
+
+ print("=" * 82)
+
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ "status": "running",
+ "promoted": False,
+ "depthai_version": getattr(dai, "__version__", "unknown"),
+ "opencv_version": cv2.__version__,
+ "device_mx_id": actual_mx,
+ "usb_speed": usb,
+ "hardware_signature": hardware_signature(specs),
+ "calibration_space": CALIBRATION_SPACE,
+ "charuco": {
+ "dictionary": args.charuco_dictionary,
+ "squares_x": args.charuco_squares_x,
+ "squares_y": args.charuco_squares_y,
+ "square_length_m": args.charuco_square_length,
+ "marker_length_m": args.charuco_marker_length,
+ },
+ "distortion_model_policy": {
+ "requested": args.distortion_model,
+ "auto_rule": (
+ "Prefer Brown5; choose Rational8 only if LOO-CV p95 "
+ "improves materially."
+ ),
+ },
+ "qa_thresholds": QA,
+ "isp_measurement_settings": isp,
+ "traceability": {
+ "module_id": args.module_id or None,
+ "operator": args.operator or None,
+ "lens_by_role": {
+ "rgb": args.lens_rgb or None,
+ "re": args.lens_re or None,
+ "nir": args.lens_nir or None,
+ },
+ },
+ "notes": args.notes,
+ "locked_controls": None,
+ "views": [],
+ "evaluation": None,
+ "module_params_fragment": None,
+ }
+
+ info = dai.DeviceInfo(actual_mx) if actual_mx else None
+ dev_ctx = dai.Device(pipeline, info) if info is not None else dai.Device(pipeline)
+
+ with dev_ctx as dev:
+ runtime = Runtime(dev, specs)
+ runtime.wait_all()
+
+ # Preflight
+ print("[PRE-FLIGHT] Mostre o ChArUco nas três câmeras.")
+
+ while True:
+ runtime.poll()
+
+ trip = {"frames": runtime.frames}
+ det = detect_triplet(trip, board, dictionary, params)
+
+ panels = []
+
+ for role in ROLES:
+ img = runtime.frames[role]
+ bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
+ p = cv2.resize(
+ bgr,
+ (args.panel_width, args.panel_height),
+ interpolation=cv2.INTER_AREA,
+ )
+
+ d = det["detections"][role]
+ overlay(p, [
+ f"{role.upper()} | {specs[role].sensor}",
+ f"markers={d['markers']} corners={d['corners']}",
+ f"EXP={runtime.ctrl[role].get('exposure_time_us')}us "
+ f"ISO={runtime.ctrl[role].get('sensitivity_iso')}",
+ ], x=8, y=18, scale=0.38, step=17)
+
+ panels.append(p)
+
+ data = np.zeros(
+ (args.panel_height, args.panel_width, 3),
+ dtype=np.uint8,
+ )
+
+ overlay(data, [
+ "INTRINSICS PRE-FLIGHT",
+ "",
+ "GOOD" if det["valid"] else "BAD",
+ "",
+ "ENTER = lock EXP/ISO",
+ "Q/ESC = cancel",
+ "",
+ *[f"! {x}" for x in det["reasons"][:8]],
+ ], x=14, y=28, scale=0.43, step=20)
+
+ board_ui = np.vstack([
+ np.hstack([panels[0], panels[1]]),
+ np.hstack([panels[2], data]),
+ ])
+
+ cv2.imshow(window, board_ui)
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (ord("q"), ord("Q"), 27):
+ raise KeyboardInterrupt("Cancelado no preflight.")
+
+ if k in (13, 10):
+ if not det["valid"]:
+ print("[BLOCK] ChArUco ainda não GOOD nas 3 câmeras.")
+ continue
+
+ locked = lock_current_ae(runtime)
+ report["locked_controls"] = {
+ r: asdict(locked[r]) for r in ROLES
+ }
+ save_json_atomic(report_path, report)
+ break
+
+ time.sleep(0.002)
+
+ # Capture loop
+ selected_role = "rgb"
+ show_undist = False
+ message = ""
+ message_t = 0.0
+
+ print(
+ f"[CAPTURE] mínimo={QA['min_views']}, ideal={QA['recommended_views']}. "
+ "Varie posição, distância, yaw, pitch e roll."
+ )
+
+ while True:
+ runtime.poll()
+
+ msg = message if (
+ message and time.time() - message_t < 4.0
+ ) else ""
+
+ ui = build_ui(
+ runtime, specs, views, last_det, evaluation,
+ selected_role, show_undist, args, msg
+ )
+
+ cv2.imshow(window, ui)
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (ord("q"), ord("Q"), 27):
+ raise KeyboardInterrupt("Sessão cancelada.")
+
+ if k == ord("1"):
+ selected_role = "rgb"
+ elif k == ord("2"):
+ selected_role = "re"
+ elif k == ord("3"):
+ selected_role = "nir"
+
+ elif k in (ord("u"), ord("U")):
+ if evaluation is None:
+ message = "Use A primeiro para calibrar."
+ else:
+ show_undist = not show_undist
+ message = f"Undistort preview={show_undist}"
+ message_t = time.time()
+
+ elif k in (ord("v"), ord("V")):
+ views.clear()
+ last_det = None
+ evaluation = None
+ report["views"] = []
+ report["evaluation"] = None
+ save_json_atomic(report_path, report)
+ message = "Views limpas."
+ message_t = time.time()
+
+ elif k in (ord("s"), ord("S")):
+ snap = snapshots_dir / f"board_{stamp()}.png"
+ cv2.imwrite(str(snap), ui)
+ message = f"Snapshot={snap.name}"
+ message_t = time.time()
+
+ elif k in (ord("g"), ord("G")):
+ try:
+ trip = runtime.fresh_triplet()
+ det = detect_triplet(
+ trip, board, dictionary, params
+ )
+ last_det = det
+
+ if not det["valid"]:
+ message = "REJEITADO: " + " | ".join(det["reasons"])
+ message_t = time.time()
+ continue
+
+ pose = {
+ role: pose_signature(
+ det["detections"][role]["points"],
+ trip["frames"][role].shape[:2],
+ )
+ for role in ROLES
+ }
+
+ if duplicate_pose(pose, views):
+ message = (
+ "REJEITADO: pose repetida. "
+ "Mude posição/distância/inclinação."
+ )
+ message_t = time.time()
+ continue
+
+ view_id = len(views) + 1
+
+ view = {
+ "view_id": view_id,
+ "captured_at": now_str(),
+ "pose": pose,
+ "controls": trip["controls"],
+ "detections": {
+ role: {
+ "markers": det["detections"][role]["markers"],
+ "corners": det["detections"][role]["corners"],
+ "points": {
+ str(cid): pt
+ for cid, pt in det["detections"][role]["points"].items()
+ },
+ }
+ for role in ROLES
+ },
+ }
+
+ preview = views_dir / f"view_{view_id:03d}.png"
+ save_view_preview(
+ preview, trip["frames"], det
+ )
+ view["preview_png"] = str(preview)
+
+ views.append(view)
+ report["views"] = views
+ save_json_atomic(report_path, report)
+
+ message = (
+ f"VIEW #{view_id:03d} ACCEPTED | "
+ f"RGB={view['detections']['rgb']['corners']} "
+ f"RE={view['detections']['re']['corners']} "
+ f"NIR={view['detections']['nir']['corners']}"
+ )
+ message_t = time.time()
+ print("[VIEW]", message)
+
+ except Exception as exc:
+ message = f"Captura rejeitada: {exc}"
+ message_t = time.time()
+
+ elif k in (ord("a"), ord("A")):
+ if len(views) < QA["min_views"]:
+ message = (
+ f"Precisa >= {QA['min_views']} views. Atual={len(views)}"
+ )
+ message_t = time.time()
+ continue
+
+ evaluation = evaluate_all(
+ views, board_pts, specs, args.distortion_model
+ )
+
+ report["evaluation"] = evaluation
+ report["module_params_fragment"] = module_fragment(
+ evaluation, specs
+ )
+ save_json_atomic(report_path, report)
+
+ if evaluation["status"] == "bad":
+ message = (
+ "EVAL BAD: "
+ + " | ".join(evaluation["reasons"][:5])
+ )
+ message_t = time.time()
+ print("[BAD]", *evaluation["reasons"], sep="\n ")
+ continue
+
+ if (
+ evaluation["status"] == "warning"
+ and not args.promote_warning
+ ):
+ message = (
+ "EVAL WARNING. Capture mais/melhores views."
+ )
+ message_t = time.time()
+ print("[WARNING]", *evaluation["reasons"], sep="\n ")
+ continue
+
+ report["status"] = (
+ "pass"
+ if evaluation["status"] == "good"
+ else "warning_promoted"
+ )
+ report["finished_at"] = now_str()
+ report["promoted"] = True
+ report["promoted_at"] = now_str()
+
+ active = Path(args.active_json)
+
+ if active.exists():
+ backup = active.with_name(
+ active.stem + ".backup_" + stamp() + active.suffix
+ )
+ shutil.copy2(active, backup)
+
+ save_json_atomic(active, report)
+ save_json_atomic(report_path, report)
+
+ marker = candidate_dir / (
+ "PASS.txt"
+ if evaluation["status"] == "good"
+ else "PASS_WARNING.txt"
+ )
+ marker.write_text(
+ f"status={report['status']}\nactive_json={active}\n",
+ encoding="utf-8",
+ )
+
+ print("=" * 82)
+ print("[PASS] INTRINSICS PROMOTED")
+
+ for role in ROLES:
+ c = evaluation["cameras"][role]
+ m = c["selected_model"]
+ print(
+ f"{role.upper()}: "
+ f"{c['model_selection']['selected']} | "
+ f"RMS={m['rms_px']:.3f}px | "
+ f"CVmed={m['cross_validation']['overall']['median_px']:.3f}px | "
+ f"edgeP95={m['distortion_shift']['edge_p95_px']:.1f}px | "
+ f"{c['runtime_geometry_recommendation']}"
+ )
+
+ print("[RUNTIME] runtime_undistort permanece false.")
+ print(
+ "[IMPORTANTE] Se ativarmos undistort, recalibrar Homography "
+ "no espaço undistorted."
+ )
+ print(f"[ACTIVE] {active}")
+ print("=" * 82)
+
+ final = np.zeros((720, 1280, 3), dtype=np.uint8)
+ lines = [
+ "INTRINSICS CALIBRATION - PASS",
+ "",
+ ]
+
+ for role in ROLES:
+ c = evaluation["cameras"][role]
+ m = c["selected_model"]
+ lines.append(
+ f"{role.upper()}: {c['model_selection']['selected']} | "
+ f"RMS={m['rms_px']:.3f}px | "
+ f"CV={m['cross_validation']['overall']['median_px']:.3f}px | "
+ f"edge={m['distortion_shift']['edge_p95_px']:.1f}px"
+ )
+
+ lines += [
+ "",
+ "runtime_undistort = DISABLED",
+ f"Active: {active}",
+ "",
+ "Qualquer tecla fecha.",
+ ]
+
+ overlay(final, lines, x=42, y=62, scale=0.64, step=31)
+ cv2.imshow(window, final)
+ cv2.waitKey(0)
+ break
+
+ time.sleep(0.001)
+
+ except KeyboardInterrupt as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ }
+
+ report["status"] = "cancelled"
+ report["promoted"] = False
+ report["finished_at"] = now_str()
+ report["error"] = str(exc)
+ report["views"] = views
+
+ try:
+ save_json_atomic(report_path, report)
+ (candidate_dir / "CANCELLED.txt").write_text(
+ f"reason={exc}\n", encoding="utf-8"
+ )
+ except Exception:
+ pass
+
+ print("[CANCELLED]", exc)
+ print("[SAFE] Intrinsics ativo anterior preservado.")
+
+ except Exception as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ }
+
+ report["status"] = "error"
+ report["promoted"] = False
+ report["finished_at"] = now_str()
+ report["error"] = f"{type(exc).__name__}: {exc}"
+ report["views"] = views
+
+ try:
+ save_json_atomic(report_path, report)
+ (candidate_dir / "FAIL.txt").write_text(
+ f"error={type(exc).__name__}: {exc}\n",
+ encoding="utf-8",
+ )
+ except Exception:
+ pass
+
+ print("[ERROR]", type(exc).__name__, exc)
+ print("[SAFE] Intrinsics ativo anterior preservado.")
+ raise
+
+ finally:
+ cv2.destroyAllWindows()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_3_flatfield_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_3_flatfield_calibration_tool.py
new file mode 100644
index 000000000..680844c1e
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/utils/_3_flatfield_calibration_tool.py
@@ -0,0 +1,3089 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+flatfield_calibration_production.py
+===================================
+
+Calibrador de flat-field / dark-frame de PRODUÇÃO para o módulo OAK-FFC-3P.
+
+Setup suportado:
+ CAM_A = RGB = OV9782 (1280x800) OU AR0234 (1920x1200)
+ CAM_B = RE = OV9282 (1280x800)
+ CAM_C = NIR = OV9282 (1280x800)
+
+Princípios de produto:
+----------------------
+1. Descobre e valida o hardware antes de iniciar.
+2. Configura resolução por SENSOR, não por resolução global.
+3. Captura RAW10 nativo de cada sensor.
+4. Usa o mesmo RawProcessorCore da aplicação para obter R/G/B/RE/NIR.
+5. Deixa AE estabilizar no WHITE e então CONGELA exposição/ISO.
+6. WHITE, DARK e WHITE-VALIDATION usam exatamente os mesmos controles.
+7. Não redimensiona dark para "caber": shape divergente é erro fatal.
+8. Rejeita frames temporalmente anômalos.
+9. Gera o gain a partir do campo suavizado antes da inversão.
+10. Valida o flat numa segunda captura WHITE que não participou do cálculo.
+11. Sempre salva um candidate auditável.
+12. Só promove para "active" se o acceptance test passar.
+13. Preserva calibração ativa anterior em caso de falha.
+14. Gera metadados, hashes, previews e sugestão de flatfield_config.
+
+Dependências:
+-------------
+ pip install depthai opencv-python numpy
+
+ Projeto:
+ core/raw_processor_core.py
+
+Fluxo:
+------
+ A) WHITE PREFLIGHT
+ - alvo branco/cinza fosco uniforme
+ - AE estabiliza
+ - ENTER congela exposição/ISO de cada câmera
+
+ B) WHITE A
+ - captura usada para construir o flat
+
+ C) DARK
+ - lentes totalmente tampadas
+ - MESMOS controles do WHITE A
+
+ D) WHITE B / VALIDATION
+ - destampar e voltar ao mesmo alvo
+ - MESMOS controles
+ - esta captura NÃO participa da construção
+ - mede ganho real de uniformidade antes/depois
+
+Saídas:
+-------
+ calibration/flatfield_candidates//
+ flatfield_maps.npz
+ report.json
+ previews/
+ PASS.txt ou FAIL.txt
+
+ Se PASS:
+ calibration/flatfield_maps_v1.npz
+ calibration/flatfield_maps_v1.json
+
+Observação importante sobre Bayer:
+----------------------------------
+ --rgb-bayer auto usa:
+ OV9782 -> BGGR (configuração histórica do módulo deste projeto)
+ AR0234 -> GRBG (CFA nativo usual do AR0234)
+
+ Para produto, o Bayer resolvido fica registrado no report.json.
+ Se o seu módulo AR0234 específico estiver montado com orientação/mirror
+ diferente, use --rgb-bayer RGGB|BGGR|GRBG|GBRG explicitamente.
+
+Exemplo:
+--------
+ python flatfield_calibration_production.py
+
+ python flatfield_calibration_production.py ^
+ --module-params calibration/module_params.json ^
+ --frames 60 ^
+ --validation-frames 30
+
+Teclas:
+-------
+ ENTER = avançar/iniciar etapa
+ Q/ESC = cancelar sem promover
+
+IMPORTANTE:
+-----------
+Este script é fail-closed. Um candidate pode ser salvo mesmo quando falha,
+mas uma calibração FAIL não sobrescreve o arquivo ativo.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import math
+import os
+import shutil
+import sys
+import time
+from collections import deque
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Optional, Tuple, List, Any
+
+import cv2
+import depthai as dai
+import numpy as np
+
+from core.raw_processor_core import RawProcessorCore
+
+
+# ============================================================
+# Constantes de produto
+# ============================================================
+
+ROLES = ("rgb", "re", "nir")
+CHANNELS = ("R", "G", "B", "RE", "NIR")
+
+ROLE_TO_SOCKET = {
+ "rgb": "CAM_A",
+ "re": "CAM_B",
+ "nir": "CAM_C",
+}
+
+EXPECTED_MONO_SENSOR = "OV9282"
+
+SUPPORTED_RGB = {
+ "OV9782": {
+ "width": 1280,
+ "height": 800,
+ "resolution_enum": "THE_800_P",
+ "default_bayer": "BGGR",
+ },
+ "AR0234": {
+ "width": 1920,
+ "height": 1200,
+ "resolution_enum": "THE_1200_P",
+ "default_bayer": "GRBG",
+ },
+}
+
+SUPPORTED_MONO = {
+ "OV9282": {
+ "width": 1280,
+ "height": 800,
+ "resolution_enum": "THE_800_P",
+ },
+}
+
+BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG")
+
+# Raw10
+RAW10_MAX = 1023.0
+RAW10_WHITE_SAT = 1018
+RAW10_DARK_FLOOR = 16
+
+SCHEMA = "multispec_flatfield_production_v2"
+
+
+# ============================================================
+# Thresholds de QA
+# ============================================================
+
+DEFAULT_THRESHOLDS = {
+ # WHITE ao vivo / raw por role
+ "white_sat_warning_pct": 0.10,
+ "white_sat_bad_pct": 0.50,
+ "white_dark_warning_pct": 1.00,
+ "white_dark_bad_pct": 4.00,
+
+ # Estabilidade temporal da mediana global por role
+ "temporal_cv_warning": 0.005, # 0.5%
+ "temporal_cv_bad": 0.015, # 1.5%
+
+ # Rejeição de frame individual em relação à mediana temporal
+ "frame_global_deviation_reject": 0.05, # 5%
+
+ # DARK: vazamento de luz / tampa inadequada
+ "dark_p99_warning": 0.08,
+ "dark_p99_bad": 0.16,
+
+ # Gain
+ "gain_p99_warning": 1.75,
+ "gain_p99_bad": 2.20,
+ "gain_max_warning": 2.20,
+ "gain_max_bad": 3.00,
+ "gain_min_warning": 0.55,
+ "gain_min_bad": 0.35,
+
+ # Defeitos locais no sinal branco após remover shading suave
+ "local_dev_p99_warning": 0.12,
+ "local_dev_p99_bad": 0.20,
+ "local_outlier_pct_warning": 1.0,
+ "local_outlier_pct_bad": 3.0,
+
+ # Acceptance WHITE B após flat
+ "corrected_grid_cv_warning": 0.055,
+ "corrected_grid_cv_bad": 0.085,
+
+ # Só exige melhora relativa quando havia shading mensurável antes.
+ "min_before_grid_cv_for_improvement": 0.025,
+ "improvement_warning": 0.25,
+ "improvement_bad": 0.10,
+
+ # Se o flat piorar a uniformidade de forma clara, falha.
+ "worsening_bad": -0.05,
+
+ # Controle travado
+ "exposure_rel_tolerance": 0.01,
+ "exposure_abs_tolerance_us": 20,
+ "iso_tolerance": 5,
+}
+
+
+# ============================================================
+# Utilidades
+# ============================================================
+
+def now_str() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def session_id() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
+
+
+def ensure_dir(path: str | Path):
+ Path(path).mkdir(parents=True, exist_ok=True)
+
+
+def load_json(path: Optional[str]) -> dict:
+ if not path:
+ return {}
+ p = Path(path)
+ if not p.is_file():
+ return {}
+ with p.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def save_json(path: str | Path, data: dict):
+ p = Path(path)
+ ensure_dir(p.parent)
+ tmp = p.with_suffix(p.suffix + ".tmp")
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp, p)
+
+
+def sha256_file(path: str | Path) -> str:
+ h = hashlib.sha256()
+ with open(path, "rb") as f:
+ while True:
+ chunk = f.read(1024 * 1024)
+ if not chunk:
+ break
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def safe_float(v, default=None):
+ try:
+ if v is None:
+ return default
+ return float(v)
+ except Exception:
+ return default
+
+
+def safe_int(v, default=None):
+ try:
+ if v is None:
+ return default
+ return int(v)
+ except Exception:
+ return default
+
+
+def clamp(v, lo, hi):
+ return max(lo, min(hi, v))
+
+
+def finite_percentile(arr: np.ndarray, pct: float, default=0.0) -> float:
+ f = arr[np.isfinite(arr)]
+ if f.size == 0:
+ return float(default)
+ return float(np.percentile(f, pct))
+
+
+def robust_median(arr: np.ndarray, margin_frac=0.04) -> float:
+ a = arr.astype(np.float32)
+ h, w = a.shape[:2]
+ mx = int(w * margin_frac)
+ my = int(h * margin_frac)
+ if w - 2 * mx > 8 and h - 2 * my > 8:
+ a = a[my:h-my, mx:w-mx]
+ f = a[np.isfinite(a)]
+ if f.size == 0:
+ return 0.0
+ return float(np.median(f))
+
+
+def robust_center_reference(arr: np.ndarray, center_frac=0.44) -> float:
+ """
+ Referência do flat no centro óptico, robusta contra pixels ruins.
+ Mantém ganho ~1 no centro.
+ """
+ a = arr.astype(np.float32)
+ h, w = a.shape[:2]
+
+ cw = max(16, int(w * center_frac))
+ ch = max(16, int(h * center_frac))
+
+ x0 = (w - cw) // 2
+ y0 = (h - ch) // 2
+
+ core = a[y0:y0+ch, x0:x0+cw]
+ f = core[np.isfinite(core)]
+
+ if f.size == 0:
+ f = a[np.isfinite(a)]
+
+ if f.size == 0:
+ return 1.0
+
+ lo = np.percentile(f, 10)
+ hi = np.percentile(f, 90)
+ trimmed = f[(f >= lo) & (f <= hi)]
+ if trimmed.size:
+ f = trimmed
+
+ v = float(np.median(f))
+ return v if np.isfinite(v) and v > 1e-8 else 1.0
+
+
+def normalize_display(arr: np.ndarray, low=1.0, high=99.0) -> np.ndarray:
+ a = arr.astype(np.float32)
+ f = a[np.isfinite(a)]
+ if f.size == 0:
+ return np.zeros(a.shape[:2], dtype=np.uint8)
+
+ lo = float(np.percentile(f, low))
+ hi = float(np.percentile(f, high))
+
+ if hi <= lo:
+ hi = lo + 1e-6
+
+ out = (a - lo) / (hi - lo)
+ return np.clip(out * 255.0, 0, 255).astype(np.uint8)
+
+
+def raw10_display(raw16: np.ndarray) -> np.ndarray:
+ u8 = np.clip(raw16.astype(np.float32) * (255.0 / RAW10_MAX), 0, 255).astype(np.uint8)
+ return u8
+
+
+def overlay_hud(
+ img,
+ lines,
+ x=12,
+ y=24,
+ font_scale=0.52,
+ line_step=21,
+ color=(255, 255, 255),
+):
+ yy = int(y)
+ h = img.shape[0]
+
+ for line in lines:
+ if yy > h - 6:
+ break
+
+ s = str(line)
+ cv2.putText(
+ img, s, (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale, (0, 0, 0), 3, cv2.LINE_AA,
+ )
+ cv2.putText(
+ img, s, (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale, color, 1, cv2.LINE_AA,
+ )
+ yy += int(line_step)
+
+
+def resize_panel(img: np.ndarray, width: int, height: int) -> np.ndarray:
+ return cv2.resize(img, (int(width), int(height)), interpolation=cv2.INTER_AREA)
+
+
+def status_rank(status: str) -> int:
+ return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 2)
+
+
+def merge_status(a: str, b: str) -> str:
+ return a if status_rank(a) >= status_rank(b) else b
+
+
+def get_timestamp_seconds(pkt) -> float:
+ for name in ("getTimestampDevice", "getTimestamp"):
+ fn = getattr(pkt, name, None)
+ if callable(fn):
+ try:
+ ts = fn()
+ if hasattr(ts, "total_seconds"):
+ return float(ts.total_seconds())
+ return float(ts)
+ except Exception:
+ pass
+ return time.monotonic()
+
+
+def packet_controls(pkt) -> dict:
+ exp_us = None
+ iso = None
+ color_temp = None
+ seq = None
+
+ try:
+ exp = pkt.getExposureTime()
+ if hasattr(exp, "total_seconds"):
+ exp_us = int(round(exp.total_seconds() * 1_000_000))
+ else:
+ exp_us = int(exp)
+ except Exception:
+ pass
+
+ try:
+ iso = int(pkt.getSensitivity())
+ except Exception:
+ pass
+
+ try:
+ color_temp = int(pkt.getColorTemperature())
+ except Exception:
+ pass
+
+ try:
+ seq = int(pkt.getSequenceNum())
+ except Exception:
+ pass
+
+ return {
+ "exposure_time_us": exp_us,
+ "sensitivity_iso": iso,
+ "color_temperature_k": color_temp,
+ "sequence_num": seq,
+ "timestamp_s": get_timestamp_seconds(pkt),
+ }
+
+
+# ============================================================
+# RAW10 pack/unpack
+# ============================================================
+
+def unpack_raw10(data, width: int, height: int, stride: Optional[int] = None) -> np.ndarray:
+ """
+ Unpack RAW10 MIPI: 4 pixels / 5 bytes.
+ Retorna uint16 0..1023.
+ Vetorizado e com suporte a stride.
+ """
+ width = int(width)
+ height = int(height)
+
+ if width <= 0 or height <= 0:
+ raise ValueError(f"Dimensão RAW inválida: {width}x{height}")
+
+ if width % 4 != 0:
+ raise ValueError(f"RAW10 width precisa ser múltiplo de 4: {width}")
+
+ arr = np.asarray(data, dtype=np.uint8).reshape(-1)
+
+ min_row_bytes = (width // 4) * 5
+
+ if stride is None or int(stride) <= 0:
+ stride = min_row_bytes
+
+ stride = int(stride)
+
+ expected = stride * height
+ if arr.size < expected:
+ raise ValueError(
+ f"RAW10 curto: bytes={arr.size}, esperado>={expected} "
+ f"({width}x{height}, stride={stride})"
+ )
+
+ rows = arr[:expected].reshape(height, stride)
+ payload = rows[:, :min_row_bytes]
+
+ groups = payload.reshape(height, width // 4, 5)
+
+ out = np.empty((height, width // 4, 4), dtype=np.uint16)
+
+ out[:, :, 0] = (groups[:, :, 0].astype(np.uint16) << 2) | (groups[:, :, 4] & 0x03)
+ out[:, :, 1] = (groups[:, :, 1].astype(np.uint16) << 2) | ((groups[:, :, 4] >> 2) & 0x03)
+ out[:, :, 2] = (groups[:, :, 2].astype(np.uint16) << 2) | ((groups[:, :, 4] >> 4) & 0x03)
+ out[:, :, 3] = (groups[:, :, 3].astype(np.uint16) << 2) | ((groups[:, :, 4] >> 6) & 0x03)
+
+ return np.ascontiguousarray(out.reshape(height, width))
+
+
+def pack_raw10(raw16: np.ndarray) -> np.ndarray:
+ """
+ Reempacota uint16 0..1023 no formato esperado pelo RawProcessorCore:
+ shape=(H, W*5/4), uint8.
+ """
+ raw = np.clip(np.rint(raw16), 0, 1023).astype(np.uint16)
+
+ h, w = raw.shape[:2]
+ if w % 4 != 0:
+ raise ValueError(f"RAW10 pack requer width múltiplo de 4: {w}")
+
+ px = raw.reshape(h, w // 4, 4)
+
+ packed = np.empty((h, w // 4, 5), dtype=np.uint8)
+
+ packed[:, :, 0] = (px[:, :, 0] >> 2).astype(np.uint8)
+ packed[:, :, 1] = (px[:, :, 1] >> 2).astype(np.uint8)
+ packed[:, :, 2] = (px[:, :, 2] >> 2).astype(np.uint8)
+ packed[:, :, 3] = (px[:, :, 3] >> 2).astype(np.uint8)
+
+ low = (
+ (px[:, :, 0] & 0x03)
+ | ((px[:, :, 1] & 0x03) << 2)
+ | ((px[:, :, 2] & 0x03) << 4)
+ | ((px[:, :, 3] & 0x03) << 6)
+ )
+
+ packed[:, :, 4] = low.astype(np.uint8)
+
+ return np.ascontiguousarray(packed.reshape(h, (w // 4) * 5))
+
+
+# ============================================================
+# Hardware discovery
+# ============================================================
+
+@dataclass
+class CameraSpec:
+ role: str
+ socket_name: str
+ sensor_name: str
+ width: int
+ height: int
+ resolution_enum: str
+ stream_name: str
+ control_name: str
+ is_color: bool
+ bayer_pattern: Optional[str] = None
+
+
+def device_id_from_info(dev_info) -> Optional[str]:
+ for name in ("getMxId", "getDeviceId"):
+ try:
+ fn = getattr(dev_info, name, None)
+ if callable(fn):
+ v = fn()
+ if v:
+ return str(v)
+ except Exception:
+ pass
+
+ for name in ("mxid", "deviceId"):
+ try:
+ v = getattr(dev_info, name, None)
+ if v:
+ return str(v)
+ except Exception:
+ pass
+
+ return None
+
+
+def select_device_info(mx_id: Optional[str]):
+ devices = dai.Device.getAllAvailableDevices()
+
+ if not devices:
+ raise RuntimeError("Nenhum dispositivo OAK/DepthAI encontrado.")
+
+ if not mx_id:
+ return devices[0]
+
+ target = str(mx_id).strip()
+
+ for info in devices:
+ if device_id_from_info(info) == target:
+ return info
+
+ available = [device_id_from_info(x) for x in devices]
+ raise RuntimeError(f"MX ID {target!r} não encontrado. Disponíveis: {available}")
+
+
+def discover_features(dev_info):
+ with dai.Device(dev_info) as dev:
+ rows = []
+
+ for f in dev.getConnectedCameraFeatures():
+ rows.append({
+ "socket_name": str(f.socket.name),
+ "sensor_name": str(f.sensorName or ""),
+ "width": int(getattr(f, "width", 0) or 0),
+ "height": int(getattr(f, "height", 0) or 0),
+ "supported_types": [str(x) for x in (getattr(f, "supportedTypes", []) or [])],
+ "has_autofocus_ic": int(getattr(f, "hasAutofocusIC", 0) or 0),
+ })
+
+ usb = None
+ try:
+ usb = str(dev.getUsbSpeed())
+ except Exception:
+ pass
+
+ return rows, usb
+
+
+def get_socket(name: str):
+ name = str(name).upper()
+
+ mapping = {
+ "CAM_A": dai.CameraBoardSocket.CAM_A,
+ "CAM_B": dai.CameraBoardSocket.CAM_B,
+ "CAM_C": dai.CameraBoardSocket.CAM_C,
+ }
+
+ if name not in mapping:
+ raise ValueError(f"Socket não suportado: {name}")
+
+ return mapping[name]
+
+
+def resolve_bayer(sensor_name: str, arg_value: str, module_params: dict) -> str:
+ arg = str(arg_value or "auto").upper()
+
+ if arg != "AUTO":
+ if arg not in BAYER_PATTERNS:
+ raise ValueError(f"Bayer inválido: {arg}")
+ return arg
+
+ # 1) Procura configuração explícita no module_params.
+ candidates = [
+ module_params.get("bayer_pattern"),
+ module_params.get("bayer"),
+ ]
+
+ cam_a_cfg = (
+ module_params.get("camera_info", {}).get("CAM_A", {})
+ if isinstance(module_params.get("camera_info"), dict)
+ else {}
+ )
+
+ if isinstance(cam_a_cfg, dict):
+ candidates.extend([
+ cam_a_cfg.get("bayer_pattern"),
+ cam_a_cfg.get("bayer"),
+ ])
+
+ for c in candidates:
+ if c and str(c).upper() in BAYER_PATTERNS:
+ return str(c).upper()
+
+ # 2) Fallback sensor-aware.
+ sensor = str(sensor_name).upper()
+ if sensor in SUPPORTED_RGB:
+ return SUPPORTED_RGB[sensor]["default_bayer"]
+
+ raise RuntimeError(
+ f"Não foi possível resolver Bayer automaticamente para {sensor_name}. "
+ f"Use --rgb-bayer explicitamente."
+ )
+
+
+def validate_and_build_specs(
+ features: list[dict],
+ rgb_bayer_arg: str,
+ module_params: dict,
+) -> Dict[str, CameraSpec]:
+ by_socket = {x["socket_name"]: x for x in features}
+
+ missing = [sock for sock in ("CAM_A", "CAM_B", "CAM_C") if sock not in by_socket]
+ if missing:
+ raise RuntimeError(f"Módulo incompleto. Sockets ausentes: {missing}")
+
+ rgb_row = by_socket["CAM_A"]
+ re_row = by_socket["CAM_B"]
+ nir_row = by_socket["CAM_C"]
+
+ rgb_sensor = rgb_row["sensor_name"].upper()
+
+ if rgb_sensor not in SUPPORTED_RGB:
+ raise RuntimeError(
+ f"CAM_A deve ser OV9782 ou AR0234. Detectado: {rgb_row['sensor_name']}"
+ )
+
+ for role, row in (("RE", re_row), ("NIR", nir_row)):
+ if row["sensor_name"].upper() != EXPECTED_MONO_SENSOR:
+ raise RuntimeError(
+ f"{role} deve usar OV9282. Detectado em "
+ f"{'CAM_B' if role == 'RE' else 'CAM_C'}: {row['sensor_name']}"
+ )
+
+ rgb_cfg = SUPPORTED_RGB[rgb_sensor]
+ bayer = resolve_bayer(rgb_sensor, rgb_bayer_arg, module_params)
+
+ specs = {
+ "rgb": CameraSpec(
+ role="rgb",
+ socket_name="CAM_A",
+ sensor_name=rgb_sensor,
+ width=rgb_cfg["width"],
+ height=rgb_cfg["height"],
+ resolution_enum=rgb_cfg["resolution_enum"],
+ stream_name="raw_rgb",
+ control_name="ctrl_rgb",
+ is_color=True,
+ bayer_pattern=bayer,
+ ),
+ "re": CameraSpec(
+ role="re",
+ socket_name="CAM_B",
+ sensor_name="OV9282",
+ width=1280,
+ height=800,
+ resolution_enum="THE_800_P",
+ stream_name="raw_re",
+ control_name="ctrl_re",
+ is_color=False,
+ ),
+ "nir": CameraSpec(
+ role="nir",
+ socket_name="CAM_C",
+ sensor_name="OV9282",
+ width=1280,
+ height=800,
+ resolution_enum="THE_800_P",
+ stream_name="raw_nir",
+ control_name="ctrl_nir",
+ is_color=False,
+ ),
+ }
+
+ # Valida o que o device anunciou, sem aceitar silent mismatch.
+ for role, spec in specs.items():
+ row = by_socket[spec.socket_name]
+
+ if row["width"] and row["height"]:
+ if (row["width"], row["height"]) != (spec.width, spec.height):
+ # Alguns firmwares podem anunciar dimensão diferente da mode table.
+ # Produto: não adivinhamos.
+ raise RuntimeError(
+ f"{spec.socket_name}/{spec.sensor_name}: dimensão anunciada "
+ f"{row['width']}x{row['height']} diverge da esperada "
+ f"{spec.width}x{spec.height}."
+ )
+
+ return specs
+
+
+# ============================================================
+# Pipeline DepthAI standalone
+# ============================================================
+
+def resolve_antibanding(mode: str):
+ mode = str(mode or "OFF").upper()
+
+ enum_cls = dai.CameraControl.AntiBandingMode
+
+ names = {
+ "OFF": "OFF",
+ "50": "MAINS_50_HZ",
+ "50HZ": "MAINS_50_HZ",
+ "60": "MAINS_60_HZ",
+ "60HZ": "MAINS_60_HZ",
+ }
+
+ enum_name = names.get(mode)
+ if not enum_name:
+ raise ValueError(f"anti-banding inválido: {mode}")
+
+ return getattr(enum_cls, enum_name)
+
+
+def build_pipeline(specs: Dict[str, CameraSpec], fps: float, anti_banding: str):
+ pipeline = dai.Pipeline()
+
+ anti_enum = resolve_antibanding(anti_banding)
+
+ for role in ROLES:
+ spec = specs[role]
+ socket = get_socket(spec.socket_name)
+
+ if spec.is_color:
+ cam = pipeline.createColorCamera()
+ cam.setBoardSocket(socket)
+
+ enum_value = getattr(
+ dai.ColorCameraProperties.SensorResolution,
+ spec.resolution_enum,
+ None,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ f"DepthAI instalado não expõe ColorCameraProperties."
+ f"SensorResolution.{spec.resolution_enum}."
+ )
+
+ cam.setResolution(enum_value)
+ cam.setFps(float(fps))
+ cam.setInterleaved(False)
+
+ else:
+ cam = pipeline.create(dai.node.MonoCamera)
+ cam.setBoardSocket(socket)
+
+ enum_value = getattr(
+ dai.MonoCameraProperties.SensorResolution,
+ spec.resolution_enum,
+ None,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ f"DepthAI instalado não expõe MonoCameraProperties."
+ f"SensorResolution.{spec.resolution_enum}."
+ )
+
+ cam.setResolution(enum_value)
+ cam.setFps(float(fps))
+
+ # AE inicia ativo para o preflight WHITE.
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+
+ try:
+ cam.initialControl.setAntiBandingMode(anti_enum)
+ except Exception:
+ pass
+
+ if spec.is_color:
+ try:
+ cam.initialControl.setAutoWhiteBalanceLock(False)
+ except Exception:
+ pass
+
+ # RAW obrigatório.
+ if not hasattr(cam, "raw"):
+ raise RuntimeError(
+ f"{spec.socket_name}/{spec.sensor_name} não expõe saída raw "
+ "nesta versão do DepthAI."
+ )
+
+ xout = pipeline.createXLinkOut()
+ xout.setStreamName(spec.stream_name)
+ cam.raw.link(xout.input)
+
+ xin = pipeline.createXLinkIn()
+ xin.setStreamName(spec.control_name)
+
+ if not hasattr(cam, "inputControl"):
+ raise RuntimeError(
+ f"{spec.socket_name}/{spec.sensor_name} sem inputControl."
+ )
+
+ xin.out.link(cam.inputControl)
+
+ return pipeline
+
+
+# ============================================================
+# Controle manual de exposição
+# ============================================================
+
+@dataclass
+class LockedControl:
+ role: str
+ exposure_time_us: int
+ sensitivity_iso: int
+ source: str
+
+
+def send_manual_control(queue, control: LockedControl, lock_awb=False):
+ ctrl = dai.CameraControl()
+ ctrl.setManualExposure(
+ int(control.exposure_time_us),
+ int(control.sensitivity_iso),
+ )
+
+ if lock_awb:
+ try:
+ ctrl.setAutoWhiteBalanceLock(True)
+ except Exception:
+ pass
+
+ queue.send(ctrl)
+
+
+def controls_match(actual: dict, target: LockedControl, th: dict) -> bool:
+ exp = safe_int(actual.get("exposure_time_us"), None)
+ iso = safe_int(actual.get("sensitivity_iso"), None)
+
+ if exp is None or iso is None:
+ return False
+
+ exp_tol = max(
+ int(th["exposure_abs_tolerance_us"]),
+ int(round(target.exposure_time_us * th["exposure_rel_tolerance"])),
+ )
+
+ if abs(exp - target.exposure_time_us) > exp_tol:
+ return False
+
+ if abs(iso - target.sensitivity_iso) > int(th["iso_tolerance"]):
+ return False
+
+ return True
+
+
+# ============================================================
+# Estatísticas / QA
+# ============================================================
+
+def raw_stats(raw16: np.ndarray) -> dict:
+ a = raw16.astype(np.float32) / RAW10_MAX
+
+ return {
+ "mean": float(np.mean(a)),
+ "std": float(np.std(a)),
+ "p01": float(np.percentile(a, 1)),
+ "p05": float(np.percentile(a, 5)),
+ "p50": float(np.percentile(a, 50)),
+ "p95": float(np.percentile(a, 95)),
+ "p99": float(np.percentile(a, 99)),
+ "sat_pct": float((raw16 >= RAW10_WHITE_SAT).mean() * 100.0),
+ "dark_pct": float((raw16 <= RAW10_DARK_FLOOR).mean() * 100.0),
+ }
+
+
+def temporal_cv(values: List[float]) -> float:
+ if len(values) < 3:
+ return float("inf")
+
+ arr = np.array(values, dtype=np.float64)
+ mean = float(np.mean(arr))
+
+ if abs(mean) <= 1e-12:
+ return float("inf")
+
+ return float(np.std(arr) / abs(mean))
+
+
+def local_defect_stats(
+ img01: np.ndarray,
+ blur_frac: float = 0.18,
+ margin_frac: float = 0.04,
+ outlier_thr: float = 0.10,
+) -> dict:
+ a = img01.astype(np.float32)
+
+ h, w = a.shape[:2]
+ mx = int(w * margin_frac)
+ my = int(h * margin_frac)
+
+ if w - 2 * mx > 32 and h - 2 * my > 32:
+ a = a[my:h-my, mx:w-mx]
+
+ h, w = a.shape[:2]
+
+ k = int(round(min(h, w) * blur_frac))
+ k = max(31, k)
+ if k % 2 == 0:
+ k += 1
+
+ # Kernel OpenCV não pode exceder a imagem.
+ k = min(k, min(h, w) - (1 - min(h, w) % 2))
+ if k < 3:
+ return {"valid": False}
+
+ smooth = cv2.GaussianBlur(
+ a, (k, k), 0,
+ borderType=cv2.BORDER_REFLECT,
+ )
+
+ ratio = a / np.maximum(smooth, 1e-6)
+ resid = np.abs(ratio - 1.0)
+
+ f = resid[np.isfinite(resid)]
+ if f.size == 0:
+ return {"valid": False}
+
+ return {
+ "valid": True,
+ "blur_ksize": int(k),
+ "local_dev_p95": float(np.percentile(f, 95)),
+ "local_dev_p99": float(np.percentile(f, 99)),
+ "local_dev_max": float(np.max(f)),
+ "local_outlier_pct": float((f >= outlier_thr).mean() * 100.0),
+ "outlier_threshold": float(outlier_thr),
+ }
+
+
+def grid_uniformity(img01: np.ndarray, grid_size=5, margin_frac=0.05) -> dict:
+ a = img01.astype(np.float32)
+
+ h, w = a.shape[:2]
+ mx = int(w * margin_frac)
+ my = int(h * margin_frac)
+
+ if w - 2 * mx > 16 and h - 2 * my > 16:
+ a = a[my:h-my, mx:w-mx]
+
+ h, w = a.shape[:2]
+
+ vals = []
+ grid = []
+
+ for gy in range(grid_size):
+ row = []
+ y0 = int(round(gy * h / grid_size))
+ y1 = int(round((gy + 1) * h / grid_size))
+
+ for gx in range(grid_size):
+ x0 = int(round(gx * w / grid_size))
+ x1 = int(round((gx + 1) * w / grid_size))
+
+ cell = a[y0:y1, x0:x1]
+ f = cell[np.isfinite(cell)]
+
+ v = float(np.median(f)) if f.size else 0.0
+ vals.append(v)
+ row.append(v)
+
+ grid.append(row)
+
+ v = np.array(vals, dtype=np.float64)
+ center = float(grid[grid_size // 2][grid_size // 2])
+ med = float(np.median(v))
+
+ den = max(abs(med), 1e-9)
+
+ return {
+ "grid": grid,
+ "median": med,
+ "center": center,
+ "min": float(np.min(v)),
+ "max": float(np.max(v)),
+ "grid_cv": float(np.std(v) / den),
+ "range_over_median": float((np.max(v) - np.min(v)) / den),
+ }
+
+
+def evaluate_live_white(
+ latest_raw: Dict[str, np.ndarray],
+ temporal_medians: Dict[str, deque],
+ th: dict,
+) -> dict:
+ report = {
+ "status": "good",
+ "roles": {},
+ "reasons": [],
+ }
+
+ for role in ROLES:
+ raw = latest_raw.get(role)
+
+ if raw is None:
+ report["status"] = "bad"
+ report["roles"][role] = {
+ "status": "bad",
+ "reasons": ["sem_frame"],
+ }
+ continue
+
+ st = raw_stats(raw)
+ cv = temporal_cv(list(temporal_medians[role]))
+
+ status = "good"
+ reasons = []
+
+ if st["sat_pct"] > th["white_sat_bad_pct"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"sat_bad:{st['sat_pct']:.3f}%")
+ elif st["sat_pct"] > th["white_sat_warning_pct"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"sat_warn:{st['sat_pct']:.3f}%")
+
+ if st["dark_pct"] > th["white_dark_bad_pct"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"dark_bad:{st['dark_pct']:.3f}%")
+ elif st["dark_pct"] > th["white_dark_warning_pct"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"dark_warn:{st['dark_pct']:.3f}%")
+
+ if np.isfinite(cv):
+ if cv > th["temporal_cv_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"temporal_cv_bad:{cv:.4f}")
+ elif cv > th["temporal_cv_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"temporal_cv_warn:{cv:.4f}")
+
+ report["roles"][role] = {
+ "status": status,
+ "stats": st,
+ "temporal_cv": None if not np.isfinite(cv) else cv,
+ "reasons": reasons,
+ }
+
+ report["status"] = merge_status(report["status"], status)
+ report["reasons"].extend([f"{role}:{x}" for x in reasons])
+
+ return report
+
+
+# ============================================================
+# Acumulador streaming
+# ============================================================
+
+class MeanAccumulator:
+ """
+ Média por pixel com rejeição de frames baseada na mediana global.
+ Não guarda N frames full-res em RAM.
+ """
+
+ def __init__(self, shape: Tuple[int, int], reject_fraction: float):
+ self.shape = tuple(shape)
+ self.reject_fraction = float(reject_fraction)
+
+ self.sum = np.zeros(self.shape, dtype=np.float64)
+ self.count = 0
+ self.rejected = 0
+
+ self.global_medians = deque(maxlen=25)
+ self.accepted_medians = []
+ self.frame_controls = []
+ self.frame_meta = []
+
+ def try_add(self, raw16: np.ndarray, ctrl: dict, meta: dict) -> bool:
+ if raw16.shape != self.shape:
+ raise RuntimeError(
+ f"Shape mudou durante captura: esperado={self.shape}, recebido={raw16.shape}"
+ )
+
+ med = robust_median(raw16)
+
+ if len(self.global_medians) >= 5:
+ ref = float(np.median(np.array(self.global_medians, dtype=np.float64)))
+
+ if abs(ref) > 1e-9:
+ dev = abs(med - ref) / abs(ref)
+
+ if dev > self.reject_fraction:
+ self.rejected += 1
+ return False
+
+ self.global_medians.append(med)
+ self.accepted_medians.append(float(med))
+
+ self.sum += raw16.astype(np.float64)
+ self.count += 1
+
+ self.frame_controls.append(dict(ctrl))
+ self.frame_meta.append(dict(meta))
+
+ return True
+
+ def mean(self) -> np.ndarray:
+ if self.count <= 0:
+ raise RuntimeError("Nenhum frame aceito no acumulador.")
+ return (self.sum / float(self.count)).astype(np.float32)
+
+ def summary(self) -> dict:
+ vals = np.array(self.accepted_medians, dtype=np.float64)
+
+ return {
+ "accepted": int(self.count),
+ "rejected": int(self.rejected),
+ "global_median_mean": float(np.mean(vals)) if vals.size else None,
+ "global_median_std": float(np.std(vals)) if vals.size else None,
+ "global_median_cv": (
+ float(np.std(vals) / max(abs(np.mean(vals)), 1e-9))
+ if vals.size else None
+ ),
+ }
+
+
+# ============================================================
+# Contexto de aquisição
+# ============================================================
+
+class AcquisitionContext:
+ def __init__(
+ self,
+ device,
+ specs: Dict[str, CameraSpec],
+ thresholds: dict,
+ panel_width: int,
+ panel_height: int,
+ rgb_bayer: str,
+ ):
+ self.device = device
+ self.specs = specs
+ self.thresholds = thresholds
+ self.panel_width = int(panel_width)
+ self.panel_height = int(panel_height)
+ self.rgb_bayer = rgb_bayer
+
+ self.raw_queues = {
+ role: device.getOutputQueue(
+ name=spec.stream_name,
+ maxSize=2,
+ blocking=False,
+ )
+ for role, spec in specs.items()
+ }
+
+ self.control_queues = {
+ role: device.getInputQueue(spec.control_name)
+ for role, spec in specs.items()
+ }
+
+ self.latest_raw: Dict[str, Optional[np.ndarray]] = {r: None for r in ROLES}
+ self.latest_ctrl: Dict[str, dict] = {r: {} for r in ROLES}
+ self.latest_packet_meta: Dict[str, dict] = {r: {} for r in ROLES}
+ self.latest_seq: Dict[str, Optional[int]] = {r: None for r in ROLES}
+
+ self.temporal_medians = {
+ r: deque(maxlen=20)
+ for r in ROLES
+ }
+
+ self.last_ui_time = 0.0
+
+ def decode_packet(self, role: str, pkt):
+ spec = self.specs[role]
+
+ # Produto: RAW10 obrigatório para estes três sensores.
+ raw_type = str(pkt.getType()).upper()
+
+ if "RAW10" not in raw_type:
+ raise RuntimeError(
+ f"{role.upper()}/{spec.sensor_name}: esperado RAW10, recebido {pkt.getType()}"
+ )
+
+ width = int(pkt.getWidth())
+ height = int(pkt.getHeight())
+
+ if (width, height) != (spec.width, spec.height):
+ raise RuntimeError(
+ f"{role.upper()}/{spec.sensor_name}: frame {width}x{height}, "
+ f"esperado {spec.width}x{spec.height}"
+ )
+
+ stride = None
+ try:
+ stride = int(pkt.getStride())
+ except Exception:
+ pass
+
+ raw16 = unpack_raw10(
+ pkt.getData(),
+ width=width,
+ height=height,
+ stride=stride,
+ )
+
+ ctrl = packet_controls(pkt)
+
+ seq = ctrl.get("sequence_num")
+ meta = {
+ "role": role,
+ "socket": spec.socket_name,
+ "sensor": spec.sensor_name,
+ "width": width,
+ "height": height,
+ "stride": stride,
+ "raw_type": raw_type,
+ "sequence_num": seq,
+ "timestamp_s": ctrl.get("timestamp_s"),
+ }
+
+ return raw16, ctrl, meta
+
+ def poll(self):
+ updated = []
+
+ for role in ROLES:
+ pkt = self.raw_queues[role].tryGet()
+ if pkt is None:
+ continue
+
+ raw16, ctrl, meta = self.decode_packet(role, pkt)
+
+ seq = ctrl.get("sequence_num")
+
+ if seq is not None and seq == self.latest_seq.get(role):
+ continue
+
+ self.latest_seq[role] = seq
+ self.latest_raw[role] = raw16
+ self.latest_ctrl[role] = ctrl
+ self.latest_packet_meta[role] = meta
+ self.temporal_medians[role].append(robust_median(raw16))
+
+ updated.append(role)
+
+ return updated
+
+ def wait_until_all_have_frames(self, timeout=8.0):
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ self.poll()
+
+ if all(self.latest_raw[r] is not None for r in ROLES):
+ return
+
+ time.sleep(0.003)
+
+ missing = [r for r in ROLES if self.latest_raw[r] is None]
+ raise TimeoutError(f"Timeout aguardando RAW das roles: {missing}")
+
+ def send_locked_controls(self, locked: Dict[str, LockedControl]):
+ for role in ROLES:
+ send_manual_control(
+ self.control_queues[role],
+ locked[role],
+ lock_awb=(role == "rgb"),
+ )
+
+ def verify_locked_controls(
+ self,
+ locked: Dict[str, LockedControl],
+ settle_frames=10,
+ timeout=10.0,
+ ):
+ matched = {r: 0 for r in ROLES}
+ last_seq = dict(self.latest_seq)
+
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ self.poll()
+
+ for role in ROLES:
+ seq = self.latest_seq.get(role)
+ if seq is None or seq == last_seq.get(role):
+ continue
+
+ last_seq[role] = seq
+
+ if controls_match(
+ self.latest_ctrl[role],
+ locked[role],
+ self.thresholds,
+ ):
+ matched[role] += 1
+ else:
+ matched[role] = 0
+
+ self.show_board(
+ title="TRAVANDO CONTROLES",
+ lines=[
+ "Verificando exposição/ISO manual...",
+ *[
+ f"{r.upper()}: {matched[r]}/{settle_frames} "
+ f"target={locked[r].exposure_time_us}us ISO{locked[r].sensitivity_iso} "
+ f"actual={self.latest_ctrl[r].get('exposure_time_us')}us "
+ f"ISO{self.latest_ctrl[r].get('sensitivity_iso')}"
+ for r in ROLES
+ ],
+ "",
+ "Não altere iluminação nem cubra as lentes ainda.",
+ ],
+ )
+
+ if all(matched[r] >= settle_frames for r in ROLES):
+ return
+
+ k = cv2.waitKey(1) & 0xFF
+ if k in (ord("q"), ord("Q"), 27):
+ raise KeyboardInterrupt("Cancelado durante lock de controles.")
+
+ time.sleep(0.001)
+
+ raise RuntimeError(
+ "Não foi possível confirmar controles manuais estáveis. "
+ f"matched={matched}"
+ )
+
+ def _rgb_raw_preview(self, raw16: np.ndarray) -> np.ndarray:
+ # Preview somente. A calibração usa RawProcessorCore.
+ code_map = {
+ "RGGB": cv2.COLOR_BayerRG2BGR,
+ "BGGR": cv2.COLOR_BayerBG2BGR,
+ "GRBG": cv2.COLOR_BayerGR2BGR,
+ "GBRG": cv2.COLOR_BayerGB2BGR,
+ }
+
+ u8 = raw10_display(raw16)
+
+ try:
+ bgr = cv2.cvtColor(u8, code_map[self.rgb_bayer])
+ except Exception:
+ bgr = cv2.cvtColor(u8, cv2.COLOR_GRAY2BGR)
+
+ return bgr
+
+ def show_board(
+ self,
+ title: str,
+ lines: List[str],
+ progress: Optional[Dict[str, Tuple[int, int]]] = None,
+ quality: Optional[dict] = None,
+ ):
+ pw = self.panel_width
+ ph = self.panel_height
+
+ panels = {}
+
+ for role in ROLES:
+ raw = self.latest_raw.get(role)
+ spec = self.specs[role]
+
+ if raw is None:
+ panel = np.zeros((ph, pw, 3), dtype=np.uint8)
+ overlay_hud(panel, [role.upper(), "aguardando frame"])
+ panels[role] = panel
+ continue
+
+ if role == "rgb":
+ view = self._rgb_raw_preview(raw)
+ else:
+ u8 = raw10_display(raw)
+ view = cv2.cvtColor(u8, cv2.COLOR_GRAY2BGR)
+
+ panel = resize_panel(view, pw, ph)
+
+ st = raw_stats(raw)
+ ctrl = self.latest_ctrl.get(role, {})
+
+ overlay_hud(
+ panel,
+ [
+ f"{role.upper()} | {spec.socket_name} | {spec.sensor_name}",
+ f"RAW10 {spec.width}x{spec.height}",
+ f"p50={st['p50']:.3f} p99={st['p99']:.3f}",
+ f"sat={st['sat_pct']:.3f}% dark={st['dark_pct']:.3f}%",
+ f"EXP={ctrl.get('exposure_time_us')}us ISO={ctrl.get('sensitivity_iso')}",
+ ],
+ x=10,
+ y=22,
+ font_scale=0.46,
+ line_step=19,
+ )
+
+ panels[role] = panel
+
+ data = np.zeros((ph, pw, 3), dtype=np.uint8)
+
+ data_lines = [title, ""] + list(lines)
+
+ if quality:
+ data_lines.append("")
+ data_lines.append(f"PREFLIGHT: {quality.get('status', 'unknown').upper()}")
+
+ for role in ROLES:
+ item = quality.get("roles", {}).get(role, {})
+ st = item.get("status", "unknown").upper()
+ cv = item.get("temporal_cv")
+ cv_txt = "n/a" if cv is None else f"{cv*100:.2f}%"
+ data_lines.append(
+ f"{role.upper()}: {st} temporal_cv={cv_txt}"
+ )
+
+ for reason in item.get("reasons", [])[:2]:
+ data_lines.append(f" ! {reason}")
+
+ if progress:
+ data_lines.append("")
+ for role in ROLES:
+ a, b = progress.get(role, (0, 0))
+ data_lines.append(f"{role.upper()}: {a}/{b}")
+
+ overlay_hud(
+ data,
+ data_lines,
+ x=14,
+ y=24,
+ font_scale=0.44,
+ line_step=18,
+ )
+
+ board = np.vstack([
+ np.hstack([panels["rgb"], panels["re"]]),
+ np.hstack([panels["nir"], data]),
+ ])
+
+ cv2.imshow("Flat Field Calibration - Production", board)
+ return board
+
+
+# ============================================================
+# Preflight / locking
+# ============================================================
+
+def resolve_manual_override(args, role: str):
+ exp = getattr(args, f"{role}_exp_us")
+ iso = getattr(args, f"{role}_iso")
+
+ if exp is None and iso is None:
+ return None
+
+ if exp is None or iso is None:
+ raise ValueError(
+ f"Para override manual de {role}, informe ambos "
+ f"--{role}-exp-us e --{role}-iso."
+ )
+
+ return LockedControl(
+ role=role,
+ exposure_time_us=int(exp),
+ sensitivity_iso=int(iso),
+ source="cli_manual",
+ )
+
+
+def choose_locked_controls(
+ ctx: AcquisitionContext,
+ args,
+) -> Dict[str, LockedControl]:
+ locked = {}
+
+ for role in ROLES:
+ override = resolve_manual_override(args, role)
+
+ if override is not None:
+ locked[role] = override
+ continue
+
+ # Usa mediana das últimas leituras recentes observadas.
+ exp_values = []
+ iso_values = []
+
+ # O estado latest contém a amostra mais recente. Para robustez,
+ # capturamos mais algumas amostras rapidamente.
+ t0 = time.time()
+
+ while time.time() - t0 < 0.7:
+ ctx.poll()
+
+ c = ctx.latest_ctrl.get(role, {})
+ exp = safe_int(c.get("exposure_time_us"), None)
+ iso = safe_int(c.get("sensitivity_iso"), None)
+
+ if exp is not None and exp > 0:
+ exp_values.append(exp)
+ if iso is not None and iso > 0:
+ iso_values.append(iso)
+
+ time.sleep(0.01)
+
+ if not exp_values or not iso_values:
+ raise RuntimeError(
+ f"Não foi possível ler exposição/ISO reais da câmera {role.upper()}."
+ )
+
+ locked[role] = LockedControl(
+ role=role,
+ exposure_time_us=int(round(np.median(exp_values))),
+ sensitivity_iso=int(round(np.median(iso_values))),
+ source="ae_snapshot",
+ )
+
+ return locked
+
+
+def wait_white_preflight(
+ ctx: AcquisitionContext,
+ args,
+ thresholds: dict,
+) -> Tuple[dict, Dict[str, LockedControl]]:
+ print("")
+ print("[ETAPA] WHITE PREFLIGHT")
+ print("Aponte para alvo branco/cinza fosco uniforme, preenchendo todo o FOV.")
+ print("A iluminação deve estar estável e sem reflexos.")
+ print("ENTER congela os controles quando o preflight estiver aprovado.")
+
+ ctx.wait_until_all_have_frames()
+
+ last_quality = None
+
+ while True:
+ ctx.poll()
+
+ last_quality = evaluate_live_white(
+ {r: ctx.latest_raw[r] for r in ROLES},
+ ctx.temporal_medians,
+ thresholds,
+ )
+
+ ctx.show_board(
+ title="ETAPA 0/3 - WHITE PREFLIGHT",
+ lines=[
+ "Alvo branco/cinza fosco uniforme.",
+ "Preencha TODO o campo de visão.",
+ "Aguarde AE estabilizar.",
+ "",
+ "ENTER = congelar EXP/ISO e iniciar",
+ "Q/ESC = cancelar",
+ ],
+ quality=last_quality,
+ )
+
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (ord("q"), ord("Q"), 27):
+ raise KeyboardInterrupt("Cancelado no WHITE PREFLIGHT.")
+
+ if k in (13, 10):
+ status = last_quality.get("status", "bad")
+
+ allowed = (
+ status == "good"
+ or (status == "warning" and args.allow_warning_preflight)
+ or args.force_preflight
+ )
+
+ if not allowed:
+ print(
+ f"[BLOCK] WHITE PREFLIGHT={status.upper()}. "
+ "Ajuste luz/alvo ou use override explícito somente se souber o que está fazendo."
+ )
+ continue
+
+ locked = choose_locked_controls(ctx, args)
+
+ print("[LOCK] Controles escolhidos:")
+ for role in ROLES:
+ c = locked[role]
+ print(
+ f" {role.upper()}: {c.exposure_time_us} us | "
+ f"ISO {c.sensitivity_iso} | source={c.source}"
+ )
+
+ ctx.send_locked_controls(locked)
+ ctx.verify_locked_controls(
+ locked,
+ settle_frames=args.lock_settle_frames,
+ )
+
+ return last_quality, locked
+
+ time.sleep(0.002)
+
+
+# ============================================================
+# Captura de estágio
+# ============================================================
+
+def wait_stage_prompt(
+ ctx: AcquisitionContext,
+ title: str,
+ instructions: List[str],
+):
+ while True:
+ ctx.poll()
+
+ ctx.show_board(
+ title=title,
+ lines=instructions + [
+ "",
+ "ENTER = iniciar captura",
+ "Q/ESC = cancelar",
+ ],
+ )
+
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (ord("q"), ord("Q"), 27):
+ raise KeyboardInterrupt(f"Cancelado em {title}.")
+
+ if k in (13, 10):
+ return
+
+ time.sleep(0.002)
+
+
+def capture_stage(
+ ctx: AcquisitionContext,
+ stage_name: str,
+ frames: int,
+ discard_frames: int,
+ locked: Dict[str, LockedControl],
+ thresholds: dict,
+) -> Tuple[Dict[str, np.ndarray], dict]:
+ target = int(frames)
+
+ accum = {
+ role: MeanAccumulator(
+ shape=(ctx.specs[role].height, ctx.specs[role].width),
+ reject_fraction=thresholds["frame_global_deviation_reject"],
+ )
+ for role in ROLES
+ }
+
+ discard_left = {role: int(discard_frames) for role in ROLES}
+ last_seq = dict(ctx.latest_seq)
+
+ t0 = time.time()
+
+ while any(accum[r].count < target for r in ROLES):
+ updated = ctx.poll()
+
+ for role in updated:
+ if accum[role].count >= target:
+ continue
+
+ seq = ctx.latest_seq.get(role)
+ if seq is not None and seq == last_seq.get(role):
+ continue
+ last_seq[role] = seq
+
+ ctrl = ctx.latest_ctrl[role]
+
+ if not controls_match(ctrl, locked[role], thresholds):
+ raise RuntimeError(
+ f"{stage_name}/{role}: controles divergiram do lock. "
+ f"target={asdict(locked[role])}, actual={ctrl}"
+ )
+
+ if discard_left[role] > 0:
+ discard_left[role] -= 1
+ continue
+
+ raw = ctx.latest_raw[role]
+
+ accum[role].try_add(
+ raw,
+ ctrl=ctrl,
+ meta=ctx.latest_packet_meta[role],
+ )
+
+ progress = {
+ role: (accum[role].count, target)
+ for role in ROLES
+ }
+
+ ctx.show_board(
+ title=f"CAPTURA - {stage_name.upper()}",
+ lines=[
+ "Controles MANUAIS travados.",
+ "Não mova o módulo nem altere iluminação.",
+ f"Tempo: {time.time() - t0:.1f}s",
+ "",
+ "Frames temporalmente anômalos são rejeitados.",
+ "Q/ESC = cancelar",
+ ],
+ progress=progress,
+ )
+
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (ord("q"), ord("Q"), 27):
+ raise KeyboardInterrupt(f"Captura {stage_name} cancelada.")
+
+ time.sleep(0.001)
+
+ means = {
+ role: accum[role].mean()
+ for role in ROLES
+ }
+
+ report = {
+ role: accum[role].summary()
+ for role in ROLES
+ }
+
+ return means, report
+
+
+# ============================================================
+# Decode usando o RawProcessorCore da aplicação
+# ============================================================
+
+def disable_nonflat_processing(core: RawProcessorCore):
+ """
+ Mantém SOMENTE o decode físico necessário para produzir R/G/B/RE/NIR.
+
+ Durante a geração de um novo flat não podemos deixar uma calibração antiga
+ participar da própria medição. Por isso ficam forçados OFF:
+ - flat-field existente;
+ - normalização radiométrica;
+ - enhancement;
+ - ganhos RGB globais/calibração de cor.
+
+ O único item herdável do module_params é a estratégia de DECODE RGB
+ (mode + demosaic_algorithm), pois o mapa precisa nascer no mesmo domínio
+ espacial em que será aplicado na aplicação.
+ """
+ try:
+ core.flatfield_config["enabled"] = False
+ core.flatfield_config["npz_file"] = None
+ except Exception:
+ pass
+
+ try:
+ core.flatfield_loaded = False
+ core.flatfield_maps = {}
+ except Exception:
+ pass
+
+ for attr in (
+ "radiometric_normalization_config",
+ "radiometric_config",
+ "patch_normalization_config",
+ ):
+ try:
+ cfg = getattr(core, attr, None)
+ if isinstance(cfg, dict):
+ cfg["enabled"] = False
+ except Exception:
+ pass
+
+ try:
+ enh = core.rgb_processing_config.get("enhancement")
+ if isinstance(enh, dict):
+ enh["enabled"] = False
+ auto_stretch = enh.get("auto_stretch")
+ if isinstance(auto_stretch, dict):
+ auto_stretch["enabled"] = False
+ except Exception:
+ pass
+
+ try:
+ core.rgb_calibration["enabled"] = False
+ gains = core.rgb_calibration.get("gains")
+ if isinstance(gains, dict):
+ gains.update({"R": 1.0, "G": 1.0, "B": 1.0})
+ except Exception:
+ pass
+
+
+def create_processing_core(
+ specs: Dict[str, CameraSpec],
+ module_params_path: Optional[str],
+) -> RawProcessorCore:
+ """
+ Cria um RawProcessorCore LIMPO.
+
+ Regra importante:
+ NUNCA passa module_params como calibration_json_path aqui. O construtor do
+ RawProcessorCore pode carregar flatfield_config imediatamente, portanto um
+ flat antigo da OV9782 poderia contaminar ou até quebrar a calibração da
+ AR0234 antes de conseguirmos desligá-lo.
+
+ Do module_params copiamos apenas:
+ rgb_processing.mode
+ rgb_processing.demosaic_algorithm
+ """
+ rgb = specs["rgb"]
+
+ core = RawProcessorCore(
+ sensor_width=rgb.width,
+ sensor_height=rgb.height,
+ bayer_pattern=rgb.bayer_pattern or "BGGR",
+ calibration_json_path=None,
+ )
+
+ module_cfg = load_json(module_params_path)
+
+ rgb_cfg = module_cfg.get("rgb_processing")
+ if isinstance(rgb_cfg, dict):
+ mode = str(
+ rgb_cfg.get("mode", core.rgb_processing_config.get("mode", "linear_demosaic"))
+ ).strip().lower()
+
+ allowed_modes = {
+ "linear_demosaic",
+ "linear_demosaic_half",
+ "bayer_planes",
+ }
+
+ if mode not in allowed_modes:
+ raise RuntimeError(
+ "rgb_processing.mode do module_params não é seguro/compatível "
+ f"com este calibrador: {mode!r}. Permitidos: {sorted(allowed_modes)}"
+ )
+
+ algorithm = str(
+ rgb_cfg.get(
+ "demosaic_algorithm",
+ core.rgb_processing_config.get("demosaic_algorithm", "ea"),
+ )
+ ).strip().lower()
+
+ if algorithm not in {"ea", "bilinear"}:
+ raise RuntimeError(
+ "rgb_processing.demosaic_algorithm inválido no module_params: "
+ f"{algorithm!r}. Use 'ea' ou 'bilinear'."
+ )
+
+ core.rgb_processing_config["mode"] = mode
+ core.rgb_processing_config["demosaic_algorithm"] = algorithm
+
+ # A identidade do sensor recém-descoberto sempre vence qualquer JSON antigo.
+ core.sensor_width = int(rgb.width)
+ core.sensor_height = int(rgb.height)
+ core.bayer_pattern = str(rgb.bayer_pattern or "BGGR").upper()
+
+ disable_nonflat_processing(core)
+ return core
+
+
+def build_decode_meta(specs: Dict[str, CameraSpec]) -> dict:
+ camera_info = {}
+
+ for role in ROLES:
+ spec = specs[role]
+
+ camera_info[spec.socket_name] = {
+ "id": spec.socket_name,
+ "role": role,
+ "sensor": spec.sensor_name,
+ "sensor_name": spec.sensor_name,
+ "width": int(spec.width),
+ "height": int(spec.height),
+ "bit_depth": 10,
+ "raw_format": "RAW10_PACKED",
+ "packed": True,
+ "bayer_pattern": spec.bayer_pattern if role == "rgb" else None,
+ }
+
+ return {
+ "frame_type": "RAW_BRUTO",
+ "camera_info": camera_info,
+ "camera_frames": camera_info,
+ }
+
+
+def decode_reference(
+ core: RawProcessorCore,
+ specs: Dict[str, CameraSpec],
+ raw_means: Dict[str, np.ndarray],
+) -> dict:
+ frame = {}
+
+ for role in ROLES:
+ spec = specs[role]
+ raw = raw_means[role]
+
+ expected = (spec.height, spec.width)
+ if raw.shape != expected:
+ raise RuntimeError(
+ f"decode_reference/{role}: shape={raw.shape}, esperado={expected}"
+ )
+
+ frame[spec.socket_name] = pack_raw10(raw)
+
+ decoded = core.decode_stream_cameras(
+ frame,
+ build_decode_meta(specs),
+ )
+
+ out = {}
+
+ for cam_id, item in decoded.items():
+ role = str(item.get("role", "")).lower()
+ img = item.get("image")
+
+ if role == "rgb":
+ if img is None or img.ndim != 3 or img.shape[2] < 3:
+ raise RuntimeError(f"Decode RGB inválido: {None if img is None else img.shape}")
+
+ out["R"] = img[:, :, 0].astype(np.float32).copy()
+ out["G"] = img[:, :, 1].astype(np.float32).copy()
+ out["B"] = img[:, :, 2].astype(np.float32).copy()
+
+ elif role == "re":
+ if img is None or img.ndim != 2:
+ raise RuntimeError(f"Decode RE inválido: {None if img is None else img.shape}")
+ out["RE"] = img.astype(np.float32).copy()
+
+ elif role == "nir":
+ if img is None or img.ndim != 2:
+ raise RuntimeError(f"Decode NIR inválido: {None if img is None else img.shape}")
+ out["NIR"] = img.astype(np.float32).copy()
+
+ missing = [ch for ch in CHANNELS if ch not in out]
+ if missing:
+ raise RuntimeError(f"RawProcessorCore não produziu canais: {missing}")
+
+ return out
+
+
+# ============================================================
+# Construção do flat
+# ============================================================
+
+def gaussian_smooth_field(signal: np.ndarray, sigma_frac: float) -> Tuple[np.ndarray, float]:
+ """
+ Suaviza o CAMPO luminoso antes de inverter.
+ sigma é proporcional à menor dimensão, portanto AR0234/OV9782 recebem
+ escalas comparáveis no FOV.
+ """
+ a = signal.astype(np.float32)
+
+ sigma = max(1.0, float(min(a.shape[:2])) * float(sigma_frac))
+
+ smooth = cv2.GaussianBlur(
+ a,
+ (0, 0),
+ sigmaX=sigma,
+ sigmaY=sigma,
+ borderType=cv2.BORDER_REFLECT,
+ )
+
+ return smooth.astype(np.float32), float(sigma)
+
+
+def build_gain_maps(
+ white_a: dict,
+ dark: Optional[dict],
+ epsilon: float,
+ sigma_frac: float,
+ min_gain: float,
+ max_gain: float,
+):
+ gain_maps = {}
+ flat_norm = {}
+ white_signal = {}
+ smooth_fields = {}
+ dark_used = {}
+ processing_meta = {}
+
+ for ch in CHANNELS:
+ w = white_a[ch].astype(np.float32)
+
+ if dark is not None:
+ d = dark[ch].astype(np.float32)
+
+ if d.shape != w.shape:
+ raise RuntimeError(
+ f"{ch}: DARK shape {d.shape} != WHITE shape {w.shape}. "
+ "Calibração abortada; resize de dark é proibido."
+ )
+ else:
+ d = np.zeros_like(w, dtype=np.float32)
+
+ signal = w - d
+ signal = np.maximum(signal, float(epsilon)).astype(np.float32)
+
+ smooth, sigma = gaussian_smooth_field(signal, sigma_frac=sigma_frac)
+
+ reference = robust_center_reference(smooth)
+ gain_unclipped = reference / np.maximum(smooth, float(epsilon))
+
+ gain = np.clip(
+ gain_unclipped,
+ float(min_gain),
+ float(max_gain),
+ ).astype(np.float32)
+
+ gain_maps[ch] = gain
+ flat_norm[ch] = (smooth / max(reference, epsilon)).astype(np.float32)
+ white_signal[ch] = signal
+ smooth_fields[ch] = smooth
+ dark_used[ch] = d
+
+ processing_meta[ch] = {
+ "reference": float(reference),
+ "sigma_px": float(sigma),
+ "gain_unclipped_min": float(np.min(gain_unclipped)),
+ "gain_unclipped_max": float(np.max(gain_unclipped)),
+ "clipped_low_pct": float((gain_unclipped < min_gain).mean() * 100.0),
+ "clipped_high_pct": float((gain_unclipped > max_gain).mean() * 100.0),
+ }
+
+ return {
+ "gain_maps": gain_maps,
+ "flat_norm": flat_norm,
+ "white_signal": white_signal,
+ "smooth_fields": smooth_fields,
+ "dark_used": dark_used,
+ "processing_meta": processing_meta,
+ }
+
+
+# ============================================================
+# Acceptance test
+# ============================================================
+
+def evaluate_gain_channel(gain: np.ndarray, th: dict) -> dict:
+ f = gain[np.isfinite(gain)]
+
+ if f.size == 0:
+ return {
+ "status": "bad",
+ "reasons": ["gain_invalid"],
+ }
+
+ st = {
+ "min": float(np.min(f)),
+ "p01": float(np.percentile(f, 1)),
+ "p05": float(np.percentile(f, 5)),
+ "p50": float(np.percentile(f, 50)),
+ "p95": float(np.percentile(f, 95)),
+ "p99": float(np.percentile(f, 99)),
+ "max": float(np.max(f)),
+ "mean": float(np.mean(f)),
+ "std": float(np.std(f)),
+ }
+
+ status = "good"
+ reasons = []
+
+ if st["p99"] > th["gain_p99_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"gain_p99_bad:{st['p99']:.3f}")
+ elif st["p99"] > th["gain_p99_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"gain_p99_warn:{st['p99']:.3f}")
+
+ if st["max"] > th["gain_max_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"gain_max_bad:{st['max']:.3f}")
+ elif st["max"] > th["gain_max_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"gain_max_warn:{st['max']:.3f}")
+
+ if st["min"] < th["gain_min_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"gain_min_bad:{st['min']:.3f}")
+ elif st["min"] < th["gain_min_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"gain_min_warn:{st['min']:.3f}")
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "stats": st,
+ }
+
+
+def evaluate_dark_channels(dark: dict, th: dict) -> dict:
+ result = {
+ "status": "good",
+ "channels": {},
+ "reasons": [],
+ }
+
+ for ch in CHANNELS:
+ a = dark[ch].astype(np.float32)
+ p99 = finite_percentile(a, 99, default=1.0)
+
+ status = "good"
+ reasons = []
+
+ if p99 > th["dark_p99_bad"]:
+ status = "bad"
+ reasons.append(f"dark_p99_bad:{p99:.4f}")
+ elif p99 > th["dark_p99_warning"]:
+ status = "warning"
+ reasons.append(f"dark_p99_warn:{p99:.4f}")
+
+ result["channels"][ch] = {
+ "status": status,
+ "p99": p99,
+ "reasons": reasons,
+ }
+
+ result["status"] = merge_status(result["status"], status)
+ result["reasons"].extend([f"{ch}:{x}" for x in reasons])
+
+ return result
+
+
+def acceptance_test(
+ gain_maps: dict,
+ white_b: dict,
+ dark: Optional[dict],
+ th: dict,
+) -> dict:
+ result = {
+ "status": "good",
+ "channels": {},
+ "reasons": [],
+ }
+
+ for ch in CHANNELS:
+ wb = white_b[ch].astype(np.float32)
+
+ if dark is not None:
+ d = dark[ch].astype(np.float32)
+
+ if wb.shape != d.shape:
+ raise RuntimeError(
+ f"Acceptance {ch}: WHITE_B {wb.shape} != DARK {d.shape}"
+ )
+ else:
+ d = np.zeros_like(wb)
+
+ signal = np.maximum(wb - d, 1e-8)
+ corrected = signal * gain_maps[ch]
+
+ before = grid_uniformity(signal)
+ after = grid_uniformity(corrected)
+
+ before_cv = float(before["grid_cv"])
+ after_cv = float(after["grid_cv"])
+
+ if before_cv > 1e-9:
+ improvement = 1.0 - (after_cv / before_cv)
+ else:
+ improvement = 0.0
+
+ local_after = local_defect_stats(
+ corrected,
+ outlier_thr=th["local_dev_p99_warning"],
+ )
+
+ gain_report = evaluate_gain_channel(gain_maps[ch], th)
+
+ status = gain_report["status"]
+ reasons = list(gain_report["reasons"])
+
+ if after_cv > th["corrected_grid_cv_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"corrected_grid_cv_bad:{after_cv:.4f}")
+ elif after_cv > th["corrected_grid_cv_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"corrected_grid_cv_warn:{after_cv:.4f}")
+
+ if before_cv >= th["min_before_grid_cv_for_improvement"]:
+ if improvement < th["worsening_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"flat_worsened:{improvement:.3f}")
+ elif improvement < th["improvement_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"improvement_bad:{improvement:.3f}")
+ elif improvement < th["improvement_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"improvement_warn:{improvement:.3f}")
+
+ if local_after.get("valid"):
+ dev = float(local_after["local_dev_p99"])
+ out_pct = float(local_after["local_outlier_pct"])
+
+ if dev > th["local_dev_p99_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"local_dev_bad:{dev:.3f}")
+ elif dev > th["local_dev_p99_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"local_dev_warn:{dev:.3f}")
+
+ if out_pct > th["local_outlier_pct_bad"]:
+ status = merge_status(status, "bad")
+ reasons.append(f"local_outlier_bad:{out_pct:.2f}%")
+ elif out_pct > th["local_outlier_pct_warning"]:
+ status = merge_status(status, "warning")
+ reasons.append(f"local_outlier_warn:{out_pct:.2f}%")
+
+ result["channels"][ch] = {
+ "status": status,
+ "reasons": reasons,
+ "before_uniformity": before,
+ "after_uniformity": after,
+ "grid_cv_before": before_cv,
+ "grid_cv_after": after_cv,
+ "improvement": float(improvement),
+ "local_after": local_after,
+ "gain": gain_report,
+ }
+
+ result["status"] = merge_status(result["status"], status)
+ result["reasons"].extend([f"{ch}:{x}" for x in reasons])
+
+ return result
+
+
+# ============================================================
+# Previews
+# ============================================================
+
+def gray_bgr(arr: np.ndarray) -> np.ndarray:
+ return cv2.cvtColor(normalize_display(arr), cv2.COLOR_GRAY2BGR)
+
+
+def save_previews(
+ out_dir: Path,
+ gain_maps: dict,
+ white_signal_a: dict,
+ white_b: dict,
+ dark: Optional[dict],
+):
+ ensure_dir(out_dir)
+
+ for ch in CHANNELS:
+ gain = gain_maps[ch]
+
+ wb = white_b[ch].astype(np.float32)
+ d = dark[ch].astype(np.float32) if dark is not None else np.zeros_like(wb)
+ validation_signal = np.maximum(wb - d, 1e-8)
+ corrected = validation_signal * gain
+
+ cv2.imwrite(str(out_dir / f"{ch}_gain.png"), gray_bgr(gain))
+ cv2.imwrite(str(out_dir / f"{ch}_white_A_signal.png"), gray_bgr(white_signal_a[ch]))
+ cv2.imwrite(str(out_dir / f"{ch}_white_B_before.png"), gray_bgr(validation_signal))
+ cv2.imwrite(str(out_dir / f"{ch}_white_B_after.png"), gray_bgr(corrected))
+
+ # Comparativo lado a lado.
+ before = gray_bgr(validation_signal)
+ after = gray_bgr(corrected)
+
+ h = min(before.shape[0], after.shape[0])
+ w = min(before.shape[1], after.shape[1])
+
+ before = cv2.resize(before, (w, h))
+ after = cv2.resize(after, (w, h))
+
+ overlay_hud(before, [f"{ch} BEFORE"], x=12, y=26, font_scale=0.65, line_step=24)
+ overlay_hud(after, [f"{ch} AFTER"], x=12, y=26, font_scale=0.65, line_step=24)
+
+ side = np.hstack([before, after])
+ cv2.imwrite(str(out_dir / f"{ch}_validation_compare.png"), side)
+
+
+# ============================================================
+# Persistência / promoção
+# ============================================================
+
+def suggested_flatfield_config(active_npz: str) -> dict:
+ return {
+ "flatfield_config": {
+ "enabled": True,
+ "npz_file": str(active_npz).replace("\\", "/"),
+ "apply_before_fusion": True,
+ "apply_after_decode": True,
+ "apply_space": "native_camera_space",
+ "map_type": "gain",
+ "channels": list(CHANNELS),
+ "channel_maps": {
+ ch: f"gain_{ch}"
+ for ch in CHANNELS
+ },
+ "subtract_dark": False,
+ "clip_output": True,
+ }
+ }
+
+
+def save_candidate_npz(
+ path: Path,
+ gain_maps: dict,
+ flat_norm: dict,
+ white_a: dict,
+ white_b: dict,
+ dark: Optional[dict],
+ raw_white_a: dict,
+ raw_white_b: dict,
+ raw_dark: Optional[dict],
+):
+ payload = {}
+
+ for ch in CHANNELS:
+ payload[f"gain_{ch}"] = gain_maps[ch].astype(np.float32)
+ payload[f"flat_norm_{ch}"] = flat_norm[ch].astype(np.float32)
+
+ # Novo nome correto.
+ payload[f"white_reference_{ch}"] = white_a[ch].astype(np.float32)
+ payload[f"validation_white_{ch}"] = white_b[ch].astype(np.float32)
+
+ # Alias legado para ferramentas existentes.
+ payload[f"white_median_{ch}"] = white_a[ch].astype(np.float32)
+
+ if dark is not None:
+ payload[f"dark_reference_{ch}"] = dark[ch].astype(np.float32)
+ payload[f"dark_median_{ch}"] = dark[ch].astype(np.float32)
+
+ # Auditoria RAW nativa por câmera.
+ for role in ROLES:
+ payload[f"raw_white_A_{role}"] = raw_white_a[role].astype(np.float32)
+ payload[f"raw_white_B_{role}"] = raw_white_b[role].astype(np.float32)
+
+ if raw_dark is not None:
+ payload[f"raw_dark_{role}"] = raw_dark[role].astype(np.float32)
+
+ ensure_dir(path.parent)
+ tmp = path.with_suffix(path.suffix + ".tmp.npz")
+ np.savez_compressed(tmp, **payload)
+ os.replace(tmp, path)
+
+
+def atomic_promote(
+ candidate_npz: Path,
+ candidate_report: Path,
+ active_npz: Path,
+ active_json: Path,
+):
+ ensure_dir(active_npz.parent)
+ ensure_dir(active_json.parent)
+
+ # Backup da calibração ativa anterior.
+ stamp = session_id()
+
+ if active_npz.exists():
+ backup = active_npz.with_name(active_npz.stem + f".backup_{stamp}" + active_npz.suffix)
+ shutil.copy2(active_npz, backup)
+
+ if active_json.exists():
+ backup = active_json.with_name(active_json.stem + f".backup_{stamp}" + active_json.suffix)
+ shutil.copy2(active_json, backup)
+
+ tmp_npz = active_npz.with_suffix(active_npz.suffix + ".tmp")
+ tmp_json = active_json.with_suffix(active_json.suffix + ".tmp")
+
+ shutil.copy2(candidate_npz, tmp_npz)
+ shutil.copy2(candidate_report, tmp_json)
+
+ # Só troca depois que ambos foram copiados.
+ os.replace(tmp_npz, active_npz)
+ os.replace(tmp_json, active_json)
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=(
+ "Calibrador de flat-field/dark-frame de produção para "
+ "OAK-FFC-3P: OV9782 ou AR0234 + 2x OV9282."
+ ),
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ # Hardware
+ parser.add_argument("--mx-id", default=None)
+ parser.add_argument("--fps", type=float, default=20.0)
+ parser.add_argument(
+ "--anti-banding",
+ default="OFF",
+ choices=["OFF", "50", "60", "50HZ", "60HZ"],
+ )
+ parser.add_argument(
+ "--rgb-bayer",
+ default="auto",
+ choices=["auto", "RGGB", "BGGR", "GRBG", "GBRG"],
+ )
+ parser.add_argument(
+ "--module-params",
+ default="calibration/module_params.json",
+ help=(
+ "Lê SOMENTE rgb_processing.mode e demosaic_algorithm para manter "
+ "o mesmo domínio de decode da produção. O JSON completo NÃO é "
+ "carregado no RawProcessorCore durante a calibração, evitando que "
+ "flat/radiometric/enhancement antigos contaminem o novo flat."
+ ),
+ )
+
+ # Captura
+ parser.add_argument("--frames", type=int, default=60)
+ parser.add_argument("--validation-frames", type=int, default=30)
+ parser.add_argument("--dark-frames", type=int, default=60)
+ parser.add_argument("--discard-frames", type=int, default=12)
+ parser.add_argument("--lock-settle-frames", type=int, default=8)
+
+ # Overrides manuais opcionais
+ for role in ROLES:
+ parser.add_argument(f"--{role}-exp-us", type=int, default=None)
+ parser.add_argument(f"--{role}-iso", type=int, default=None)
+
+ # Processamento
+ parser.add_argument(
+ "--sigma-frac",
+ type=float,
+ default=0.025,
+ help="Sigma do shading gaussiano como fração da menor dimensão.",
+ )
+ parser.add_argument("--min-gain", type=float, default=0.25)
+ parser.add_argument("--max-gain", type=float, default=4.0)
+ parser.add_argument("--epsilon", type=float, default=1e-6)
+
+ # Política
+ parser.add_argument(
+ "--skip-dark",
+ action="store_true",
+ help=(
+ "Permite gerar candidate sem dark. Por padrão candidate sem dark "
+ "NÃO é promovido, a menos que --allow-no-dark-promote seja usado."
+ ),
+ )
+ parser.add_argument("--allow-no-dark-promote", action="store_true")
+ parser.add_argument("--allow-warning-preflight", action="store_true")
+ parser.add_argument(
+ "--force-preflight",
+ action="store_true",
+ help="Override explícito de engenharia. Fica registrado no report.",
+ )
+ parser.add_argument(
+ "--promote-warning",
+ action="store_true",
+ help="Permite promover acceptance WARNING. Nunca promove BAD.",
+ )
+ parser.add_argument("--no-promote", action="store_true")
+
+ # Output
+ parser.add_argument(
+ "--candidate-root",
+ default="calibration/flatfield_candidates",
+ )
+ parser.add_argument(
+ "--active-npz",
+ default="calibration/flatfield_maps_v1.npz",
+ )
+ parser.add_argument(
+ "--active-json",
+ default="calibration/flatfield_maps_v1.json",
+ )
+
+ # UI
+ parser.add_argument("--panel-width", type=int, default=640)
+ parser.add_argument("--panel-height", type=int, default=400)
+
+ parser.add_argument("--notes", default="")
+
+ args = parser.parse_args()
+
+ if args.frames < 10 or args.validation_frames < 10 or args.dark_frames < 10:
+ raise ValueError("Produção: use pelo menos 10 frames por estágio.")
+
+ if not (0.001 <= args.sigma_frac <= 0.20):
+ raise ValueError("--sigma-frac fora da faixa segura [0.001, 0.20].")
+
+ thresholds = dict(DEFAULT_THRESHOLDS)
+
+ sid = session_id()
+ candidate_dir = Path(args.candidate_root) / sid
+ preview_dir = candidate_dir / "previews"
+ candidate_npz = candidate_dir / "flatfield_maps.npz"
+ candidate_report_path = candidate_dir / "report.json"
+
+ ensure_dir(candidate_dir)
+ ensure_dir(preview_dir)
+
+ module_params = load_json(args.module_params)
+
+ # --------------------------------------------------------
+ # Descoberta
+ # --------------------------------------------------------
+
+ dev_info = select_device_info(args.mx_id)
+ mx_id = device_id_from_info(dev_info)
+
+ features, usb_speed = discover_features(dev_info)
+
+ specs = validate_and_build_specs(
+ features=features,
+ rgb_bayer_arg=args.rgb_bayer,
+ module_params=module_params,
+ )
+
+ rgb_spec = specs["rgb"]
+
+ print("=" * 82)
+ print("FLAT-FIELD CALIBRATION - PRODUCTION")
+ print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}")
+ print(f"MX ID : {mx_id}")
+ print(f"USB : {usb_speed}")
+ print("-" * 82)
+
+ for role in ROLES:
+ s = specs[role]
+ extra = f" | Bayer={s.bayer_pattern}" if s.bayer_pattern else ""
+ print(
+ f"{s.socket_name} -> {role.upper():3s} | {s.sensor_name:8s} | "
+ f"{s.width}x{s.height} | {s.resolution_enum}{extra}"
+ )
+
+ print("=" * 82)
+
+ # --------------------------------------------------------
+ # Pipeline
+ # --------------------------------------------------------
+
+ pipeline = build_pipeline(
+ specs,
+ fps=args.fps,
+ anti_banding=args.anti_banding,
+ )
+
+ device = dai.Device(pipeline, dev_info)
+
+ cv2.namedWindow(
+ "Flat Field Calibration - Production",
+ cv2.WINDOW_NORMAL,
+ )
+
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ "status": "running",
+ "promoted": False,
+ "depthai_version": getattr(dai, "__version__", "unknown"),
+ "device": {
+ "mx_id": mx_id,
+ "usb_speed": usb_speed,
+ },
+ "hardware_inventory": features,
+ "resolved_setup": {
+ role: asdict(spec)
+ for role, spec in specs.items()
+ },
+ "module_params_path": args.module_params,
+ "rgb_processing": None,
+ "capture": {
+ "fps": args.fps,
+ "anti_banding": args.anti_banding,
+ "frames": args.frames,
+ "dark_frames": args.dark_frames,
+ "validation_frames": args.validation_frames,
+ "discard_frames": args.discard_frames,
+ },
+ "policy": {
+ "skip_dark": bool(args.skip_dark),
+ "allow_no_dark_promote": bool(args.allow_no_dark_promote),
+ "allow_warning_preflight": bool(args.allow_warning_preflight),
+ "force_preflight": bool(args.force_preflight),
+ "promote_warning": bool(args.promote_warning),
+ "no_promote": bool(args.no_promote),
+ },
+ "thresholds": thresholds,
+ "notes": args.notes,
+ "stages": {},
+ "outputs": {
+ "candidate_dir": str(candidate_dir),
+ "candidate_npz": str(candidate_npz),
+ "candidate_report": str(candidate_report_path),
+ "active_npz": args.active_npz,
+ "active_json": args.active_json,
+ },
+ "suggested_module_params_patch": suggested_flatfield_config(args.active_npz),
+ }
+
+ try:
+ ctx = AcquisitionContext(
+ device=device,
+ specs=specs,
+ thresholds=thresholds,
+ panel_width=args.panel_width,
+ panel_height=args.panel_height,
+ rgb_bayer=rgb_spec.bayer_pattern,
+ )
+
+ # ----------------------------------------------------
+ # Core de processamento da própria aplicação
+ # ----------------------------------------------------
+
+ core = create_processing_core(specs, args.module_params)
+
+ report["rgb_processing"] = {
+ "mode": str(core.rgb_processing_config.get("mode")),
+ "demosaic_algorithm": str(core.rgb_processing_config.get("demosaic_algorithm")),
+ "enhancement_forced_off": True,
+ "flatfield_forced_off": True,
+ "radiometric_normalization_forced_off": True,
+ }
+
+ print(
+ "[CORE] rgb_processing.mode="
+ f"{report['rgb_processing']['mode']}"
+ )
+
+ # ----------------------------------------------------
+ # WHITE PREFLIGHT + lock
+ # ----------------------------------------------------
+
+ preflight_report, locked = wait_white_preflight(
+ ctx,
+ args,
+ thresholds,
+ )
+
+ report["white_preflight"] = preflight_report
+ report["locked_controls"] = {
+ role: asdict(ctrl)
+ for role, ctrl in locked.items()
+ }
+
+ # ----------------------------------------------------
+ # WHITE A
+ # ----------------------------------------------------
+
+ wait_stage_prompt(
+ ctx,
+ title="ETAPA 1/3 - WHITE A",
+ instructions=[
+ "Mantenha o MESMO alvo do preflight.",
+ "Não mova módulo, lente ou iluminação.",
+ "Esta captura CONSTRÓI o flat.",
+ "EXP/ISO já estão travados.",
+ ],
+ )
+
+ raw_white_a, white_a_capture_report = capture_stage(
+ ctx=ctx,
+ stage_name="white_A",
+ frames=args.frames,
+ discard_frames=args.discard_frames,
+ locked=locked,
+ thresholds=thresholds,
+ )
+
+ report["stages"]["white_A"] = white_a_capture_report
+
+ # ----------------------------------------------------
+ # DARK
+ # ----------------------------------------------------
+
+ raw_dark = None
+
+ if not args.skip_dark:
+ wait_stage_prompt(
+ ctx,
+ title="ETAPA 2/3 - DARK",
+ instructions=[
+ "TAMPE COMPLETAMENTE as três lentes.",
+ "Não use fundo preto distante.",
+ "Não altere iluminação nem controles.",
+ "EXP/ISO permanecem idênticos ao WHITE.",
+ ],
+ )
+
+ raw_dark, dark_capture_report = capture_stage(
+ ctx=ctx,
+ stage_name="dark",
+ frames=args.dark_frames,
+ discard_frames=args.discard_frames,
+ locked=locked,
+ thresholds=thresholds,
+ )
+
+ report["stages"]["dark"] = dark_capture_report
+ else:
+ report["stages"]["dark"] = {
+ "skipped": True,
+ }
+
+ # ----------------------------------------------------
+ # WHITE B / acceptance independente
+ # ----------------------------------------------------
+
+ wait_stage_prompt(
+ ctx,
+ title="ETAPA 3/3 - WHITE B / VALIDATION",
+ instructions=[
+ "DESTAMPE as lentes.",
+ "Volte exatamente ao mesmo alvo WHITE.",
+ "Esta captura NÃO participa do cálculo do flat.",
+ "Ela mede se a correção funciona de verdade.",
+ ],
+ )
+
+ raw_white_b, white_b_capture_report = capture_stage(
+ ctx=ctx,
+ stage_name="white_B_validation",
+ frames=args.validation_frames,
+ discard_frames=args.discard_frames,
+ locked=locked,
+ thresholds=thresholds,
+ )
+
+ report["stages"]["white_B_validation"] = white_b_capture_report
+
+ # ----------------------------------------------------
+ # Decode das referências pelo RawProcessorCore
+ # ----------------------------------------------------
+
+ processing_screen = np.zeros((720, 1280, 3), dtype=np.uint8)
+ overlay_hud(
+ processing_screen,
+ [
+ "PROCESSANDO FLAT-FIELD...",
+ "",
+ f"RGB sensor: {rgb_spec.sensor_name}",
+ f"RGB Bayer : {rgb_spec.bayer_pattern}",
+ f"RGB mode : {report['rgb_processing']['mode']}",
+ "",
+ "Decodificando referências pelo RawProcessorCore.",
+ "Gerando shading field, gain maps e acceptance test.",
+ ],
+ x=40,
+ y=80,
+ font_scale=0.72,
+ line_step=31,
+ )
+ cv2.imshow("Flat Field Calibration - Production", processing_screen)
+ cv2.waitKey(1)
+
+ white_a = decode_reference(core, specs, raw_white_a)
+ white_b = decode_reference(core, specs, raw_white_b)
+ dark = decode_reference(core, specs, raw_dark) if raw_dark is not None else None
+
+ # Garante shapes invariantes por canal.
+ for ch in CHANNELS:
+ if white_a[ch].shape != white_b[ch].shape:
+ raise RuntimeError(
+ f"{ch}: WHITE_A shape {white_a[ch].shape} != "
+ f"WHITE_B shape {white_b[ch].shape}"
+ )
+
+ if dark is not None and white_a[ch].shape != dark[ch].shape:
+ raise RuntimeError(
+ f"{ch}: WHITE_A shape {white_a[ch].shape} != "
+ f"DARK shape {dark[ch].shape}"
+ )
+
+ report["decoded_channel_shapes"] = {
+ ch: list(white_a[ch].shape)
+ for ch in CHANNELS
+ }
+
+ # ----------------------------------------------------
+ # Dark QA
+ # ----------------------------------------------------
+
+ dark_quality = None
+
+ if dark is not None:
+ dark_quality = evaluate_dark_channels(dark, thresholds)
+ report["dark_quality"] = dark_quality
+
+ # ----------------------------------------------------
+ # Gain
+ # ----------------------------------------------------
+
+ built = build_gain_maps(
+ white_a=white_a,
+ dark=dark,
+ epsilon=args.epsilon,
+ sigma_frac=args.sigma_frac,
+ min_gain=args.min_gain,
+ max_gain=args.max_gain,
+ )
+
+ gain_maps = built["gain_maps"]
+ flat_norm = built["flat_norm"]
+ white_signal_a = built["white_signal"]
+
+ report["gain_processing"] = {
+ "sigma_frac": args.sigma_frac,
+ "min_gain": args.min_gain,
+ "max_gain": args.max_gain,
+ "epsilon": args.epsilon,
+ "channels": built["processing_meta"],
+ }
+
+ # ----------------------------------------------------
+ # Acceptance
+ # ----------------------------------------------------
+
+ acceptance = acceptance_test(
+ gain_maps=gain_maps,
+ white_b=white_b,
+ dark=dark,
+ th=thresholds,
+ )
+
+ report["acceptance"] = acceptance
+
+ # Dark BAD também torna calibração BAD.
+ final_status = acceptance["status"]
+
+ if dark_quality is not None:
+ final_status = merge_status(final_status, dark_quality["status"])
+
+ if args.skip_dark and not args.allow_no_dark_promote:
+ final_status = merge_status(final_status, "warning")
+ report.setdefault("reasons", []).append(
+ "dark_skipped:promotion_blocked_without_allow_no_dark_promote"
+ )
+
+ report["status"] = final_status
+ report["finished_at"] = now_str()
+
+ # ----------------------------------------------------
+ # Candidate
+ # ----------------------------------------------------
+
+ save_candidate_npz(
+ path=candidate_npz,
+ gain_maps=gain_maps,
+ flat_norm=flat_norm,
+ white_a=white_a,
+ white_b=white_b,
+ dark=dark,
+ raw_white_a=raw_white_a,
+ raw_white_b=raw_white_b,
+ raw_dark=raw_dark,
+ )
+
+ save_previews(
+ out_dir=preview_dir,
+ gain_maps=gain_maps,
+ white_signal_a=white_signal_a,
+ white_b=white_b,
+ dark=dark,
+ )
+
+ report["candidate_sha256"] = sha256_file(candidate_npz)
+
+ # Decide promoção ANTES de salvar report final.
+ promote_allowed = False
+
+ if final_status == "good":
+ promote_allowed = True
+
+ elif final_status == "warning" and args.promote_warning:
+ promote_allowed = True
+
+ if args.skip_dark and not args.allow_no_dark_promote:
+ promote_allowed = False
+
+ if args.no_promote:
+ promote_allowed = False
+
+ report["promotion_decision"] = {
+ "allowed_by_quality": bool(promote_allowed),
+ "final_status": final_status,
+ "no_promote": bool(args.no_promote),
+ }
+
+ save_json(candidate_report_path, report)
+
+ # Marca candidate.
+ marker = candidate_dir / (
+ "PASS.txt" if promote_allowed else "FAIL.txt"
+ )
+
+ marker.write_text(
+ (
+ f"status={final_status}\n"
+ f"promote_allowed={promote_allowed}\n"
+ f"created_at={report['created_at']}\n"
+ f"finished_at={report['finished_at']}\n"
+ ),
+ encoding="utf-8",
+ )
+
+ # ----------------------------------------------------
+ # Promoção atômica
+ # ----------------------------------------------------
+
+ if promote_allowed:
+ atomic_promote(
+ candidate_npz=candidate_npz,
+ candidate_report=candidate_report_path,
+ active_npz=Path(args.active_npz),
+ active_json=Path(args.active_json),
+ )
+
+ report["promoted"] = True
+ report["promoted_at"] = now_str()
+
+ # Atualiza report candidate e active JSON com estado promovido.
+ save_json(candidate_report_path, report)
+ save_json(args.active_json, report)
+
+ print("")
+ print("=" * 82)
+ print("[PASS] FLAT-FIELD APROVADO E PROMOVIDO")
+ print(f"[ACTIVE] NPZ : {args.active_npz}")
+ print(f"[ACTIVE] JSON: {args.active_json}")
+ print(f"[CANDIDATE] {candidate_dir}")
+ print("=" * 82)
+
+ else:
+ print("")
+ print("=" * 82)
+ print("[FAIL/WARN] CANDIDATE NÃO FOI PROMOVIDO")
+ print(f"[STATUS] {final_status.upper()}")
+ print(f"[CANDIDATE] {candidate_dir}")
+ print("[SAFE] Calibração ativa anterior foi preservada.")
+ print("=" * 82)
+
+ # ----------------------------------------------------
+ # Resumo visual final
+ # ----------------------------------------------------
+
+ final_panel = np.zeros((720, 1280, 3), dtype=np.uint8)
+
+ lines = [
+ "FLAT-FIELD - RESULTADO FINAL",
+ "",
+ f"STATUS: {final_status.upper()}",
+ f"PROMOVIDO: {'SIM' if report.get('promoted') else 'NAO'}",
+ "",
+ f"RGB: {rgb_spec.sensor_name} {rgb_spec.width}x{rgb_spec.height}",
+ f"Bayer: {rgb_spec.bayer_pattern}",
+ f"RGB processing: {report['rgb_processing']['mode']}",
+ "",
+ ]
+
+ for ch in CHANNELS:
+ a = acceptance["channels"][ch]
+ lines.append(
+ f"{ch}: {a['status'].upper()} | "
+ f"gridCV {a['grid_cv_before']:.4f}->{a['grid_cv_after']:.4f} | "
+ f"melhora={a['improvement']*100:.1f}%"
+ )
+
+ lines.extend([
+ "",
+ f"Candidate: {candidate_dir}",
+ "",
+ "Qualquer tecla fecha.",
+ ])
+
+ overlay_hud(
+ final_panel,
+ lines,
+ x=34,
+ y=58,
+ font_scale=0.62,
+ line_step=27,
+ )
+
+ cv2.imshow("Flat Field Calibration - Production", final_panel)
+ cv2.waitKey(0)
+
+ except KeyboardInterrupt as exc:
+ report["status"] = "cancelled"
+ report["finished_at"] = now_str()
+ report["error"] = str(exc)
+
+ try:
+ save_json(candidate_report_path, report)
+ except Exception:
+ pass
+
+ print(f"[CANCELADO] {exc}")
+
+ except Exception as exc:
+ report["status"] = "error"
+ report["finished_at"] = now_str()
+ report["error"] = f"{type(exc).__name__}: {exc}"
+
+ try:
+ save_json(candidate_report_path, report)
+ except Exception:
+ pass
+
+ print("")
+ print("=" * 82)
+ print("[ERRO] CALIBRAÇÃO ABORTADA - NADA FOI PROMOVIDO")
+ print(f"{type(exc).__name__}: {exc}")
+ print(f"Report parcial: {candidate_report_path}")
+ print("=" * 82)
+
+ raise
+
+ finally:
+ try:
+ device.close()
+ except Exception:
+ pass
+
+ cv2.destroyAllWindows()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_config_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_config_tool.py
new file mode 100644
index 000000000..380fe5d46
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/utils/_4_radiometric_config_tool.py
@@ -0,0 +1,5357 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+radiometric_calibration_production.py
+=====================================
+
+Calibrador FINAL de produção para a NORMALIZAÇÃO RADIOMÉTRICA do módulo
+multiespectral OAK-FFC-3P.
+
+Topologia oficial
+-----------------
+ CAM_A = RGB = OV9782 1280x800
+ OU
+ AR0234 1920x1200
+
+ CAM_B = RE = OV9282 1280x800
+ CAM_C = NIR = OV9282 1280x800
+
+O que esta ferramenta calibra
+-----------------------------
+Esta ferramenta calibra o bloco que o runtime chama de:
+
+ radiometric_normalization
+
+Contrato atual:
+
+ method = oak_ae_frame_controls_v1
+ factor_model = exposure_time_us_x_iso
+ iso_base = 100
+ scale = reference_factor / actual_factor
+
+onde:
+
+ actual_factor =
+ exposure_time_us * (sensitivity_iso / iso_base)
+
+Ela NÃO configura automaticamente o controlador dinâmico de exposição
+`radiometric_config` e NÃO habilita `patch_normalization`.
+
+Esses dois blocos são políticas diferentes:
+ - radiometric_config:
+ controlador ativo de exposição em campo
+ - patch_normalization:
+ normalização pós-fusão por cartões/patches
+
+Por segurança de produto, ambos permanecem recomendados como DISABLED até
+uma etapa específica de validação de campo decidir o contrário.
+
+Por que fazer uma calibração real?
+----------------------------------
+Trocar OV9782 -> AR0234 muda sensibilidade, resposta e faixa operacional.
+Copiar reference_controls antigos pode deslocar a escala dos canais que entra
+no modelo e alterar relações espectrais.
+
+Esta ferramenta mede, por sensor:
+ 1. estabilidade temporal;
+ 2. resposta RAW vs exposição;
+ 3. linearidade da hipótese exposição x ISO;
+ 4. intercepto/offset efetivo;
+ 5. erro da normalização ao longo de um sweep;
+ 6. ponto de referência para um alvo radiométrico definido;
+ 7. validação independente no ponto calculado.
+
+Domínio de medição
+------------------
+A medição é feita no RAW nativo da câmera, antes de:
+ - demosaic;
+ - enhancement;
+ - homografia;
+ - flat gain;
+ - resize;
+ - patch normalization.
+
+Isso casa com o propósito do bloco runtime:
+ apply_stage = after_dark_before_flat_gain
+
+IMPORTANTE:
+-----------
+A referência absoluta depende do contrato físico da bancada.
+
+Para repetir calibrações entre módulos, mantenha fixos:
+ - painel difuso/cinza usado;
+ - distância;
+ - geometria;
+ - fonte de luz;
+ - temperatura/tempo de aquecimento;
+ - intensidade da fonte.
+
+Se a bancada mudar, a referência radiométrica muda junto.
+
+Uso recomendado
+---------------
+1. Coloque um painel cinza/difuso uniforme preenchendo o FOV.
+2. Use iluminação de bancada estável e reproduzível.
+3. Rode:
+
+ python radiometric_calibration_production.py ^
+ --rig-id RIG_MS_01 ^
+ --target-id GRAY_PANEL_01 ^
+ --illumination-id LED_CAL_01
+
+4. Espere o preflight ficar GOOD.
+5. ENTER inicia a calibração automática.
+6. Não mexa mais em câmera, painel ou luz.
+7. O script executa o sweep, regressão e validação.
+8. PASS promove o artefato.
+
+Saídas
+------
+calibration/radiometry_candidates//
+ report.json
+ sweep.csv
+ PASS.txt / FAIL.txt / CANCELLED.txt
+ response_rgb.png
+ response_re.png
+ response_nir.png
+ validation_rgb.png
+ validation_re.png
+ validation_nir.png
+
+Se PASS:
+ calibration/radiometry_calibration_v5.json
+
+O JSON ativo contém:
+ module_params_fragment.radiometric_normalization
+
+O assembler final continua responsável por montar module_params.json.
+
+Política fail-closed
+--------------------
+- BAD nunca promove.
+- WARNING só promove com --promote-warning.
+- FAIL/CANCEL preserva o ativo anterior.
+- sensor/resolução diferentes do produto abortam.
+- controles manuais divergentes abortam.
+- sweep insuficiente aborta.
+- o script nunca reativa silenciosamente o controlador de campo.
+
+Teclas
+------
+PRE-FLIGHT:
+ ENTER -> iniciar
+ Q/ESC -> cancelar
+
+RESULTADO:
+ qualquer tecla -> fechar
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import math
+import os
+import shutil
+import time
+from collections import deque
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Optional, Tuple, List, Any
+
+import cv2
+import depthai as dai
+import numpy as np
+
+
+# ============================================================
+# Contrato do produto
+# ============================================================
+
+ROLES = ("rgb", "re", "nir")
+
+PRODUCT_TOPOLOGY = {
+ "rgb": {
+ "socket": "CAM_A",
+ "allowed_sensors": ("OV9782", "AR0234"),
+ },
+ "re": {
+ "socket": "CAM_B",
+ "allowed_sensors": ("OV9282",),
+ },
+ "nir": {
+ "socket": "CAM_C",
+ "allowed_sensors": ("OV9282",),
+ },
+}
+
+RGB_SENSOR_MODES = {
+ "OV9782": {
+ "resolution_enum": "THE_800_P",
+ "width": 1280,
+ "height": 800,
+ },
+ "AR0234": {
+ "resolution_enum": "THE_1200_P",
+ "width": 1920,
+ "height": 1200,
+ },
+}
+
+MONO_SENSOR_MODES = {
+ "OV9282": {
+ "resolution_enum": "THE_800_P",
+ "width": 1280,
+ "height": 800,
+ },
+}
+
+SCHEMA = "multispec_radiometric_calibration_v5"
+CALIBRATION_DOMAIN = "native_sensor_raw_linear"
+NORMALIZATION_METHOD = "oak_ae_frame_controls_v1"
+FACTOR_MODEL = "exposure_time_us_x_iso"
+ISO_BASE = 100.0
+
+
+# ============================================================
+# QA de produção
+# ============================================================
+
+DEFAULT_QA = {
+ # Preflight em painel cinza
+ "preflight_p50_min": 0.12,
+ "preflight_p50_max": 0.78,
+ "preflight_sat_warning_pct": 0.10,
+ "preflight_sat_bad_pct": 0.50,
+ "preflight_dark_warning_pct": 2.0,
+ "preflight_dark_bad_pct": 8.0,
+ "preflight_temporal_cv_warning": 0.010,
+ "preflight_temporal_cv_bad": 0.025,
+
+ # Sweep
+ "min_sweep_levels": 6,
+ "min_valid_levels": 5,
+ "valid_p50_min": 0.035,
+ "valid_p50_max": 0.88,
+ "valid_sat_max_pct": 0.20,
+ "level_temporal_cv_warning": 0.010,
+ "level_temporal_cv_bad": 0.025,
+
+ # Regressão
+ "r2_warning": 0.995,
+ "r2_bad": 0.985,
+ "intercept_fraction_warning": 0.035,
+ "intercept_fraction_bad": 0.080,
+
+ # Validação do modelo de normalização
+ "norm_median_rel_error_warning": 0.030,
+ "norm_median_rel_error_bad": 0.060,
+ "norm_p95_rel_error_warning": 0.050,
+ "norm_p95_rel_error_bad": 0.090,
+
+ # Validação independente no reference point
+ "validation_target_rel_error_warning": 0.050,
+ "validation_target_rel_error_bad": 0.100,
+ "validation_temporal_cv_warning": 0.010,
+ "validation_temporal_cv_bad": 0.025,
+ "validation_sat_warning_pct": 0.10,
+ "validation_sat_bad_pct": 0.50,
+
+ # Diferença entre target real escolhido e faixa observada
+ "reference_extrapolation_margin": 0.03,
+
+ # Controles
+ "exposure_rel_tolerance": 0.012,
+ "exposure_abs_tolerance_us": 25,
+ "iso_tolerance": 5,
+
+ # Sync visual, não é requisito rígido da regressão
+ "sync_warning_ms": 40.0,
+}
+
+
+DEFAULT_SWEEP_FACTORS = (
+ 0.30,
+ 0.42,
+ 0.55,
+ 0.70,
+ 0.85,
+ 1.00,
+ 1.18,
+ 1.38,
+ 1.58,
+)
+
+
+# ============================================================
+# Helpers
+# ============================================================
+
+def now_str() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def session_stamp() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+
+
+def ensure_dir(path: str | Path):
+ Path(path).mkdir(parents=True, exist_ok=True)
+
+
+def save_json_atomic(path: str | Path, data: dict):
+ p = Path(path)
+ ensure_dir(p.parent)
+
+ tmp = p.with_suffix(p.suffix + ".tmp")
+
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.flush()
+ os.fsync(f.fileno())
+
+ os.replace(tmp, p)
+
+
+def load_json(path: str | Path) -> dict:
+ p = Path(path)
+
+ if not p.is_file():
+ return {}
+
+ with p.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def sha256_file(path: str | Path) -> str:
+ h = hashlib.sha256()
+
+ with open(path, "rb") as f:
+ while True:
+ chunk = f.read(1024 * 1024)
+ if not chunk:
+ break
+ h.update(chunk)
+
+ return h.hexdigest()
+
+
+def safe_int(v, default=None):
+ try:
+ if v is None:
+ return default
+ return int(v)
+ except Exception:
+ return default
+
+
+def safe_float(v, default=None):
+ try:
+ if v is None:
+ return default
+ return float(v)
+ except Exception:
+ return default
+
+
+def clamp(v, lo, hi):
+ return max(lo, min(hi, v))
+
+
+def status_rank(s: str) -> int:
+ return {
+ "good": 0,
+ "warning": 1,
+ "bad": 2,
+ }.get(str(s).lower(), 2)
+
+
+def merge_status(a: str, b: str) -> str:
+ return a if status_rank(a) >= status_rank(b) else b
+
+
+def overlay_hud(
+ img,
+ lines,
+ x=12,
+ y=24,
+ font_scale=0.50,
+ line_step=20,
+ color=(255, 255, 255),
+):
+ yy = int(y)
+ h = img.shape[0]
+
+ for line in lines:
+ if yy > h - 6:
+ break
+
+ text = str(line)
+
+ cv2.putText(
+ img,
+ text,
+ (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale,
+ (0, 0, 0),
+ 3,
+ cv2.LINE_AA,
+ )
+
+ cv2.putText(
+ img,
+ text,
+ (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale,
+ color,
+ 1,
+ cv2.LINE_AA,
+ )
+
+ yy += int(line_step)
+
+
+def socket_name(socket) -> str:
+ name = getattr(socket, "name", None)
+
+ if name:
+ return str(name)
+
+ text = str(socket)
+
+ for candidate in (
+ "CAM_A",
+ "CAM_B",
+ "CAM_C",
+ "CAM_D",
+ ):
+ if candidate in text:
+ return candidate
+
+ return text
+
+
+def get_socket(name: str):
+ mapping = {
+ "CAM_A": dai.CameraBoardSocket.CAM_A,
+ "CAM_B": dai.CameraBoardSocket.CAM_B,
+ "CAM_C": dai.CameraBoardSocket.CAM_C,
+ }
+
+ key = str(name).upper()
+
+ if key not in mapping:
+ raise ValueError(
+ f"Socket inválido: {name}"
+ )
+
+ return mapping[key]
+
+
+def enum_if_exists(enum_cls, name: str):
+ return getattr(
+ enum_cls,
+ name,
+ None,
+ )
+
+
+def supported_type_strings(feature):
+ return [
+ str(x).upper()
+ for x in (
+ getattr(feature, "supportedTypes", [])
+ or []
+ )
+ ]
+
+
+def feature_is_color(feature) -> bool:
+ types = supported_type_strings(feature)
+ sensor = str(
+ getattr(feature, "sensorName", "")
+ or ""
+ ).upper()
+
+ if any("COLOR" in x for x in types):
+ return True
+
+ if any("MONO" in x for x in types):
+ return False
+
+ return sensor in {
+ "OV9782",
+ "AR0234",
+ }
+
+
+def feature_is_mono(feature) -> bool:
+ types = supported_type_strings(feature)
+
+ if any("MONO" in x for x in types):
+ return True
+
+ if any("COLOR" in x for x in types):
+ return False
+
+ return not feature_is_color(feature)
+
+
+def resize_panel(
+ img,
+ width,
+ height,
+):
+ return cv2.resize(
+ img,
+ (
+ int(width),
+ int(height),
+ ),
+ interpolation=cv2.INTER_AREA,
+ )
+
+
+def robust_median(arr: np.ndarray) -> float:
+ f = np.asarray(
+ arr,
+ dtype=np.float32,
+ )
+
+ f = f[
+ np.isfinite(f)
+ ]
+
+ if f.size == 0:
+ return 0.0
+
+ return float(
+ np.median(f)
+ )
+
+
+def temporal_cv(values) -> float:
+ arr = np.asarray(
+ list(values),
+ dtype=np.float64,
+ )
+
+ if arr.size < 3:
+ return float("inf")
+
+ mean = float(
+ np.mean(arr)
+ )
+
+ if abs(mean) < 1e-12:
+ return float("inf")
+
+ return float(
+ np.std(arr)
+ / abs(mean)
+ )
+
+
+def central_roi(
+ img01: np.ndarray,
+ frac: float,
+):
+ h, w = img01.shape[:2]
+
+ frac = clamp(
+ float(frac),
+ 0.05,
+ 1.0,
+ )
+
+ rw = max(
+ 8,
+ int(round(w * frac)),
+ )
+
+ rh = max(
+ 8,
+ int(round(h * frac)),
+ )
+
+ x0 = (w - rw) // 2
+ y0 = (h - rh) // 2
+
+ return img01[
+ y0:y0 + rh,
+ x0:x0 + rw,
+ ], (
+ x0,
+ y0,
+ x0 + rw,
+ y0 + rh,
+ )
+
+
+def image_stats(
+ img01: np.ndarray,
+ roi_frac: float,
+):
+ roi, rect = central_roi(
+ img01,
+ roi_frac,
+ )
+
+ a = np.asarray(
+ roi,
+ dtype=np.float32,
+ )
+
+ f = a[
+ np.isfinite(a)
+ ]
+
+ if f.size == 0:
+ return {
+ "valid": False,
+ "roi_rect": list(rect),
+ }
+
+ return {
+ "valid": True,
+ "roi_rect": list(rect),
+ "pixels": int(f.size),
+ "mean": float(
+ np.mean(f)
+ ),
+ "std": float(
+ np.std(f)
+ ),
+ "p01": float(
+ np.percentile(f, 1)
+ ),
+ "p05": float(
+ np.percentile(f, 5)
+ ),
+ "p50": float(
+ np.percentile(f, 50)
+ ),
+ "p95": float(
+ np.percentile(f, 95)
+ ),
+ "p99": float(
+ np.percentile(f, 99)
+ ),
+ "sat_pct": float(
+ (f >= 0.995).mean()
+ * 100.0
+ ),
+ "dark_pct": float(
+ (f <= 0.010).mean()
+ * 100.0
+ ),
+ }
+
+
+def gray_preview(
+ img01,
+ roi_frac,
+):
+ vis = np.clip(
+ img01 * 255.0,
+ 0,
+ 255,
+ ).astype(np.uint8)
+
+ bgr = cv2.cvtColor(
+ vis,
+ cv2.COLOR_GRAY2BGR,
+ )
+
+ _, rect = central_roi(
+ img01,
+ roi_frac,
+ )
+
+ x0, y0, x1, y1 = rect
+
+ cv2.rectangle(
+ bgr,
+ (x0, y0),
+ (x1, y1),
+ (0, 255, 255),
+ 2,
+ )
+
+ return bgr
+
+
+# ============================================================
+# RAW decode
+# ============================================================
+
+def unpack_raw10(
+ data,
+ width: int,
+ height: int,
+ stride: Optional[int] = None,
+) -> np.ndarray:
+ width = int(width)
+ height = int(height)
+
+ if width % 4 != 0:
+ raise ValueError(
+ f"RAW10 width não múltiplo de 4: {width}"
+ )
+
+ arr = np.asarray(
+ data,
+ dtype=np.uint8,
+ ).reshape(-1)
+
+ row_bytes = (
+ width // 4
+ ) * 5
+
+ if stride is None or int(stride) < row_bytes:
+ stride = row_bytes
+
+ stride = int(stride)
+ expected = stride * height
+
+ if arr.size < expected:
+ raise RuntimeError(
+ f"RAW10 curto: {arr.size} < {expected}"
+ )
+
+ rows = arr[
+ :expected
+ ].reshape(
+ height,
+ stride,
+ )
+
+ payload = rows[
+ :,
+ :row_bytes,
+ ]
+
+ g = payload.reshape(
+ height,
+ width // 4,
+ 5,
+ )
+
+ out = np.empty(
+ (
+ height,
+ width // 4,
+ 4,
+ ),
+ dtype=np.uint16,
+ )
+
+ out[:, :, 0] = (
+ g[:, :, 0].astype(np.uint16) << 2
+ ) | (
+ g[:, :, 4] & 0x03
+ )
+
+ out[:, :, 1] = (
+ g[:, :, 1].astype(np.uint16) << 2
+ ) | (
+ (g[:, :, 4] >> 2)
+ & 0x03
+ )
+
+ out[:, :, 2] = (
+ g[:, :, 2].astype(np.uint16) << 2
+ ) | (
+ (g[:, :, 4] >> 4)
+ & 0x03
+ )
+
+ out[:, :, 3] = (
+ g[:, :, 3].astype(np.uint16) << 2
+ ) | (
+ (g[:, :, 4] >> 6)
+ & 0x03
+ )
+
+ return np.ascontiguousarray(
+ out.reshape(
+ height,
+ width,
+ )
+ )
+
+
+def unpack_raw8(
+ data,
+ width: int,
+ height: int,
+ stride: Optional[int] = None,
+) -> np.ndarray:
+ arr = np.asarray(
+ data,
+ dtype=np.uint8,
+ ).reshape(-1)
+
+ width = int(width)
+ height = int(height)
+
+ if stride is None or int(stride) < width:
+ stride = width
+
+ stride = int(stride)
+ expected = stride * height
+
+ if arr.size < expected:
+ raise RuntimeError(
+ f"RAW8 curto: {arr.size} < {expected}"
+ )
+
+ rows = arr[
+ :expected
+ ].reshape(
+ height,
+ stride,
+ )
+
+ return np.ascontiguousarray(
+ rows[:, :width]
+ )
+
+
+def decode_raw_packet(
+ packet,
+ spec,
+):
+ width = int(
+ packet.getWidth()
+ )
+
+ height = int(
+ packet.getHeight()
+ )
+
+ if (
+ width,
+ height,
+ ) != (
+ spec.width,
+ spec.height,
+ ):
+ raise RuntimeError(
+ f"{spec.role}: frame "
+ f"{width}x{height} != "
+ f"{spec.width}x{spec.height}"
+ )
+
+ stride = None
+
+ try:
+ stride = int(
+ packet.getStride()
+ )
+ except Exception:
+ pass
+
+ raw_type = str(
+ packet.getType()
+ ).upper()
+
+ if "RAW10" in raw_type:
+ raw = unpack_raw10(
+ packet.getData(),
+ width,
+ height,
+ stride,
+ )
+
+ img01 = (
+ raw.astype(np.float32)
+ / 1023.0
+ )
+
+ bit_depth = 10
+
+ elif (
+ "RAW8" in raw_type
+ or "GRAY8" in raw_type
+ ):
+ raw = unpack_raw8(
+ packet.getData(),
+ width,
+ height,
+ stride,
+ )
+
+ img01 = (
+ raw.astype(np.float32)
+ / 255.0
+ )
+
+ bit_depth = 8
+
+ else:
+ # Último fallback para MonoCamera.out-like payload,
+ # caso a API não nomeie RAW8 claramente.
+ data = np.asarray(
+ packet.getData(),
+ dtype=np.uint8,
+ ).reshape(-1)
+
+ if data.size >= width * height:
+ raw = unpack_raw8(
+ data,
+ width,
+ height,
+ stride,
+ )
+
+ img01 = (
+ raw.astype(np.float32)
+ / 255.0
+ )
+
+ bit_depth = 8
+ else:
+ raise RuntimeError(
+ f"{spec.role}: tipo RAW não suportado: "
+ f"{raw_type}"
+ )
+
+ return {
+ "image01": np.clip(
+ img01,
+ 0.0,
+ 1.0,
+ ),
+ "raw_type": raw_type,
+ "bit_depth": bit_depth,
+ "stride": stride,
+ }
+
+
+# ============================================================
+# Hardware discovery
+# ============================================================
+
+@dataclass
+class CameraSpec:
+ role: str
+ socket_name: str
+ sensor_name: str
+ width: int
+ height: int
+ resolution_name: str
+ stream_name: str
+ control_name: str
+ is_color: bool
+
+
+def discover_cameras(
+ mx_id: Optional[str],
+):
+ device_info = (
+ dai.DeviceInfo(mx_id)
+ if mx_id
+ else None
+ )
+
+ ctx = (
+ dai.Device(device_info)
+ if device_info is not None
+ else dai.Device()
+ )
+
+ with ctx as device:
+ actual_mx = None
+
+ for method_name in (
+ "getMxId",
+ "getDeviceId",
+ ):
+ fn = getattr(
+ device,
+ method_name,
+ None,
+ )
+
+ if callable(fn):
+ try:
+ value = fn()
+
+ if value:
+ actual_mx = str(
+ value
+ )
+ break
+
+ except Exception:
+ pass
+
+ usb_speed = None
+
+ try:
+ usb_speed = str(
+ device.getUsbSpeed()
+ )
+ except Exception:
+ pass
+
+ rows = []
+
+ for feature in (
+ device
+ .getConnectedCameraFeatures()
+ ):
+ rows.append({
+ "socket_name": socket_name(
+ feature.socket
+ ),
+ "sensor_name": str(
+ feature.sensorName
+ or ""
+ ).upper(),
+ "width": int(
+ getattr(
+ feature,
+ "width",
+ 0,
+ ) or 0
+ ),
+ "height": int(
+ getattr(
+ feature,
+ "height",
+ 0,
+ ) or 0
+ ),
+ "supported_types": (
+ supported_type_strings(
+ feature
+ )
+ ),
+ "is_color": (
+ feature_is_color(
+ feature
+ )
+ ),
+ "is_mono": (
+ feature_is_mono(
+ feature
+ )
+ ),
+ })
+
+ return rows, actual_mx, usb_speed
+
+
+def validate_product_topology(
+ camera_rows,
+):
+ by_socket = {
+ row["socket_name"]: row
+ for row in camera_rows
+ }
+
+ errors = []
+
+ for role in ROLES:
+ contract = (
+ PRODUCT_TOPOLOGY[role]
+ )
+
+ socket = contract[
+ "socket"
+ ]
+
+ row = by_socket.get(
+ socket
+ )
+
+ if row is None:
+ errors.append(
+ f"{role}: {socket} ausente"
+ )
+ continue
+
+ sensor = row[
+ "sensor_name"
+ ]
+
+ if sensor not in contract[
+ "allowed_sensors"
+ ]:
+ errors.append(
+ f"{role}: {socket} "
+ f"sensor={sensor}, "
+ f"esperado="
+ f"{contract['allowed_sensors']}"
+ )
+
+ if (
+ role == "rgb"
+ and not row["is_color"]
+ ):
+ errors.append(
+ f"RGB {socket}/{sensor} "
+ "não anunciado COLOR"
+ )
+
+ if (
+ role in ("re", "nir")
+ and not row["is_mono"]
+ ):
+ errors.append(
+ f"{role.upper()} "
+ f"{socket}/{sensor} "
+ "não anunciado MONO"
+ )
+
+ if errors:
+ raise RuntimeError(
+ "Topologia inválida:\n - "
+ + "\n - ".join(errors)
+ )
+
+ return {
+ "valid": True,
+ "errors": [],
+ }
+
+
+def build_specs(
+ camera_rows,
+):
+ by_socket = {
+ row["socket_name"]: row
+ for row in camera_rows
+ }
+
+ specs = {}
+
+ for role in ROLES:
+ socket = (
+ PRODUCT_TOPOLOGY[role]
+ ["socket"]
+ )
+
+ row = by_socket[
+ socket
+ ]
+
+ sensor = row[
+ "sensor_name"
+ ]
+
+ cfg = (
+ RGB_SENSOR_MODES[sensor]
+ if role == "rgb"
+ else MONO_SENSOR_MODES[sensor]
+ )
+
+ if (
+ row["width"]
+ and row["height"]
+ and (
+ row["width"],
+ row["height"],
+ )
+ != (
+ cfg["width"],
+ cfg["height"],
+ )
+ ):
+ raise RuntimeError(
+ f"{socket}/{sensor}: "
+ f"anunciado "
+ f"{row['width']}x{row['height']}, "
+ f"esperado "
+ f"{cfg['width']}x{cfg['height']}"
+ )
+
+ specs[role] = CameraSpec(
+ role=role,
+ socket_name=socket,
+ sensor_name=sensor,
+ width=int(
+ cfg["width"]
+ ),
+ height=int(
+ cfg["height"]
+ ),
+ resolution_name=(
+ cfg["resolution_enum"]
+ ),
+ stream_name=(
+ f"rad_raw_{role}"
+ ),
+ control_name=(
+ f"rad_ctrl_{role}"
+ ),
+ is_color=(
+ role == "rgb"
+ ),
+ )
+
+ return specs
+
+
+def hardware_signature(
+ specs,
+):
+ return {
+ role: {
+ "socket": specs[
+ role
+ ].socket_name,
+ "sensor": specs[
+ role
+ ].sensor_name,
+ "size": [
+ specs[role].width,
+ specs[role].height,
+ ],
+ }
+ for role in ROLES
+ }
+
+
+# ============================================================
+# Pipeline
+# ============================================================
+
+def build_pipeline(
+ specs,
+ args,
+):
+ pipeline = dai.Pipeline()
+
+ for role in ROLES:
+ spec = specs[role]
+ socket = get_socket(
+ spec.socket_name
+ )
+
+ if spec.is_color:
+ cam = (
+ pipeline
+ .createColorCamera()
+ )
+
+ cam.setBoardSocket(
+ socket
+ )
+
+ enum_value = enum_if_exists(
+ dai.ColorCameraProperties
+ .SensorResolution,
+ spec.resolution_name,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ "DepthAI sem "
+ f"{spec.resolution_name}"
+ )
+
+ cam.setResolution(
+ enum_value
+ )
+
+ cam.setFps(
+ float(args.fps)
+ )
+
+ cam.setInterleaved(
+ False
+ )
+
+ else:
+ cam = (
+ pipeline
+ .createMonoCamera()
+ )
+
+ cam.setBoardSocket(
+ socket
+ )
+
+ enum_value = enum_if_exists(
+ dai.MonoCameraProperties
+ .SensorResolution,
+ spec.resolution_name,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ "DepthAI sem "
+ f"{spec.resolution_name}"
+ )
+
+ cam.setResolution(
+ enum_value
+ )
+
+ cam.setFps(
+ float(args.fps)
+ )
+
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+
+ if not hasattr(
+ cam,
+ "raw",
+ ):
+ raise RuntimeError(
+ f"{spec.socket_name}/"
+ f"{spec.sensor_name} "
+ "não expõe saída raw."
+ )
+
+ xout = (
+ pipeline
+ .createXLinkOut()
+ )
+
+ xout.setStreamName(
+ spec.stream_name
+ )
+
+ cam.raw.link(
+ xout.input
+ )
+
+ xin = (
+ pipeline
+ .createXLinkIn()
+ )
+
+ xin.setStreamName(
+ spec.control_name
+ )
+
+ if not hasattr(
+ cam,
+ "inputControl",
+ ):
+ raise RuntimeError(
+ f"{spec.socket_name} "
+ "sem inputControl"
+ )
+
+ xin.out.link(
+ cam.inputControl
+ )
+
+ return pipeline
+
+
+# ============================================================
+# Controles
+# ============================================================
+
+@dataclass
+class LockedControl:
+ role: str
+ exposure_time_us: int
+ sensitivity_iso: int
+ source: str
+
+
+def packet_controls(
+ packet,
+):
+ exposure_us = None
+ iso = None
+ sequence_num = None
+ timestamp_s = None
+
+ try:
+ exp = (
+ packet
+ .getExposureTime()
+ )
+
+ if hasattr(
+ exp,
+ "total_seconds",
+ ):
+ exposure_us = int(
+ round(
+ exp.total_seconds()
+ * 1_000_000
+ )
+ )
+ else:
+ exposure_us = int(exp)
+
+ except Exception:
+ pass
+
+ try:
+ iso = int(
+ packet.getSensitivity()
+ )
+ except Exception:
+ pass
+
+ try:
+ sequence_num = int(
+ packet.getSequenceNum()
+ )
+ except Exception:
+ pass
+
+ for method_name in (
+ "getTimestampDevice",
+ "getTimestamp",
+ ):
+ fn = getattr(
+ packet,
+ method_name,
+ None,
+ )
+
+ if callable(fn):
+ try:
+ ts = fn()
+
+ if hasattr(
+ ts,
+ "total_seconds",
+ ):
+ timestamp_s = float(
+ ts.total_seconds()
+ )
+ else:
+ timestamp_s = float(
+ ts
+ )
+
+ break
+
+ except Exception:
+ pass
+
+ return {
+ "exposure_time_us": (
+ exposure_us
+ ),
+ "sensitivity_iso": iso,
+ "sequence_num": (
+ sequence_num
+ ),
+ "timestamp_s": (
+ timestamp_s
+ ),
+ }
+
+
+def controls_match(
+ actual,
+ target: LockedControl,
+ qa,
+):
+ exp = safe_int(
+ actual.get(
+ "exposure_time_us"
+ ),
+ None,
+ )
+
+ iso = safe_int(
+ actual.get(
+ "sensitivity_iso"
+ ),
+ None,
+ )
+
+ if exp is None or iso is None:
+ return False
+
+ exp_tol = max(
+ int(
+ qa[
+ "exposure_abs_tolerance_us"
+ ]
+ ),
+ int(
+ round(
+ target.exposure_time_us
+ * qa[
+ "exposure_rel_tolerance"
+ ]
+ )
+ ),
+ )
+
+ return (
+ abs(
+ exp
+ - target.exposure_time_us
+ )
+ <= exp_tol
+ and abs(
+ iso
+ - target.sensitivity_iso
+ )
+ <= int(
+ qa[
+ "iso_tolerance"
+ ]
+ )
+ )
+
+
+def send_manual_control(
+ queue,
+ lock: LockedControl,
+):
+ ctrl = dai.CameraControl()
+
+ ctrl.setManualExposure(
+ int(
+ lock.exposure_time_us
+ ),
+ int(
+ lock.sensitivity_iso
+ ),
+ )
+
+ queue.send(
+ ctrl
+ )
+
+
+# ============================================================
+# Runtime
+# ============================================================
+
+class RadiometricRuntime:
+ def __init__(
+ self,
+ device,
+ specs,
+ args,
+ qa,
+ ):
+ self.device = device
+ self.specs = specs
+ self.args = args
+ self.qa = qa
+
+ self.out_q = {
+ role: (
+ device
+ .getOutputQueue(
+ name=spec.stream_name,
+ maxSize=3,
+ blocking=False,
+ )
+ )
+ for role, spec in (
+ specs.items()
+ )
+ }
+
+ self.ctrl_q = {
+ role: (
+ device
+ .getInputQueue(
+ spec.control_name
+ )
+ )
+ for role, spec in (
+ specs.items()
+ )
+ }
+
+ self.images = {
+ role: None
+ for role in ROLES
+ }
+
+ self.controls = {
+ role: {}
+ for role in ROLES
+ }
+
+ self.raw_meta = {
+ role: {}
+ for role in ROLES
+ }
+
+ self.last_seq = {
+ role: None
+ for role in ROLES
+ }
+
+ self.fps = {
+ role: 0.0
+ for role in ROLES
+ }
+
+ self._fps_count = {
+ role: 0
+ for role in ROLES
+ }
+
+ self._fps_t0 = {
+ role: time.time()
+ for role in ROLES
+ }
+
+ self.temporal_p50 = {
+ role: deque(
+ maxlen=20
+ )
+ for role in ROLES
+ }
+
+ def poll(self):
+ fresh = set()
+
+ for role in ROLES:
+ packet = (
+ self.out_q[role]
+ .tryGet()
+ )
+
+ if packet is None:
+ continue
+
+ ctrl = packet_controls(
+ packet
+ )
+
+ seq = ctrl.get(
+ "sequence_num"
+ )
+
+ if (
+ seq is not None
+ and seq
+ == self.last_seq[role]
+ ):
+ continue
+
+ decoded = decode_raw_packet(
+ packet,
+ self.specs[role],
+ )
+
+ self.images[role] = (
+ decoded["image01"]
+ )
+
+ self.controls[role] = (
+ ctrl
+ )
+
+ self.raw_meta[role] = {
+ "raw_type": decoded[
+ "raw_type"
+ ],
+ "bit_depth": decoded[
+ "bit_depth"
+ ],
+ "stride": decoded[
+ "stride"
+ ],
+ }
+
+ self.last_seq[role] = seq
+
+ st = image_stats(
+ self.images[role],
+ self.args.roi_frac,
+ )
+
+ if st.get("valid"):
+ self.temporal_p50[
+ role
+ ].append(
+ st["p50"]
+ )
+
+ fresh.add(role)
+
+ self._fps_count[
+ role
+ ] += 1
+
+ dt = (
+ time.time()
+ - self._fps_t0[role]
+ )
+
+ if dt >= 1.0:
+ self.fps[role] = (
+ self._fps_count[
+ role
+ ]
+ / dt
+ )
+
+ self._fps_count[
+ role
+ ] = 0
+
+ self._fps_t0[
+ role
+ ] = time.time()
+
+ return fresh
+
+ def wait_all(
+ self,
+ timeout=8.0,
+ ):
+ t0 = time.time()
+
+ while (
+ time.time() - t0
+ < timeout
+ ):
+ self.poll()
+
+ if all(
+ self.images[
+ role
+ ] is not None
+ for role in ROLES
+ ):
+ return
+
+ time.sleep(
+ 0.003
+ )
+
+ missing = [
+ role
+ for role in ROLES
+ if self.images[
+ role
+ ] is None
+ ]
+
+ raise TimeoutError(
+ f"Sem frames: {missing}"
+ )
+
+ def send_controls(
+ self,
+ controls_by_role,
+ ):
+ for role in ROLES:
+ send_manual_control(
+ self.ctrl_q[role],
+ controls_by_role[
+ role
+ ],
+ )
+
+ def verify_controls(
+ self,
+ controls_by_role,
+ settle_frames=5,
+ timeout=10.0,
+ ):
+ matched = {
+ role: 0
+ for role in ROLES
+ }
+
+ seen_seq = dict(
+ self.last_seq
+ )
+
+ t0 = time.time()
+
+ while (
+ time.time() - t0
+ < timeout
+ ):
+ fresh = self.poll()
+
+ for role in fresh:
+ seq = (
+ self.last_seq[
+ role
+ ]
+ )
+
+ if (
+ seq
+ == seen_seq.get(
+ role
+ )
+ ):
+ continue
+
+ seen_seq[
+ role
+ ] = seq
+
+ if controls_match(
+ self.controls[
+ role
+ ],
+ controls_by_role[
+ role
+ ],
+ self.qa,
+ ):
+ matched[
+ role
+ ] += 1
+ else:
+ matched[
+ role
+ ] = 0
+
+ if all(
+ matched[role]
+ >= int(
+ settle_frames
+ )
+ for role in ROLES
+ ):
+ return
+
+ time.sleep(
+ 0.002
+ )
+
+ raise RuntimeError(
+ "Controles manuais "
+ "não estabilizaram: "
+ f"{matched}"
+ )
+
+ def capture_level(
+ self,
+ frames_count,
+ controls_by_role,
+ ):
+ values = {
+ role: []
+ for role in ROLES
+ }
+
+ p95_values = {
+ role: []
+ for role in ROLES
+ }
+
+ sat_values = {
+ role: []
+ for role in ROLES
+ }
+
+ dark_values = {
+ role: []
+ for role in ROLES
+ }
+
+ latest_stats = {
+ role: None
+ for role in ROLES
+ }
+
+ seen = dict(
+ self.last_seq
+ )
+
+ t0 = time.time()
+
+ while any(
+ len(
+ values[role]
+ ) < int(
+ frames_count
+ )
+ for role in ROLES
+ ):
+ fresh = self.poll()
+
+ for role in fresh:
+ if (
+ len(values[role])
+ >= int(
+ frames_count
+ )
+ ):
+ continue
+
+ seq = self.last_seq[
+ role
+ ]
+
+ if seq == seen.get(
+ role
+ ):
+ continue
+
+ seen[role] = seq
+
+ if not controls_match(
+ self.controls[
+ role
+ ],
+ controls_by_role[
+ role
+ ],
+ self.qa,
+ ):
+ raise RuntimeError(
+ f"{role}: "
+ "controle mudou durante "
+ "captura"
+ )
+
+ st = image_stats(
+ self.images[
+ role
+ ],
+ self.args.roi_frac,
+ )
+
+ if not st.get(
+ "valid"
+ ):
+ continue
+
+ values[role].append(
+ float(
+ st["p50"]
+ )
+ )
+
+ p95_values[
+ role
+ ].append(
+ float(
+ st["p95"]
+ )
+ )
+
+ sat_values[
+ role
+ ].append(
+ float(
+ st["sat_pct"]
+ )
+ )
+
+ dark_values[
+ role
+ ].append(
+ float(
+ st["dark_pct"]
+ )
+ )
+
+ latest_stats[
+ role
+ ] = st
+
+ if (
+ time.time() - t0
+ > 12.0
+ ):
+ raise TimeoutError(
+ "Timeout capturando "
+ "nível do sweep."
+ )
+
+ time.sleep(
+ 0.001
+ )
+
+ out = {}
+
+ for role in ROLES:
+ arr = np.asarray(
+ values[role],
+ dtype=np.float64,
+ )
+
+ out[role] = {
+ "frames": int(
+ arr.size
+ ),
+ "p50": float(
+ np.median(
+ arr
+ )
+ ),
+ "p50_mean": float(
+ np.mean(
+ arr
+ )
+ ),
+ "p50_std": float(
+ np.std(
+ arr
+ )
+ ),
+ "p50_temporal_cv": float(
+ np.std(arr)
+ / max(
+ abs(
+ np.mean(
+ arr
+ )
+ ),
+ 1e-12,
+ )
+ ),
+ "p95": float(
+ np.median(
+ np.asarray(
+ p95_values[
+ role
+ ]
+ )
+ )
+ ),
+ "sat_pct": float(
+ np.median(
+ np.asarray(
+ sat_values[
+ role
+ ]
+ )
+ )
+ ),
+ "dark_pct": float(
+ np.median(
+ np.asarray(
+ dark_values[
+ role
+ ]
+ )
+ )
+ ),
+ "last_stats": (
+ latest_stats[
+ role
+ ]
+ ),
+ }
+
+ return out
+
+
+# ============================================================
+# Preflight
+# ============================================================
+
+def preflight_report(
+ runtime,
+ qa,
+):
+ result = {
+ "status": "good",
+ "roles": {},
+ "reasons": [],
+ }
+
+ for role in ROLES:
+ img = runtime.images[
+ role
+ ]
+
+ if img is None:
+ result[
+ "status"
+ ] = "bad"
+
+ result[
+ "roles"
+ ][role] = {
+ "status": "bad",
+ "reasons": [
+ "sem_frame"
+ ],
+ }
+
+ continue
+
+ st = image_stats(
+ img,
+ runtime.args.roi_frac,
+ )
+
+ cv = temporal_cv(
+ runtime.temporal_p50[
+ role
+ ]
+ )
+
+ status = "good"
+ reasons = []
+
+ p50 = st[
+ "p50"
+ ]
+
+ if (
+ p50
+ < qa[
+ "preflight_p50_min"
+ ]
+ ):
+ status = "bad"
+ reasons.append(
+ f"p50_low:{p50:.3f}"
+ )
+
+ elif (
+ p50
+ > qa[
+ "preflight_p50_max"
+ ]
+ ):
+ status = "bad"
+ reasons.append(
+ f"p50_high:{p50:.3f}"
+ )
+
+ if (
+ st["sat_pct"]
+ > qa[
+ "preflight_sat_bad_pct"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ "sat_bad:"
+ f"{st['sat_pct']:.3f}%"
+ )
+
+ elif (
+ st["sat_pct"]
+ > qa[
+ "preflight_sat_warning_pct"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ "sat_warn:"
+ f"{st['sat_pct']:.3f}%"
+ )
+
+ if (
+ st["dark_pct"]
+ > qa[
+ "preflight_dark_bad_pct"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ "dark_bad:"
+ f"{st['dark_pct']:.3f}%"
+ )
+
+ elif (
+ st["dark_pct"]
+ > qa[
+ "preflight_dark_warning_pct"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ "dark_warn:"
+ f"{st['dark_pct']:.3f}%"
+ )
+
+ if math.isfinite(cv):
+ if (
+ cv
+ > qa[
+ "preflight_temporal_cv_bad"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ f"temporal_cv_bad:{cv:.4f}"
+ )
+
+ elif (
+ cv
+ > qa[
+ "preflight_temporal_cv_warning"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ f"temporal_cv_warn:{cv:.4f}"
+ )
+
+ result[
+ "roles"
+ ][role] = {
+ "status": status,
+ "stats": st,
+ "temporal_cv": (
+ None
+ if not math.isfinite(
+ cv
+ )
+ else float(cv)
+ ),
+ "controls": dict(
+ runtime.controls[
+ role
+ ]
+ ),
+ "raw_meta": dict(
+ runtime.raw_meta[
+ role
+ ]
+ ),
+ "reasons": reasons,
+ }
+
+ result[
+ "status"
+ ] = merge_status(
+ result[
+ "status"
+ ],
+ status,
+ )
+
+ result[
+ "reasons"
+ ].extend(
+ [
+ f"{role}:{x}"
+ for x in reasons
+ ]
+ )
+
+ return result
+
+
+def choose_base_equivalent_exposure(
+ runtime,
+ calibration_iso,
+):
+ """
+ Converte o ponto encontrado pelo AE para exposição equivalente no ISO
+ de calibração:
+
+ factor = exp * ISO/100
+ exp_cal = factor / (ISO_cal/100)
+ """
+ out = {}
+
+ for role in ROLES:
+ ctrl = runtime.controls[
+ role
+ ]
+
+ exp = safe_float(
+ ctrl.get(
+ "exposure_time_us"
+ ),
+ None,
+ )
+
+ iso = safe_float(
+ ctrl.get(
+ "sensitivity_iso"
+ ),
+ None,
+ )
+
+ if (
+ exp is None
+ or iso is None
+ or exp <= 0
+ or iso <= 0
+ ):
+ raise RuntimeError(
+ f"Sem EXP/ISO válido "
+ f"para {role}"
+ )
+
+ factor = (
+ exp
+ * (
+ iso
+ / ISO_BASE
+ )
+ )
+
+ cal_gain = (
+ float(
+ calibration_iso
+ )
+ / ISO_BASE
+ )
+
+ exp_equiv = (
+ factor
+ / cal_gain
+ )
+
+ out[role] = {
+ "ae_exposure_time_us": (
+ float(exp)
+ ),
+ "ae_sensitivity_iso": (
+ float(iso)
+ ),
+ "ae_factor": (
+ float(factor)
+ ),
+ "calibration_iso": int(
+ calibration_iso
+ ),
+ "equivalent_exposure_us": (
+ float(
+ exp_equiv
+ )
+ ),
+ }
+
+ return out
+
+
+# ============================================================
+# Sweep
+# ============================================================
+
+def parse_sweep_factors(
+ text: str,
+):
+ if not text:
+ return list(
+ DEFAULT_SWEEP_FACTORS
+ )
+
+ vals = []
+
+ for token in (
+ str(text)
+ .replace(";", ",")
+ .split(",")
+ ):
+ token = token.strip()
+
+ if not token:
+ continue
+
+ v = float(token)
+
+ if v <= 0:
+ raise ValueError(
+ "Sweep factor deve ser > 0"
+ )
+
+ vals.append(
+ v
+ )
+
+ vals = sorted(
+ set(
+ round(v, 6)
+ for v in vals
+ )
+ )
+
+ return vals
+
+
+def build_sweep_plan(
+ base_equiv,
+ factors,
+ args,
+):
+ """
+ Cada nível possui exposição própria por role, mas o mesmo fator relativo
+ ao ponto de trabalho encontrado pelo AE.
+ """
+ levels = []
+
+ max_frame_exp = int(
+ min(
+ float(
+ args.max_exposure_us
+ ),
+ (
+ 1_000_000.0
+ / max(
+ float(
+ args.fps
+ ),
+ 1.0,
+ )
+ )
+ * float(
+ args.frame_period_fraction
+ ),
+ )
+ )
+
+ min_exp = int(
+ args.min_exposure_us
+ )
+
+ for factor in factors:
+ controls = {}
+
+ for role in ROLES:
+ base = float(
+ base_equiv[
+ role
+ ][
+ "equivalent_exposure_us"
+ ]
+ )
+
+ exp = int(
+ round(
+ base
+ * float(
+ factor
+ )
+ )
+ )
+
+ exp = int(
+ clamp(
+ exp,
+ min_exp,
+ max_frame_exp,
+ )
+ )
+
+ controls[role] = LockedControl(
+ role=role,
+ exposure_time_us=exp,
+ sensitivity_iso=int(
+ args.calibration_iso
+ ),
+ source=(
+ "radiometric_sweep"
+ ),
+ )
+
+ signature = tuple(
+ controls[
+ role
+ ].exposure_time_us
+ for role in ROLES
+ )
+
+ if (
+ levels
+ and tuple(
+ levels[-1][
+ "controls"
+ ][role]
+ .exposure_time_us
+ for role in ROLES
+ )
+ == signature
+ ):
+ continue
+
+ levels.append({
+ "factor": float(
+ factor
+ ),
+ "controls": controls,
+ })
+
+ return levels, {
+ "min_exposure_us": min_exp,
+ "max_exposure_us": max_frame_exp,
+ }
+
+
+def show_progress_board(
+ runtime,
+ title,
+ lines,
+ args,
+):
+ pw = int(
+ args.panel_width
+ )
+
+ ph = int(
+ args.panel_height
+ )
+
+ panels = {}
+
+ for role in ROLES:
+ img = runtime.images[
+ role
+ ]
+
+ if img is None:
+ panel = np.zeros(
+ (
+ ph,
+ pw,
+ 3,
+ ),
+ dtype=np.uint8,
+ )
+ else:
+ view = gray_preview(
+ img,
+ args.roi_frac,
+ )
+
+ panel = resize_panel(
+ view,
+ pw,
+ ph,
+ )
+
+ st = (
+ image_stats(
+ img,
+ args.roi_frac,
+ )
+ if img is not None
+ else {}
+ )
+
+ ctrl = runtime.controls[
+ role
+ ]
+
+ overlay_hud(
+ panel,
+ [
+ f"{role.upper()} | "
+ f"{runtime.specs[role].sensor_name}",
+ f"RAW "
+ f"{runtime.specs[role].width}x"
+ f"{runtime.specs[role].height}",
+ f"p50={st.get('p50', 0):.3f} "
+ f"p95={st.get('p95', 0):.3f}",
+ f"sat={st.get('sat_pct', 0):.3f}%",
+ f"EXP={ctrl.get('exposure_time_us')}us "
+ f"ISO={ctrl.get('sensitivity_iso')}",
+ ],
+ x=8,
+ y=18,
+ font_scale=0.39,
+ line_step=17,
+ )
+
+ panels[
+ role
+ ] = panel
+
+ data = np.zeros(
+ (
+ ph,
+ pw,
+ 3,
+ ),
+ dtype=np.uint8,
+ )
+
+ overlay_hud(
+ data,
+ [
+ title,
+ "",
+ *lines,
+ ],
+ x=14,
+ y=25,
+ font_scale=0.43,
+ line_step=19,
+ )
+
+ return np.vstack([
+ np.hstack([
+ panels["rgb"],
+ panels["re"],
+ ]),
+ np.hstack([
+ panels["nir"],
+ data,
+ ]),
+ ])
+
+
+# ============================================================
+# Regression / calibration math
+# ============================================================
+
+def linear_fit(
+ x,
+ y,
+):
+ x = np.asarray(
+ x,
+ dtype=np.float64,
+ )
+
+ y = np.asarray(
+ y,
+ dtype=np.float64,
+ )
+
+ if x.size < 2:
+ raise ValueError(
+ "Poucos pontos para regressão"
+ )
+
+ A = np.vstack([
+ x,
+ np.ones_like(x),
+ ]).T
+
+ slope, intercept = (
+ np.linalg.lstsq(
+ A,
+ y,
+ rcond=None,
+ )[0]
+ )
+
+ pred = (
+ slope * x
+ + intercept
+ )
+
+ resid = y - pred
+
+ ss_res = float(
+ np.sum(
+ resid ** 2
+ )
+ )
+
+ ss_tot = float(
+ np.sum(
+ (
+ y
+ - np.mean(y)
+ ) ** 2
+ )
+ )
+
+ r2 = (
+ 1.0
+ if ss_tot <= 1e-18
+ else 1.0
+ - ss_res
+ / ss_tot
+ )
+
+ return {
+ "slope": float(
+ slope
+ ),
+ "intercept": float(
+ intercept
+ ),
+ "r2": float(
+ r2
+ ),
+ "prediction": pred,
+ "residual": resid,
+ }
+
+
+def describe(
+ values,
+):
+ arr = np.asarray(
+ list(values),
+ dtype=np.float64,
+ )
+
+ if arr.size == 0:
+ return {
+ "count": 0,
+ }
+
+ return {
+ "count": int(
+ arr.size
+ ),
+ "min": float(
+ np.min(arr)
+ ),
+ "p05": float(
+ np.percentile(
+ arr,
+ 5,
+ )
+ ),
+ "p50": float(
+ np.percentile(
+ arr,
+ 50,
+ )
+ ),
+ "p95": float(
+ np.percentile(
+ arr,
+ 95,
+ )
+ ),
+ "max": float(
+ np.max(arr)
+ ),
+ "mean": float(
+ np.mean(arr)
+ ),
+ "std": float(
+ np.std(arr)
+ ),
+ }
+
+
+def analyze_role_sweep(
+ sweep_rows,
+ role,
+ target_p50,
+ args,
+ qa,
+):
+ all_points = []
+
+ for row in sweep_rows:
+ r = row[
+ "roles"
+ ][role]
+
+ ctrl = row[
+ "controls"
+ ][role]
+
+ exposure = float(
+ ctrl[
+ "exposure_time_us"
+ ]
+ )
+
+ iso = float(
+ ctrl[
+ "sensitivity_iso"
+ ]
+ )
+
+ actual_factor = (
+ exposure
+ * (
+ iso
+ / ISO_BASE
+ )
+ )
+
+ p50 = float(
+ r["p50"]
+ )
+
+ point = {
+ "level_index": int(
+ row[
+ "level_index"
+ ]
+ ),
+ "sweep_factor": float(
+ row[
+ "sweep_factor"
+ ]
+ ),
+ "exposure_time_us": exposure,
+ "sensitivity_iso": iso,
+ "actual_factor": actual_factor,
+ "p50": p50,
+ "p95": float(
+ r["p95"]
+ ),
+ "sat_pct": float(
+ r["sat_pct"]
+ ),
+ "dark_pct": float(
+ r["dark_pct"]
+ ),
+ "temporal_cv": float(
+ r[
+ "p50_temporal_cv"
+ ]
+ ),
+ }
+
+ valid = (
+ p50
+ >= qa[
+ "valid_p50_min"
+ ]
+ and p50
+ <= qa[
+ "valid_p50_max"
+ ]
+ and point[
+ "sat_pct"
+ ]
+ <= qa[
+ "valid_sat_max_pct"
+ ]
+ and point[
+ "temporal_cv"
+ ]
+ <= qa[
+ "level_temporal_cv_bad"
+ ]
+ )
+
+ point["valid"] = bool(
+ valid
+ )
+
+ all_points.append(
+ point
+ )
+
+ valid_points = [
+ p
+ for p in all_points
+ if p["valid"]
+ ]
+
+ result = {
+ "status": "good",
+ "reasons": [],
+ "role": role,
+ "target_p50": float(
+ target_p50
+ ),
+ "all_points": all_points,
+ "valid_points": valid_points,
+ }
+
+ if (
+ len(valid_points)
+ < qa[
+ "min_valid_levels"
+ ]
+ ):
+ result[
+ "status"
+ ] = "bad"
+
+ result[
+ "reasons"
+ ].append(
+ "valid_levels_bad:"
+ f"{len(valid_points)}"
+ )
+
+ return result
+
+ x = np.asarray(
+ [
+ p[
+ "actual_factor"
+ ]
+ for p in valid_points
+ ],
+ dtype=np.float64,
+ )
+
+ y = np.asarray(
+ [
+ p["p50"]
+ for p in valid_points
+ ],
+ dtype=np.float64,
+ )
+
+ fit = linear_fit(
+ x,
+ y,
+ )
+
+ slope = float(
+ fit["slope"]
+ )
+
+ intercept = float(
+ fit["intercept"]
+ )
+
+ if slope <= 0:
+ result[
+ "status"
+ ] = "bad"
+
+ result[
+ "reasons"
+ ].append(
+ f"negative_slope:{slope}"
+ )
+
+ return result
+
+ reference_factor = (
+ float(target_p50)
+ - intercept
+ ) / slope
+
+ x_min = float(
+ np.min(x)
+ )
+
+ x_max = float(
+ np.max(x)
+ )
+
+ extrap_margin = float(
+ qa[
+ "reference_extrapolation_margin"
+ ]
+ )
+
+ if (
+ reference_factor
+ < x_min
+ * (
+ 1.0
+ - extrap_margin
+ )
+ or reference_factor
+ > x_max
+ * (
+ 1.0
+ + extrap_margin
+ )
+ ):
+ result[
+ "status"
+ ] = "bad"
+
+ result[
+ "reasons"
+ ].append(
+ "reference_outside_measured_range:"
+ f"{reference_factor:.1f}"
+ )
+
+ reference_factor = float(
+ clamp(
+ reference_factor,
+ x_min,
+ x_max,
+ )
+ )
+
+ reference_iso = int(
+ args.calibration_iso
+ )
+
+ reference_exposure_us = (
+ reference_factor
+ / (
+ reference_iso
+ / ISO_BASE
+ )
+ )
+
+ # Testa exatamente o modelo utilizado no runtime:
+ # normalized = measured * reference_factor / actual_factor
+ normalized_values = (
+ y
+ * reference_factor
+ / x
+ )
+
+ ref_response = (
+ slope
+ * reference_factor
+ + intercept
+ )
+
+ rel_errors = np.abs(
+ normalized_values
+ - ref_response
+ ) / max(
+ abs(
+ ref_response
+ ),
+ 1e-9,
+ )
+
+ intercept_fraction = (
+ abs(
+ intercept
+ )
+ / max(
+ abs(
+ ref_response
+ ),
+ 1e-9,
+ )
+ )
+
+ status = result[
+ "status"
+ ]
+
+ reasons = list(
+ result[
+ "reasons"
+ ]
+ )
+
+ r2 = float(
+ fit["r2"]
+ )
+
+ if r2 < qa[
+ "r2_bad"
+ ]:
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ f"r2_bad:{r2:.6f}"
+ )
+
+ elif r2 < qa[
+ "r2_warning"
+ ]:
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ f"r2_warn:{r2:.6f}"
+ )
+
+ if (
+ intercept_fraction
+ > qa[
+ "intercept_fraction_bad"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ "intercept_fraction_bad:"
+ f"{intercept_fraction:.4f}"
+ )
+
+ elif (
+ intercept_fraction
+ > qa[
+ "intercept_fraction_warning"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ "intercept_fraction_warn:"
+ f"{intercept_fraction:.4f}"
+ )
+
+ rel_med = float(
+ np.median(
+ rel_errors
+ )
+ )
+
+ rel_p95 = float(
+ np.percentile(
+ rel_errors,
+ 95,
+ )
+ )
+
+ if (
+ rel_med
+ > qa[
+ "norm_median_rel_error_bad"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ "norm_med_rel_bad:"
+ f"{rel_med:.4f}"
+ )
+
+ elif (
+ rel_med
+ > qa[
+ "norm_median_rel_error_warning"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ "norm_med_rel_warn:"
+ f"{rel_med:.4f}"
+ )
+
+ if (
+ rel_p95
+ > qa[
+ "norm_p95_rel_error_bad"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ "norm_p95_rel_bad:"
+ f"{rel_p95:.4f}"
+ )
+
+ elif (
+ rel_p95
+ > qa[
+ "norm_p95_rel_error_warning"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ "norm_p95_rel_warn:"
+ f"{rel_p95:.4f}"
+ )
+
+ measured_scales = (
+ reference_factor
+ / x
+ )
+
+ result.update({
+ "status": status,
+ "reasons": reasons,
+ "fit": {
+ "slope_per_factor": slope,
+ "intercept": intercept,
+ "r2": r2,
+ "intercept_fraction_of_reference_response": (
+ float(
+ intercept_fraction
+ )
+ ),
+ "reference_response_predicted": float(
+ ref_response
+ ),
+ },
+ "reference": {
+ "reference_factor": float(
+ reference_factor
+ ),
+ "exposure_time_us": int(
+ round(
+ reference_exposure_us
+ )
+ ),
+ "sensitivity_iso": int(
+ reference_iso
+ ),
+ "target_p50": float(
+ target_p50
+ ),
+ },
+ "normalization_model_validation": {
+ "normalized_values": [
+ float(x)
+ for x in normalized_values
+ ],
+ "relative_errors": [
+ float(x)
+ for x in rel_errors
+ ],
+ "median_relative_error": (
+ rel_med
+ ),
+ "p95_relative_error": (
+ rel_p95
+ ),
+ "max_relative_error": float(
+ np.max(
+ rel_errors
+ )
+ ),
+ },
+ "validated_scale_range": {
+ "min": float(
+ np.min(
+ measured_scales
+ )
+ ),
+ "max": float(
+ np.max(
+ measured_scales
+ )
+ ),
+ },
+ "observed_factor_range": {
+ "min": x_min,
+ "max": x_max,
+ },
+ })
+
+ return result
+
+
+# ============================================================
+# Validation at reference
+# ============================================================
+
+def evaluate_reference_validation(
+ role_analysis,
+ validation_stats,
+ qa,
+):
+ role = role_analysis[
+ "role"
+ ]
+
+ target = float(
+ role_analysis[
+ "target_p50"
+ ]
+ )
+
+ st = validation_stats[
+ role
+ ]
+
+ measured = float(
+ st["p50"]
+ )
+
+ target_rel_error = (
+ abs(
+ measured
+ - target
+ )
+ / max(
+ abs(
+ target
+ ),
+ 1e-9,
+ )
+ )
+
+ temporal = float(
+ st[
+ "p50_temporal_cv"
+ ]
+ )
+
+ sat = float(
+ st[
+ "sat_pct"
+ ]
+ )
+
+ status = "good"
+ reasons = []
+
+ if (
+ target_rel_error
+ > qa[
+ "validation_target_rel_error_bad"
+ ]
+ ):
+ status = "bad"
+
+ reasons.append(
+ "target_rel_bad:"
+ f"{target_rel_error:.4f}"
+ )
+
+ elif (
+ target_rel_error
+ > qa[
+ "validation_target_rel_error_warning"
+ ]
+ ):
+ status = "warning"
+
+ reasons.append(
+ "target_rel_warn:"
+ f"{target_rel_error:.4f}"
+ )
+
+ if (
+ temporal
+ > qa[
+ "validation_temporal_cv_bad"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ "temporal_cv_bad:"
+ f"{temporal:.4f}"
+ )
+
+ elif (
+ temporal
+ > qa[
+ "validation_temporal_cv_warning"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ "temporal_cv_warn:"
+ f"{temporal:.4f}"
+ )
+
+ if (
+ sat
+ > qa[
+ "validation_sat_bad_pct"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "bad",
+ )
+
+ reasons.append(
+ f"sat_bad:{sat:.4f}%"
+ )
+
+ elif (
+ sat
+ > qa[
+ "validation_sat_warning_pct"
+ ]
+ ):
+ status = merge_status(
+ status,
+ "warning",
+ )
+
+ reasons.append(
+ f"sat_warn:{sat:.4f}%"
+ )
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "target_p50": target,
+ "measured_p50": measured,
+ "target_relative_error": (
+ float(
+ target_rel_error
+ )
+ ),
+ "temporal_cv": temporal,
+ "sat_pct": sat,
+ "dark_pct": float(
+ st[
+ "dark_pct"
+ ]
+ ),
+ "p95": float(
+ st["p95"]
+ ),
+ }
+
+
+# ============================================================
+# Plots / CSV
+# ============================================================
+
+def save_sweep_csv(
+ path,
+ sweep_rows,
+):
+ ensure_dir(
+ Path(path).parent
+ )
+
+ fields = [
+ "level_index",
+ "sweep_factor",
+ "role",
+ "exposure_time_us",
+ "sensitivity_iso",
+ "p50",
+ "p95",
+ "sat_pct",
+ "dark_pct",
+ "p50_temporal_cv",
+ ]
+
+ with open(
+ path,
+ "w",
+ newline="",
+ encoding="utf-8",
+ ) as f:
+ writer = csv.DictWriter(
+ f,
+ fieldnames=fields,
+ )
+
+ writer.writeheader()
+
+ for row in sweep_rows:
+ for role in ROLES:
+ ctrl = row[
+ "controls"
+ ][role]
+
+ st = row[
+ "roles"
+ ][role]
+
+ writer.writerow({
+ "level_index": (
+ row[
+ "level_index"
+ ]
+ ),
+ "sweep_factor": (
+ row[
+ "sweep_factor"
+ ]
+ ),
+ "role": role,
+ "exposure_time_us": (
+ ctrl[
+ "exposure_time_us"
+ ]
+ ),
+ "sensitivity_iso": (
+ ctrl[
+ "sensitivity_iso"
+ ]
+ ),
+ "p50": st[
+ "p50"
+ ],
+ "p95": st[
+ "p95"
+ ],
+ "sat_pct": st[
+ "sat_pct"
+ ],
+ "dark_pct": st[
+ "dark_pct"
+ ],
+ "p50_temporal_cv": (
+ st[
+ "p50_temporal_cv"
+ ]
+ ),
+ })
+
+
+def save_response_plot(
+ path,
+ analysis,
+ width=1000,
+ height=650,
+):
+ canvas = np.zeros(
+ (
+ height,
+ width,
+ 3,
+ ),
+ dtype=np.uint8,
+ )
+
+ canvas[:] = 25
+
+ margin_l = 90
+ margin_r = 40
+ margin_t = 70
+ margin_b = 90
+
+ x0 = margin_l
+ y0 = height - margin_b
+ x1 = width - margin_r
+ y1 = margin_t
+
+ cv2.rectangle(
+ canvas,
+ (x0, y1),
+ (x1, y0),
+ (180, 180, 180),
+ 1,
+ )
+
+ valid = analysis.get(
+ "valid_points",
+ [],
+ )
+
+ if not valid or "fit" not in analysis:
+ overlay_hud(
+ canvas,
+ [
+ f"{analysis.get('role', '').upper()}",
+ "Sem modelo válido",
+ ],
+ x=40,
+ y=45,
+ font_scale=0.7,
+ line_step=28,
+ )
+
+ cv2.imwrite(
+ str(path),
+ canvas,
+ )
+
+ return
+
+ xs = np.asarray(
+ [
+ p[
+ "actual_factor"
+ ]
+ for p in valid
+ ],
+ dtype=np.float64,
+ )
+
+ ys = np.asarray(
+ [
+ p["p50"]
+ for p in valid
+ ],
+ dtype=np.float64,
+ )
+
+ xmin = float(
+ np.min(xs)
+ )
+
+ xmax = float(
+ np.max(xs)
+ )
+
+ ymin = 0.0
+
+ ymax = max(
+ 0.05,
+ float(
+ np.max(ys)
+ )
+ * 1.10,
+ )
+
+ def px(x):
+ return int(
+ x0
+ + (
+ float(x)
+ - xmin
+ )
+ / max(
+ xmax - xmin,
+ 1e-9,
+ )
+ * (
+ x1 - x0
+ )
+ )
+
+ def py(y):
+ return int(
+ y0
+ - (
+ float(y)
+ - ymin
+ )
+ / max(
+ ymax - ymin,
+ 1e-9,
+ )
+ * (
+ y0 - y1
+ )
+ )
+
+ slope = analysis[
+ "fit"
+ ][
+ "slope_per_factor"
+ ]
+
+ intercept = analysis[
+ "fit"
+ ][
+ "intercept"
+ ]
+
+ line_x = np.linspace(
+ xmin,
+ xmax,
+ 120,
+ )
+
+ prev = None
+
+ for xv in line_x:
+ yv = (
+ slope
+ * xv
+ + intercept
+ )
+
+ pt = (
+ px(xv),
+ py(yv),
+ )
+
+ if prev is not None:
+ cv2.line(
+ canvas,
+ prev,
+ pt,
+ (180, 180, 180),
+ 2,
+ cv2.LINE_AA,
+ )
+
+ prev = pt
+
+ for p in analysis[
+ "all_points"
+ ]:
+ color = (
+ (0, 220, 0)
+ if p["valid"]
+ else (0, 0, 220)
+ )
+
+ cv2.circle(
+ canvas,
+ (
+ px(
+ p[
+ "actual_factor"
+ ]
+ ),
+ py(
+ p["p50"]
+ ),
+ ),
+ 6,
+ color,
+ -1,
+ )
+
+ ref = analysis[
+ "reference"
+ ]
+
+ ref_x = float(
+ ref[
+ "reference_factor"
+ ]
+ )
+
+ cv2.line(
+ canvas,
+ (
+ px(ref_x),
+ y1,
+ ),
+ (
+ px(ref_x),
+ y0,
+ ),
+ (0, 255, 255),
+ 2,
+ )
+
+ overlay_hud(
+ canvas,
+ [
+ f"{analysis['role'].upper()} RADIOMETRIC RESPONSE",
+ f"status={analysis['status'].upper()}",
+ f"R2={analysis['fit']['r2']:.6f}",
+ f"ref={ref['exposure_time_us']}us ISO={ref['sensitivity_iso']}",
+ f"norm med err="
+ f"{analysis['normalization_model_validation']['median_relative_error']*100:.2f}%",
+ f"norm p95 err="
+ f"{analysis['normalization_model_validation']['p95_relative_error']*100:.2f}%",
+ ],
+ x=30,
+ y=28,
+ font_scale=0.48,
+ line_step=21,
+ )
+
+ cv2.putText(
+ canvas,
+ "Exposure x ISO factor",
+ (
+ width // 2 - 90,
+ height - 30,
+ ),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.55,
+ (220, 220, 220),
+ 1,
+ cv2.LINE_AA,
+ )
+
+ cv2.putText(
+ canvas,
+ "RAW p50",
+ (
+ 18,
+ height // 2,
+ ),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.55,
+ (220, 220, 220),
+ 1,
+ cv2.LINE_AA,
+ )
+
+ cv2.imwrite(
+ str(path),
+ canvas,
+ )
+
+
+# ============================================================
+# Runtime fragment
+# ============================================================
+
+def build_normalization_fragment(
+ role_analyses,
+ args,
+):
+ reference_controls = {}
+
+ scale_limits = {}
+
+ for role in ROLES:
+ ref = role_analyses[
+ role
+ ][
+ "reference"
+ ]
+
+ reference_controls[
+ role
+ ] = {
+ "exposure_time_us": int(
+ ref[
+ "exposure_time_us"
+ ]
+ ),
+ "sensitivity_iso": int(
+ ref[
+ "sensitivity_iso"
+ ]
+ ),
+ }
+
+ scale_limits[
+ role
+ ] = {
+ "min": float(
+ args.runtime_scale_min
+ ),
+ "max": float(
+ args.runtime_scale_max
+ ),
+ }
+
+ scale_limits[
+ "default"
+ ] = {
+ "min": float(
+ args.runtime_scale_min
+ ),
+ "max": float(
+ args.runtime_scale_max
+ ),
+ }
+
+ return {
+ "radiometric_normalization": {
+ "enabled": True,
+ "method": NORMALIZATION_METHOD,
+ "apply_stage": (
+ "after_dark_before_flat_gain"
+ ),
+ "control_source": (
+ "stream_meta.frame_controls"
+ ),
+ "role_mapping_source": (
+ "camera_info"
+ ),
+ "factor_model": FACTOR_MODEL,
+ "iso_base": ISO_BASE,
+ "reference_mode": "fixed",
+ "reference_controls": (
+ reference_controls
+ ),
+ "scale_limits": (
+ scale_limits
+ ),
+ "missing_controls_policy": (
+ "skip"
+ ),
+ "invalid_controls_policy": (
+ "skip"
+ ),
+ "clip_output": False,
+ "save_debug": True,
+ }
+ }
+
+
+# ============================================================
+# Active artifact
+# ============================================================
+
+def promote_active(
+ active_path: Path,
+ report,
+):
+ ensure_dir(
+ active_path.parent
+ )
+
+ if active_path.exists():
+ old = load_json(
+ active_path
+ )
+
+ old_sig = old.get(
+ "hardware_signature"
+ )
+
+ new_sig = report.get(
+ "hardware_signature"
+ )
+
+ # Diferente de homografia, aqui trocar sensor RGB é justamente
+ # um motivo legítimo para recalibrar. Portanto não bloqueamos,
+ # apenas mantemos backup.
+ backup = active_path.with_name(
+ active_path.stem
+ + f".backup_{session_stamp()}"
+ + active_path.suffix
+ )
+
+ shutil.copy2(
+ active_path,
+ backup,
+ )
+
+ save_json_atomic(
+ active_path,
+ report,
+ )
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=(
+ "Calibração radiométrica de produção para "
+ "OV9782/AR0234 + 2x OV9282."
+ ),
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ # Hardware
+ parser.add_argument(
+ "--mx-id",
+ default=None,
+ )
+
+ parser.add_argument(
+ "--fps",
+ type=float,
+ default=20.0,
+ )
+
+ # Bancada
+ parser.add_argument(
+ "--rig-id",
+ default="",
+ help="ID da bancada radiométrica.",
+ )
+
+ parser.add_argument(
+ "--target-id",
+ default="",
+ help="ID/lote do painel cinza/difuso.",
+ )
+
+ parser.add_argument(
+ "--illumination-id",
+ default="",
+ help="ID/configuração da iluminação controlada.",
+ )
+
+ parser.add_argument(
+ "--target-distance-mm",
+ type=float,
+ default=None,
+ )
+
+ parser.add_argument(
+ "--warmup-s",
+ type=float,
+ default=3.0,
+ )
+
+ # ROI
+ parser.add_argument(
+ "--roi-frac",
+ type=float,
+ default=0.50,
+ help=(
+ "Fração central usada para medição, "
+ "evitando bordas/vignette."
+ ),
+ )
+
+ # Sweep
+ parser.add_argument(
+ "--calibration-iso",
+ type=int,
+ default=100,
+ )
+
+ parser.add_argument(
+ "--sweep-factors",
+ default=",".join(
+ str(x)
+ for x in DEFAULT_SWEEP_FACTORS
+ ),
+ )
+
+ parser.add_argument(
+ "--frames-per-level",
+ type=int,
+ default=14,
+ )
+
+ parser.add_argument(
+ "--validation-frames",
+ type=int,
+ default=24,
+ )
+
+ parser.add_argument(
+ "--settle-frames",
+ type=int,
+ default=5,
+ )
+
+ parser.add_argument(
+ "--min-exposure-us",
+ type=int,
+ default=100,
+ )
+
+ parser.add_argument(
+ "--max-exposure-us",
+ type=int,
+ default=30000,
+ )
+
+ parser.add_argument(
+ "--frame-period-fraction",
+ type=float,
+ default=0.85,
+ help=(
+ "Limite de exposição como fração do período "
+ "de frame."
+ ),
+ )
+
+ # Target canônico por role
+ parser.add_argument(
+ "--target-p50-rgb",
+ type=float,
+ default=0.45,
+ )
+
+ parser.add_argument(
+ "--target-p50-re",
+ type=float,
+ default=0.45,
+ )
+
+ parser.add_argument(
+ "--target-p50-nir",
+ type=float,
+ default=0.45,
+ )
+
+ # Runtime scale policy, mantemos contrato atual amplo.
+ parser.add_argument(
+ "--runtime-scale-min",
+ type=float,
+ default=0.05,
+ )
+
+ parser.add_argument(
+ "--runtime-scale-max",
+ type=float,
+ default=3.0,
+ )
+
+ # Política
+ parser.add_argument(
+ "--promote-warning",
+ action="store_true",
+ )
+
+ # UI
+ parser.add_argument(
+ "--panel-width",
+ type=int,
+ default=640,
+ )
+
+ parser.add_argument(
+ "--panel-height",
+ type=int,
+ default=400,
+ )
+
+ # Output
+ parser.add_argument(
+ "--candidate-root",
+ default=(
+ "calibration/"
+ "radiometry_candidates"
+ ),
+ )
+
+ parser.add_argument(
+ "--active-json",
+ default=(
+ "calibration/"
+ "radiometry_calibration_v5.json"
+ ),
+ )
+
+ # Trace
+ parser.add_argument(
+ "--module-id",
+ default="",
+ )
+
+ parser.add_argument(
+ "--operator",
+ default="",
+ )
+
+ parser.add_argument(
+ "--notes",
+ default="",
+ )
+
+ args = parser.parse_args()
+
+ if not (
+ 0.10
+ <= args.roi_frac
+ <= 0.95
+ ):
+ raise ValueError(
+ "--roi-frac deve estar "
+ "entre 0.10 e 0.95"
+ )
+
+ if (
+ args.calibration_iso
+ < 50
+ ):
+ raise ValueError(
+ "--calibration-iso inválido"
+ )
+
+ if (
+ args.runtime_scale_min
+ <= 0
+ or args.runtime_scale_max
+ <= args.runtime_scale_min
+ ):
+ raise ValueError(
+ "Runtime scale limits inválidos"
+ )
+
+ qa = dict(
+ DEFAULT_QA
+ )
+
+ targets = {
+ "rgb": float(
+ args.target_p50_rgb
+ ),
+ "re": float(
+ args.target_p50_re
+ ),
+ "nir": float(
+ args.target_p50_nir
+ ),
+ }
+
+ for role, value in targets.items():
+ if not (
+ 0.10
+ <= value
+ <= 0.80
+ ):
+ raise ValueError(
+ f"target-p50-{role} "
+ f"fora de [0.10,0.80]"
+ )
+
+ factors = parse_sweep_factors(
+ args.sweep_factors
+ )
+
+ if (
+ len(factors)
+ < qa[
+ "min_sweep_levels"
+ ]
+ ):
+ raise ValueError(
+ "Sweep com poucos fatores"
+ )
+
+ sid = session_stamp()
+
+ candidate_dir = (
+ Path(
+ args.candidate_root
+ )
+ / sid
+ )
+
+ candidate_report_path = (
+ candidate_dir
+ / "report.json"
+ )
+
+ sweep_csv_path = (
+ candidate_dir
+ / "sweep.csv"
+ )
+
+ ensure_dir(
+ candidate_dir
+ )
+
+ camera_rows = []
+ specs = {}
+ report = None
+ sweep_rows = []
+ role_analyses = {}
+ validation_stats = None
+
+ window_name = (
+ "Radiometric Calibration - Production"
+ )
+
+ cv2.namedWindow(
+ window_name,
+ cv2.WINDOW_NORMAL,
+ )
+
+ try:
+ (
+ camera_rows,
+ actual_mx,
+ usb_speed,
+ ) = discover_cameras(
+ args.mx_id
+ )
+
+ topology = (
+ validate_product_topology(
+ camera_rows
+ )
+ )
+
+ specs = build_specs(
+ camera_rows
+ )
+
+ print("=" * 88)
+ print(
+ "RADIOMETRIC CALIBRATION - PRODUCTION"
+ )
+ print(
+ f"MX ID : {actual_mx}"
+ )
+ print(
+ f"USB : {usb_speed}"
+ )
+ print("-" * 88)
+
+ for role in ROLES:
+ s = specs[
+ role
+ ]
+
+ print(
+ f"{s.socket_name} -> "
+ f"{role.upper():3s} | "
+ f"{s.sensor_name:8s} | "
+ f"{s.width}x{s.height}"
+ )
+
+ print("=" * 88)
+
+ pipeline = build_pipeline(
+ specs,
+ args,
+ )
+
+ device_info = (
+ dai.DeviceInfo(
+ actual_mx
+ )
+ if actual_mx
+ else None
+ )
+
+ device_ctx = (
+ dai.Device(
+ pipeline,
+ device_info,
+ )
+ if device_info is not None
+ else dai.Device(
+ pipeline
+ )
+ )
+
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ "status": "running",
+ "promoted": False,
+ "depthai_version": getattr(
+ dai,
+ "__version__",
+ "unknown",
+ ),
+ "opencv_version": (
+ cv2.__version__
+ ),
+ "device_mx_id": (
+ actual_mx
+ ),
+ "usb_speed": usb_speed,
+ "topology": topology,
+ "camera_inventory": (
+ camera_rows
+ ),
+ "hardware_signature": (
+ hardware_signature(
+ specs
+ )
+ ),
+ "calibration_domain": (
+ CALIBRATION_DOMAIN
+ ),
+ "normalization_contract": {
+ "method": (
+ NORMALIZATION_METHOD
+ ),
+ "factor_model": (
+ FACTOR_MODEL
+ ),
+ "iso_base": (
+ ISO_BASE
+ ),
+ "apply_stage": (
+ "after_dark_before_flat_gain"
+ ),
+ },
+ "bench_contract": {
+ "rig_id": (
+ args.rig_id
+ or None
+ ),
+ "target_id": (
+ args.target_id
+ or None
+ ),
+ "illumination_id": (
+ args.illumination_id
+ or None
+ ),
+ "target_distance_mm": (
+ args.target_distance_mm
+ ),
+ "roi_frac": float(
+ args.roi_frac
+ ),
+ "target_p50_by_role": (
+ targets
+ ),
+ "operator_instruction": (
+ "Painel difuso uniforme, "
+ "geometria e iluminação "
+ "reproduzíveis entre módulos."
+ ),
+ },
+ "sweep_config": {
+ "calibration_iso": int(
+ args.calibration_iso
+ ),
+ "factors": (
+ factors
+ ),
+ "frames_per_level": int(
+ args.frames_per_level
+ ),
+ "validation_frames": int(
+ args.validation_frames
+ ),
+ "min_exposure_us": int(
+ args.min_exposure_us
+ ),
+ "max_exposure_us": int(
+ args.max_exposure_us
+ ),
+ "frame_period_fraction": float(
+ args.frame_period_fraction
+ ),
+ },
+ "qa_thresholds": qa,
+ "traceability": {
+ "module_id": (
+ args.module_id
+ or None
+ ),
+ "operator": (
+ args.operator
+ or None
+ ),
+ },
+ "notes": (
+ args.notes
+ or ""
+ ),
+ "preflight": None,
+ "base_equivalent_exposure": None,
+ "sweep_plan": None,
+ "sweep": [],
+ "analysis": {},
+ "reference_validation": {},
+ "module_params_fragment": None,
+ "runtime_policy_recommendation": {
+ "radiometric_config": {
+ "enabled": False,
+ "reason": (
+ "Field AE controller is a "
+ "separate policy and is not "
+ "factory-calibrated here."
+ ),
+ },
+ "patch_normalization": {
+ "enabled": False,
+ "reason": (
+ "Patch normalization is a "
+ "separate post-fusion policy."
+ ),
+ },
+ },
+ "candidate_dir": str(
+ candidate_dir
+ ),
+ "active_json": (
+ args.active_json
+ ),
+ }
+
+ with device_ctx as device:
+ runtime = (
+ RadiometricRuntime(
+ device,
+ specs,
+ args,
+ qa,
+ )
+ )
+
+ runtime.wait_all()
+
+ # Warm-up
+ warmup_end = (
+ time.time()
+ + max(
+ 0.0,
+ float(
+ args.warmup_s
+ ),
+ )
+ )
+
+ while (
+ time.time()
+ < warmup_end
+ ):
+ runtime.poll()
+
+ board = (
+ show_progress_board(
+ runtime,
+ "WARM-UP",
+ [
+ "Painel cinza/difuso "
+ "preenchendo o FOV.",
+ "Não altere luz, câmera "
+ "ou distância.",
+ "",
+ f"restante="
+ f"{max(0.0, warmup_end-time.time()):.1f}s",
+ ],
+ args,
+ )
+ )
+
+ cv2.imshow(
+ window_name,
+ board,
+ )
+
+ k = (
+ cv2.waitKey(1)
+ & 0xFF
+ )
+
+ if k in (
+ ord("q"),
+ ord("Q"),
+ 27,
+ ):
+ raise KeyboardInterrupt(
+ "Cancelado no warm-up."
+ )
+
+ # Preflight
+ print("")
+ print(
+ "[PRE-FLIGHT] "
+ "Aguarde GOOD e pressione ENTER."
+ )
+
+ last_preflight = None
+
+ while True:
+ runtime.poll()
+
+ last_preflight = (
+ preflight_report(
+ runtime,
+ qa,
+ )
+ )
+
+ lines = [
+ "Painel uniforme e iluminação estável.",
+ "",
+ f"GLOBAL="
+ f"{last_preflight['status'].upper()}",
+ "",
+ "ENTER = iniciar sweep",
+ "Q/ESC = cancelar",
+ ]
+
+ for role in ROLES:
+ item = (
+ last_preflight[
+ "roles"
+ ][role]
+ )
+
+ st = item.get(
+ "stats",
+ {},
+ )
+
+ lines.append(
+ f"{role.upper()}: "
+ f"{item['status'].upper()} "
+ f"p50={st.get('p50',0):.3f} "
+ f"cv="
+ f"{(item.get('temporal_cv') or 0)*100:.2f}%"
+ )
+
+ board = (
+ show_progress_board(
+ runtime,
+ "PRE-FLIGHT",
+ lines,
+ args,
+ )
+ )
+
+ cv2.imshow(
+ window_name,
+ board,
+ )
+
+ k = (
+ cv2.waitKey(1)
+ & 0xFF
+ )
+
+ if k in (
+ ord("q"),
+ ord("Q"),
+ 27,
+ ):
+ raise KeyboardInterrupt(
+ "Cancelado no preflight."
+ )
+
+ if k in (
+ 13,
+ 10,
+ ):
+ if (
+ last_preflight[
+ "status"
+ ]
+ != "good"
+ ):
+ print(
+ "[BLOCK] Preflight "
+ "ainda não está GOOD."
+ )
+ continue
+
+ break
+
+ time.sleep(
+ 0.002
+ )
+
+ report[
+ "preflight"
+ ] = last_preflight
+
+ base_equiv = (
+ choose_base_equivalent_exposure(
+ runtime,
+ args.calibration_iso,
+ )
+ )
+
+ report[
+ "base_equivalent_exposure"
+ ] = base_equiv
+
+ plan, plan_limits = (
+ build_sweep_plan(
+ base_equiv,
+ factors,
+ args,
+ )
+ )
+
+ if (
+ len(plan)
+ < qa[
+ "min_sweep_levels"
+ ]
+ ):
+ raise RuntimeError(
+ "Sweep ficou com poucos "
+ "níveis únicos após clamps. "
+ "Ajuste iluminação/fps."
+ )
+
+ report[
+ "sweep_plan"
+ ] = {
+ "levels": [
+ {
+ "factor": (
+ level[
+ "factor"
+ ]
+ ),
+ "controls": {
+ role: asdict(
+ level[
+ "controls"
+ ][role]
+ )
+ for role in ROLES
+ },
+ }
+ for level in plan
+ ],
+ "limits": (
+ plan_limits
+ ),
+ }
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ # Sweep automático
+ print("")
+ print(
+ f"[SWEEP] "
+ f"{len(plan)} níveis."
+ )
+
+ for idx, level in enumerate(
+ plan,
+ start=1,
+ ):
+ controls = level[
+ "controls"
+ ]
+
+ runtime.send_controls(
+ controls
+ )
+
+ runtime.verify_controls(
+ controls,
+ settle_frames=(
+ args.settle_frames
+ ),
+ )
+
+ stats = (
+ runtime.capture_level(
+ args.frames_per_level,
+ controls,
+ )
+ )
+
+ row = {
+ "level_index": idx,
+ "sweep_factor": (
+ level[
+ "factor"
+ ]
+ ),
+ "captured_at": (
+ now_str()
+ ),
+ "controls": {
+ role: {
+ "exposure_time_us": (
+ controls[
+ role
+ ].exposure_time_us
+ ),
+ "sensitivity_iso": (
+ controls[
+ role
+ ].sensitivity_iso
+ ),
+ }
+ for role in ROLES
+ },
+ "roles": stats,
+ }
+
+ sweep_rows.append(
+ row
+ )
+
+ report[
+ "sweep"
+ ] = sweep_rows
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ save_sweep_csv(
+ sweep_csv_path,
+ sweep_rows,
+ )
+
+ lines = [
+ f"nível {idx}/{len(plan)}",
+ f"factor={level['factor']:.3f}",
+ "",
+ ]
+
+ for role in ROLES:
+ lines.append(
+ f"{role.upper()}: "
+ f"exp="
+ f"{controls[role].exposure_time_us}us "
+ f"p50="
+ f"{stats[role]['p50']:.3f} "
+ f"sat="
+ f"{stats[role]['sat_pct']:.3f}%"
+ )
+
+ board = (
+ show_progress_board(
+ runtime,
+ "SWEEP RADIOMÉTRICO",
+ lines,
+ args,
+ )
+ )
+
+ cv2.imshow(
+ window_name,
+ board,
+ )
+
+ cv2.waitKey(1)
+
+ print(
+ "[LEVEL] "
+ + " | ".join(
+ [
+ f"{role.upper()} "
+ f"{stats[role]['p50']:.3f}"
+ for role in ROLES
+ ]
+ )
+ )
+
+ # Análise
+ overall_status = "good"
+ overall_reasons = []
+
+ for role in ROLES:
+ analysis = (
+ analyze_role_sweep(
+ sweep_rows,
+ role,
+ targets[role],
+ args,
+ qa,
+ )
+ )
+
+ role_analyses[
+ role
+ ] = analysis
+
+ overall_status = (
+ merge_status(
+ overall_status,
+ analysis[
+ "status"
+ ],
+ )
+ )
+
+ overall_reasons.extend(
+ [
+ f"{role}:{x}"
+ for x in analysis.get(
+ "reasons",
+ [],
+ )
+ ]
+ )
+
+ save_response_plot(
+ candidate_dir
+ / (
+ f"response_"
+ f"{role}.png"
+ ),
+ analysis,
+ )
+
+ report[
+ "analysis"
+ ] = role_analyses
+
+ # Não tenta reference validation se algum papel não conseguiu
+ # sequer gerar referência.
+ if any(
+ "reference"
+ not in role_analyses[
+ role
+ ]
+ for role in ROLES
+ ):
+ report[
+ "status"
+ ] = "bad"
+
+ report[
+ "finished_at"
+ ] = now_str()
+
+ report[
+ "evaluation_reasons"
+ ] = (
+ overall_reasons
+ )
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ raise RuntimeError(
+ "Não foi possível gerar "
+ "reference_controls para "
+ "todas as câmeras."
+ )
+
+ # Reference validation
+ reference_controls = {
+ role: LockedControl(
+ role=role,
+ exposure_time_us=int(
+ role_analyses[
+ role
+ ][
+ "reference"
+ ][
+ "exposure_time_us"
+ ]
+ ),
+ sensitivity_iso=int(
+ role_analyses[
+ role
+ ][
+ "reference"
+ ][
+ "sensitivity_iso"
+ ]
+ ),
+ source=(
+ "radiometric_reference_validation"
+ ),
+ )
+ for role in ROLES
+ }
+
+ runtime.send_controls(
+ reference_controls
+ )
+
+ runtime.verify_controls(
+ reference_controls,
+ settle_frames=(
+ args.settle_frames
+ ),
+ )
+
+ validation_stats = (
+ runtime.capture_level(
+ args.validation_frames,
+ reference_controls,
+ )
+ )
+
+ validation_report = {}
+
+ for role in ROLES:
+ vr = (
+ evaluate_reference_validation(
+ role_analyses[
+ role
+ ],
+ validation_stats,
+ qa,
+ )
+ )
+
+ validation_report[
+ role
+ ] = vr
+
+ overall_status = (
+ merge_status(
+ overall_status,
+ vr[
+ "status"
+ ],
+ )
+ )
+
+ overall_reasons.extend(
+ [
+ f"{role}:validation:{x}"
+ for x in vr[
+ "reasons"
+ ]
+ ]
+ )
+
+ # Salva frame de evidência no ponto de referência.
+ if runtime.images[
+ role
+ ] is not None:
+ vis = gray_preview(
+ runtime.images[
+ role
+ ],
+ args.roi_frac,
+ )
+
+ overlay_hud(
+ vis,
+ [
+ f"{role.upper()} REFERENCE VALIDATION",
+ f"p50="
+ f"{vr['measured_p50']:.4f}",
+ f"target="
+ f"{vr['target_p50']:.4f}",
+ f"status="
+ f"{vr['status'].upper()}",
+ f"EXP="
+ f"{reference_controls[role].exposure_time_us}us "
+ f"ISO="
+ f"{reference_controls[role].sensitivity_iso}",
+ ],
+ x=12,
+ y=25,
+ font_scale=0.55,
+ line_step=23,
+ )
+
+ cv2.imwrite(
+ str(
+ candidate_dir
+ / (
+ f"validation_"
+ f"{role}.png"
+ )
+ ),
+ vis,
+ )
+
+ report[
+ "reference_validation"
+ ] = validation_report
+
+ fragment = (
+ build_normalization_fragment(
+ role_analyses,
+ args,
+ )
+ )
+
+ report[
+ "module_params_fragment"
+ ] = fragment
+
+ report[
+ "evaluation_reasons"
+ ] = overall_reasons
+
+ report[
+ "status"
+ ] = overall_status
+
+ report[
+ "finished_at"
+ ] = now_str()
+
+ promote = (
+ overall_status
+ == "good"
+ or (
+ overall_status
+ == "warning"
+ and args.promote_warning
+ )
+ )
+
+ report[
+ "promoted"
+ ] = bool(
+ promote
+ )
+
+ report[
+ "promotion_policy"
+ ] = {
+ "promote_warning": bool(
+ args.promote_warning
+ ),
+ "allowed": bool(
+ promote
+ ),
+ }
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ marker_name = (
+ "PASS.txt"
+ if promote
+ else "FAIL.txt"
+ )
+
+ (
+ candidate_dir
+ / marker_name
+ ).write_text(
+ (
+ f"status={overall_status}\n"
+ f"promoted={promote}\n"
+ f"session={sid}\n"
+ ),
+ encoding="utf-8",
+ )
+
+ if promote:
+ report[
+ "promoted_at"
+ ] = now_str()
+
+ promote_active(
+ Path(
+ args.active_json
+ ),
+ report,
+ )
+
+ report[
+ "active_json_sha256"
+ ] = sha256_file(
+ args.active_json
+ )
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ # Final UI
+ final = np.zeros(
+ (
+ 760,
+ 1320,
+ 3,
+ ),
+ dtype=np.uint8,
+ )
+
+ lines = [
+ "RADIOMETRIC CALIBRATION - RESULT",
+ "",
+ f"STATUS={overall_status.upper()}",
+ f"PROMOTED={'YES' if promote else 'NO'}",
+ "",
+ ]
+
+ for role in ROLES:
+ a = role_analyses[
+ role
+ ]
+
+ v = validation_report[
+ role
+ ]
+
+ if "fit" in a:
+ lines.append(
+ f"{role.upper()}: "
+ f"ref="
+ f"{a['reference']['exposure_time_us']}us "
+ f"ISO{a['reference']['sensitivity_iso']} | "
+ f"R2={a['fit']['r2']:.6f} | "
+ f"norm p95="
+ f"{a['normalization_model_validation']['p95_relative_error']*100:.2f}% | "
+ f"val p50="
+ f"{v['measured_p50']:.3f}"
+ )
+ else:
+ lines.append(
+ f"{role.upper()}: INVALID"
+ )
+
+ lines.extend([
+ "",
+ "Field radiometric controller: remains DISABLED",
+ "Patch normalization: remains DISABLED",
+ "",
+ f"Candidate: {candidate_dir}",
+ (
+ f"Active: {args.active_json}"
+ if promote
+ else "Active calibration preserved"
+ ),
+ "",
+ "Any key closes.",
+ ])
+
+ overlay_hud(
+ final,
+ lines,
+ x=42,
+ y=62,
+ font_scale=0.62,
+ line_step=30,
+ )
+
+ cv2.imshow(
+ window_name,
+ final,
+ )
+
+ cv2.waitKey(0)
+
+ if promote:
+ print("")
+ print("=" * 88)
+ print(
+ "[PASS] RADIOMETRIC "
+ "CALIBRATION PROMOTED"
+ )
+ print(
+ f"[ACTIVE] {args.active_json}"
+ )
+ print(
+ f"[CANDIDATE] {candidate_dir}"
+ )
+ print("=" * 88)
+
+ else:
+ print("")
+ print("=" * 88)
+ print(
+ "[NOT PROMOTED] "
+ f"status={overall_status.upper()}"
+ )
+ print(
+ "[SAFE] Active calibration "
+ "preserved."
+ )
+ print(
+ f"[CANDIDATE] {candidate_dir}"
+ )
+ print("=" * 88)
+
+ except KeyboardInterrupt as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ }
+
+ report[
+ "status"
+ ] = "cancelled"
+
+ report[
+ "finished_at"
+ ] = now_str()
+
+ report[
+ "promoted"
+ ] = False
+
+ report[
+ "error"
+ ] = str(exc)
+
+ report[
+ "sweep"
+ ] = sweep_rows
+
+ try:
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ (
+ candidate_dir
+ / "CANCELLED.txt"
+ ).write_text(
+ f"reason={exc}\n",
+ encoding="utf-8",
+ )
+
+ except Exception:
+ pass
+
+ print(
+ f"[CANCELADO] {exc}"
+ )
+
+ print(
+ "[SAFE] Calibração ativa "
+ "preservada."
+ )
+
+ except Exception as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ }
+
+ # Preserva BAD já calculado, caso exista.
+ if report.get(
+ "status"
+ ) == "running":
+ report[
+ "status"
+ ] = "error"
+
+ report[
+ "finished_at"
+ ] = now_str()
+
+ report[
+ "promoted"
+ ] = False
+
+ report[
+ "error"
+ ] = (
+ f"{type(exc).__name__}: "
+ f"{exc}"
+ )
+
+ report[
+ "sweep"
+ ] = sweep_rows
+
+ try:
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ (
+ candidate_dir
+ / "FAIL.txt"
+ ).write_text(
+ (
+ f"error="
+ f"{type(exc).__name__}: "
+ f"{exc}\n"
+ ),
+ encoding="utf-8",
+ )
+
+ except Exception:
+ pass
+
+ print("")
+ print("=" * 88)
+ print(
+ "[ERRO] RADIOMETRIC "
+ "CALIBRATION ABORTED"
+ )
+ print(
+ f"{type(exc).__name__}: {exc}"
+ )
+ print(
+ "[SAFE] Active calibration "
+ "preserved."
+ )
+ print("=" * 88)
+
+ raise
+
+ finally:
+ cv2.destroyAllWindows()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_5_camera_startup_profile.py b/Python/OAK/datasets/oak-fcc-3/utils/_5_camera_startup_profile.py
new file mode 100644
index 000000000..1dbfbfa52
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/utils/_5_camera_startup_profile.py
@@ -0,0 +1,761 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+camera_startup_profile_production.py
+====================================
+
+Gerador/auditor FINAL de produção para o bloco `camera_settings`
+do módulo multiespectral.
+
+IMPORTANTE
+----------
+Isto NÃO é uma calibração óptica/radiométrica nova.
+
+O script antigo "Sensor Calibration Tool" misturava:
+ - camera_settings;
+ - ajustes manuais AE/AWB/exposure/gain;
+ - ROIs de cana/erva/solo;
+ - guidance heurístico;
+ - rgb_calibration manual;
+ - snapshots/offline samples.
+
+Na arquitetura final de produto essas responsabilidades já foram separadas.
+
+Esta ferramenta preserva SOMENTE o que ainda é necessário ao runtime:
+ camera_settings
+
+e força explicitamente:
+ rgb_calibration.enabled = false
+ gains = 1,1,1
+
+para impedir que ganhos RGB históricos/experimentais vazem para o tensor.
+
+Dependência
+-----------
+A ferramenta lê o artefato homologado da etapa radiométrica:
+
+ calibration/radiometry_calibration_v5.json
+
+e reaproveita:
+ radiometric_normalization.reference_controls
+
+como valores de fallback do startup.
+
+Política normal de produto
+--------------------------
+ RGB:
+ AE = ON
+ AWB = ON
+ exposure_time_us = referência radiométrica (fallback)
+ analogue_gain = ISO_ref / 100
+ colour_gains = [1.0, 1.0]
+
+ RE:
+ AE = ON
+ AWB = OFF
+ exposure_time_us = referência radiométrica (fallback)
+ analogue_gain = ISO_ref / 100
+
+ NIR:
+ AE = ON
+ AWB = OFF
+ exposure_time_us = referência radiométrica (fallback)
+ analogue_gain = ISO_ref / 100
+
+Por que manter os valores manuais se AE está ON?
+-------------------------------------------------
+No manager atual, com AE ligado, exposure/gain do JSON não são forçados
+fisicamente. Eles ficam armazenados como estado/fallback para uma eventual
+transição futura para manual.
+
+Assim:
+ - o campo continua adaptando exposição automaticamente;
+ - a normalização radiométrica usa os controles reais de cada frame;
+ - se AE for desabilitado em algum fluxo futuro, o fallback já é coerente
+ com a calibração radiométrica do módulo.
+
+Saída
+-----
+ calibration/camera_startup_profile_v3.json
+
+Contém:
+ module_params_fragment.camera_settings
+ module_params_fragment.rgb_calibration
+
+O assembler final do module_params continua sendo a autoridade de merge.
+
+Fail-closed
+-----------
+- exige CAM_A = OV9782 ou AR0234;
+- exige CAM_B/C = OV9282;
+- exige artefato radiométrico promovido;
+- exige hardware_signature compatível;
+- exige reference_controls válidos para rgb/re/nir;
+- não aceita ISO inválido;
+- não ativa rgb_calibration;
+- preserva backup do ativo anterior.
+
+Uso
+---
+ python camera_startup_profile_production.py
+
+Com rastreabilidade:
+ python camera_startup_profile_production.py ^
+ --module-id MOD_MS_001 ^
+ --operator Diego
+
+Somente auditoria, sem salvar:
+ python camera_startup_profile_production.py --check-only
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Optional, List
+
+import depthai as dai
+
+
+SCHEMA = "multispec_camera_startup_profile_v3"
+ISO_BASE = 100.0
+
+ROLES = ("rgb", "re", "nir")
+
+PRODUCT_TOPOLOGY = {
+ "rgb": {
+ "socket": "CAM_A",
+ "allowed_sensors": ("OV9782", "AR0234"),
+ },
+ "re": {
+ "socket": "CAM_B",
+ "allowed_sensors": ("OV9282",),
+ },
+ "nir": {
+ "socket": "CAM_C",
+ "allowed_sensors": ("OV9282",),
+ },
+}
+
+SENSOR_NATIVE_SIZE = {
+ "OV9782": (1280, 800),
+ "AR0234": (1920, 1200),
+ "OV9282": (1280, 800),
+}
+
+
+def now_str() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def stamp() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+
+
+def ensure_dir(path: str | Path):
+ Path(path).mkdir(parents=True, exist_ok=True)
+
+
+def load_json(path: str | Path) -> dict:
+ p = Path(path)
+ if not p.is_file():
+ raise FileNotFoundError(f"Arquivo não encontrado: {p}")
+
+ with p.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def save_json_atomic(path: str | Path, data: dict):
+ p = Path(path)
+ ensure_dir(p.parent)
+
+ tmp = p.with_suffix(p.suffix + ".tmp")
+
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.flush()
+ os.fsync(f.fileno())
+
+ os.replace(tmp, p)
+
+
+def socket_name(socket) -> str:
+ name = getattr(socket, "name", None)
+ if name:
+ return str(name)
+
+ text = str(socket)
+ for candidate in ("CAM_A", "CAM_B", "CAM_C", "CAM_D"):
+ if candidate in text:
+ return candidate
+
+ return text
+
+
+def supported_types(feature) -> List[str]:
+ return [
+ str(v).upper()
+ for v in (getattr(feature, "supportedTypes", []) or [])
+ ]
+
+
+def feature_is_color(feature) -> bool:
+ types = supported_types(feature)
+ sensor = str(getattr(feature, "sensorName", "") or "").upper()
+
+ if any("COLOR" in x for x in types):
+ return True
+ if any("MONO" in x for x in types):
+ return False
+
+ return sensor in {"OV9782", "AR0234"}
+
+
+def feature_is_mono(feature) -> bool:
+ types = supported_types(feature)
+
+ if any("MONO" in x for x in types):
+ return True
+ if any("COLOR" in x for x in types):
+ return False
+
+ return not feature_is_color(feature)
+
+
+@dataclass
+class CameraInfo:
+ role: str
+ socket: str
+ sensor: str
+ width: int
+ height: int
+ is_color: bool
+ is_mono: bool
+
+
+def discover_hardware(mx_id: Optional[str]):
+ info = dai.DeviceInfo(mx_id) if mx_id else None
+ ctx = dai.Device(info) if info is not None else dai.Device()
+
+ with ctx as device:
+ actual_mx = None
+
+ for method_name in ("getMxId", "getDeviceId"):
+ fn = getattr(device, method_name, None)
+ if callable(fn):
+ try:
+ value = fn()
+ if value:
+ actual_mx = str(value)
+ break
+ except Exception:
+ pass
+
+ usb_speed = None
+ try:
+ usb_speed = str(device.getUsbSpeed())
+ except Exception:
+ pass
+
+ rows = []
+
+ for f in device.getConnectedCameraFeatures():
+ rows.append({
+ "socket": socket_name(f.socket),
+ "sensor": str(getattr(f, "sensorName", "") or "").upper(),
+ "width": int(getattr(f, "width", 0) or 0),
+ "height": int(getattr(f, "height", 0) or 0),
+ "is_color": feature_is_color(f),
+ "is_mono": feature_is_mono(f),
+ "supported_types": supported_types(f),
+ })
+
+ return rows, actual_mx, usb_speed
+
+
+def validate_and_resolve_hardware(rows: list[dict]) -> Dict[str, CameraInfo]:
+ by_socket = {row["socket"]: row for row in rows}
+ resolved = {}
+ errors = []
+
+ for role in ROLES:
+ contract = PRODUCT_TOPOLOGY[role]
+ sock = contract["socket"]
+
+ row = by_socket.get(sock)
+ if row is None:
+ errors.append(f"{role.upper()}: {sock} ausente")
+ continue
+
+ sensor = row["sensor"]
+
+ if sensor not in contract["allowed_sensors"]:
+ errors.append(
+ f"{role.upper()}: {sock} sensor={sensor}, "
+ f"permitidos={contract['allowed_sensors']}"
+ )
+ continue
+
+ expected_w, expected_h = SENSOR_NATIVE_SIZE[sensor]
+
+ if row["width"] and row["height"]:
+ if (row["width"], row["height"]) != (expected_w, expected_h):
+ errors.append(
+ f"{role.upper()}: {sensor} anunciou "
+ f"{row['width']}x{row['height']}, esperado "
+ f"{expected_w}x{expected_h}"
+ )
+
+ if role == "rgb" and not row["is_color"]:
+ errors.append(f"RGB: {sock}/{sensor} não anunciado COLOR")
+
+ if role in ("re", "nir") and not row["is_mono"]:
+ errors.append(f"{role.upper()}: {sock}/{sensor} não anunciado MONO")
+
+ resolved[role] = CameraInfo(
+ role=role,
+ socket=sock,
+ sensor=sensor,
+ width=expected_w,
+ height=expected_h,
+ is_color=bool(row["is_color"]),
+ is_mono=bool(row["is_mono"]),
+ )
+
+ if errors:
+ raise RuntimeError(
+ "Topologia de produto inválida:\n - "
+ + "\n - ".join(errors)
+ )
+
+ return resolved
+
+
+def hardware_signature_from_resolved(resolved: Dict[str, CameraInfo]) -> dict:
+ return {
+ role: {
+ "socket": resolved[role].socket,
+ "sensor": resolved[role].sensor,
+ "size": [resolved[role].width, resolved[role].height],
+ }
+ for role in ROLES
+ }
+
+
+def normalize_signature(sig: dict) -> dict:
+ """
+ Aceita assinatura no formato dos calibradores de produção.
+ Retorna apenas role/socket/sensor/size para comparação robusta.
+ """
+ out = {}
+
+ for role in ROLES:
+ item = (sig or {}).get(role, {}) or {}
+
+ socket = (
+ item.get("socket")
+ or item.get("socket_name")
+ )
+
+ sensor = (
+ item.get("sensor")
+ or item.get("sensor_name")
+ )
+
+ size = item.get("size")
+
+ if size is None:
+ width = item.get("width")
+ height = item.get("height")
+ if width is not None and height is not None:
+ size = [int(width), int(height)]
+
+ if socket is None or sensor is None or size is None:
+ raise RuntimeError(
+ f"hardware_signature incompleta para {role}: {item}"
+ )
+
+ out[role] = {
+ "socket": str(socket),
+ "sensor": str(sensor).upper(),
+ "size": [int(size[0]), int(size[1])],
+ }
+
+ return out
+
+
+def extract_radiometric_fragment(data: dict) -> dict:
+ """
+ O artefato radiométrico de produção guarda:
+ module_params_fragment.radiometric_normalization
+ """
+ fragment = data.get("module_params_fragment", {}) or {}
+ rn = fragment.get("radiometric_normalization")
+
+ if not isinstance(rn, dict):
+ raise RuntimeError(
+ "Artefato radiométrico sem "
+ "module_params_fragment.radiometric_normalization"
+ )
+
+ if not rn.get("enabled", False):
+ raise RuntimeError(
+ "radiometric_normalization do artefato não está enabled=true"
+ )
+
+ return rn
+
+
+def validate_radiometry_artifact(
+ data: dict,
+ current_signature: dict,
+):
+ schema = str(data.get("schema", ""))
+
+ if schema != "multispec_radiometric_calibration_v5":
+ raise RuntimeError(
+ f"Schema radiométrico inesperado: {schema!r}. "
+ "Esperado 'multispec_radiometric_calibration_v5'."
+ )
+
+ status = str(data.get("status", "")).lower()
+
+ if status not in ("good", "warning"):
+ raise RuntimeError(
+ f"Radiometria não homologada para consumo: status={status!r}"
+ )
+
+ if not bool(data.get("promoted", False)):
+ raise RuntimeError(
+ "Artefato radiométrico não foi promovido."
+ )
+
+ artifact_sig = normalize_signature(
+ data.get("hardware_signature", {})
+ )
+
+ if artifact_sig != current_signature:
+ raise RuntimeError(
+ "Hardware conectado não corresponde ao hardware da radiometria.\n"
+ f"Atual : {current_signature}\n"
+ f"Rad : {artifact_sig}"
+ )
+
+ rn = extract_radiometric_fragment(data)
+
+ refs = rn.get("reference_controls", {}) or {}
+
+ for role in ROLES:
+ ref = refs.get(role)
+
+ if not isinstance(ref, dict):
+ raise RuntimeError(
+ f"reference_controls ausente para {role}"
+ )
+
+ exp = ref.get("exposure_time_us")
+ iso = ref.get("sensitivity_iso")
+
+ if exp is None or float(exp) <= 0:
+ raise RuntimeError(
+ f"reference exposure inválida para {role}: {exp}"
+ )
+
+ if iso is None or float(iso) < 50:
+ raise RuntimeError(
+ f"reference ISO inválido para {role}: {iso}"
+ )
+
+ return rn
+
+
+def iso_to_analogue_gain(iso: float) -> float:
+ """
+ O manager atual representa analogue_gain aproximadamente como ISO/100.
+ """
+ gain = float(iso) / ISO_BASE
+ return max(1.0, gain)
+
+
+def build_camera_settings(reference_controls: dict) -> dict:
+ settings = {}
+
+ for role in ROLES:
+ ref = reference_controls[role]
+
+ exp = int(round(float(ref["exposure_time_us"])))
+ iso = float(ref["sensitivity_iso"])
+ gain = iso_to_analogue_gain(iso)
+
+ if role == "rgb":
+ settings[role] = {
+ "ae_enable": True,
+ "awb_enable": True,
+ "exposure_time_us": exp,
+ "analogue_gain": gain,
+ "colour_gains": [1.0, 1.0],
+ }
+ else:
+ settings[role] = {
+ "ae_enable": True,
+ "awb_enable": False,
+ "exposure_time_us": exp,
+ "analogue_gain": gain,
+ "colour_gains": None,
+ }
+
+ return settings
+
+
+def build_rgb_calibration_policy() -> dict:
+ """
+ Produto final mantém correção multiplicativa RGB desligada.
+ Se um dia ela for homologada, deve existir um calibrador separado e
+ dataset/runtime precisam usar o mesmo contrato.
+ """
+ return {
+ "enabled": False,
+ "gains": {
+ "R": 1.0,
+ "G": 1.0,
+ "B": 1.0,
+ },
+ "policy": "disabled_product_default",
+ "reason": (
+ "No validated RGB tensor-gain calibration is currently active. "
+ "Historical manual gains are intentionally discarded."
+ ),
+ }
+
+
+def compare_existing_module_params(
+ module_params_path: Optional[str],
+ generated_fragment: dict,
+) -> dict:
+ if not module_params_path:
+ return {
+ "available": False,
+ "path": None,
+ }
+
+ p = Path(module_params_path)
+
+ if not p.is_file():
+ return {
+ "available": False,
+ "path": str(p),
+ }
+
+ current = load_json(p)
+
+ current_camera = current.get("camera_settings")
+ current_rgb = current.get("rgb_calibration")
+
+ generated_camera = generated_fragment["camera_settings"]
+ generated_rgb = generated_fragment["rgb_calibration"]
+
+ return {
+ "available": True,
+ "path": str(p),
+ "schema": current.get("schema"),
+ "camera_settings_equal": current_camera == generated_camera,
+ "rgb_calibration_equal": current_rgb == generated_rgb,
+ "current_camera_settings": current_camera,
+ "generated_camera_settings": generated_camera,
+ "current_rgb_calibration": current_rgb,
+ "generated_rgb_calibration": generated_rgb,
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=(
+ "Gerador/auditor de produção para camera_settings e "
+ "política rgb_calibration do módulo multiespectral."
+ ),
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ parser.add_argument(
+ "--mx-id",
+ default=None,
+ help="MX ID da OAK. Vazio usa o primeiro dispositivo disponível.",
+ )
+
+ parser.add_argument(
+ "--radiometry-json",
+ default="calibration/radiometry_calibration_v5.json",
+ help="Artefato radiométrico homologado.",
+ )
+
+ parser.add_argument(
+ "--out-json",
+ default="calibration/camera_startup_profile_v3.json",
+ help="Artefato ativo desta etapa.",
+ )
+
+ parser.add_argument(
+ "--compare-module-params",
+ default="calibration/module_params.json",
+ help="Opcional: audita diferenças contra module_params existente.",
+ )
+
+ parser.add_argument(
+ "--module-id",
+ default="",
+ )
+
+ parser.add_argument(
+ "--operator",
+ default="",
+ )
+
+ parser.add_argument(
+ "--notes",
+ default="",
+ )
+
+ parser.add_argument(
+ "--check-only",
+ action="store_true",
+ help="Valida e imprime o fragmento, mas não salva.",
+ )
+
+ args = parser.parse_args()
+
+ rows, actual_mx, usb_speed = discover_hardware(args.mx_id)
+ resolved = validate_and_resolve_hardware(rows)
+
+ current_signature = hardware_signature_from_resolved(resolved)
+
+ radiometry = load_json(args.radiometry_json)
+
+ rn = validate_radiometry_artifact(
+ radiometry,
+ current_signature,
+ )
+
+ reference_controls = rn["reference_controls"]
+
+ camera_settings = build_camera_settings(reference_controls)
+ rgb_calibration = build_rgb_calibration_policy()
+
+ module_params_fragment = {
+ "camera_settings": camera_settings,
+ "rgb_calibration": {
+ "enabled": False,
+ "gains": {
+ "R": 1.0,
+ "G": 1.0,
+ "B": 1.0,
+ },
+ },
+ }
+
+ audit = compare_existing_module_params(
+ args.compare_module_params,
+ module_params_fragment,
+ )
+
+ payload = {
+ "schema": SCHEMA,
+ "created_at": now_str(),
+ "status": "good",
+ "promoted": not args.check_only,
+ "runtime_effect": (
+ "startup_camera_policy_and_explicit_rgb_gain_disable"
+ ),
+ "device_mx_id": actual_mx,
+ "usb_speed": usb_speed,
+ "hardware_inventory": rows,
+ "hardware_signature": current_signature,
+ "source_radiometry": {
+ "path": str(args.radiometry_json),
+ "schema": radiometry.get("schema"),
+ "session_id": radiometry.get("session_id"),
+ "status": radiometry.get("status"),
+ "promoted": radiometry.get("promoted"),
+ "reference_controls": reference_controls,
+ },
+ "startup_policy": {
+ "mode": "ae_with_calibrated_manual_fallback",
+ "rgb_awb": "auto_for_isp_preview_only",
+ "spectral_awb": "off_not_applicable",
+ "manual_fallback_source": (
+ "radiometric_normalization.reference_controls"
+ ),
+ "note": (
+ "With AE enabled, manager stores exposure/gain as fallback "
+ "and does not force them manually at startup."
+ ),
+ },
+ "rgb_calibration_policy": rgb_calibration,
+ "module_params_fragment": module_params_fragment,
+ "existing_module_params_audit": audit,
+ "traceability": {
+ "module_id": args.module_id or None,
+ "operator": args.operator or None,
+ },
+ "notes": args.notes or "",
+ }
+
+ print("=" * 82)
+ print("CAMERA STARTUP PROFILE - PRODUCTION")
+ print(f"MX ID : {actual_mx}")
+ print(f"USB : {usb_speed}")
+ print("-" * 82)
+
+ for role in ROLES:
+ c = resolved[role]
+ s = camera_settings[role]
+
+ print(
+ f"{c.socket} -> {role.upper():3s} | "
+ f"{c.sensor:8s} | {c.width}x{c.height} | "
+ f"AE={s['ae_enable']} AWB={s['awb_enable']} | "
+ f"fallback={s['exposure_time_us']}us gain={s['analogue_gain']:.3f}"
+ )
+
+ print("-" * 82)
+ print("rgb_calibration: DISABLED | gains = 1.0 / 1.0 / 1.0")
+
+ if audit.get("available"):
+ print(
+ "module_params audit: "
+ f"camera_settings_equal={audit['camera_settings_equal']} | "
+ f"rgb_calibration_equal={audit['rgb_calibration_equal']}"
+ )
+ else:
+ print("module_params audit: arquivo não disponível")
+
+ if args.check_only:
+ print("[CHECK-ONLY] Nenhum arquivo alterado.")
+ print("=" * 82)
+ return
+
+ out = Path(args.out_json)
+ ensure_dir(out.parent)
+
+ if out.exists():
+ backup = out.with_name(
+ out.stem
+ + f".backup_{stamp()}"
+ + out.suffix
+ )
+ shutil.copy2(out, backup)
+
+ save_json_atomic(out, payload)
+
+ print(f"[PASS] Artefato salvo: {out}")
+ print("[ASSEMBLER] Consumir module_params_fragment deste JSON.")
+ print("=" * 82)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_6_homography_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/_6_homography_calibration_tool.py
new file mode 100644
index 000000000..064fde68f
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/utils/_6_homography_calibration_tool.py
@@ -0,0 +1,4904 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+homography_calibration_production.py
+====================================
+
+Calibrador FINAL de produção para alinhamento geométrico RGB / RE / NIR
+do módulo OAK-FFC-3P usando ChArUco.
+
+Topologia oficial
+-----------------
+ CAM_A = RGB = OV9782 1280x800
+ OU
+ AR0234 1920x1200
+
+ CAM_B = RE = OV9282 1280x800
+ CAM_C = NIR = OV9282 1280x800
+
+Princípio geométrico
+--------------------
+A homografia é estimada DIRETAMENTE entre os espaços nativos:
+
+ H_RE_to_RGB : coordenadas RE 1280x800 -> coordenadas RGB nativas
+ H_NIR_to_RGB : coordenadas NIR 1280x800 -> coordenadas RGB nativas
+
+Portanto:
+ - OV9782: destino RGB = 1280x800
+ - AR0234: destino RGB = 1920x1200
+
+NÃO redimensionamos RE/NIR para o tamanho da RGB antes de detectar pontos.
+Isso seria geometricamente incorreto para uma calibração de produto.
+
+Domínio da calibração
+---------------------
+As imagens usadas aqui são:
+ RGB -> saída ISP full-resolution
+ RE -> saída MONO full-resolution
+ NIR -> saída MONO full-resolution
+
+Nenhum undistort/remap externo é aplicado nesta ferramenta.
+
+O JSON registra explicitamente:
+ coordinate_space = "native_stream_no_external_undistort"
+
+Assim o assembler final do module_params sabe em qual espaço a matriz nasceu.
+Se uma etapa posterior introduzir remap/undistort antes da homografia, o
+assembler/runtime deve respeitar/compor os espaços corretamente.
+
+Por que ChArUco?
+----------------
+O ChArUco fornece IDs comuns subpixel entre as três câmeras e reduz o erro
+humano da seleção manual. Esta versão também:
+ - captura triplets frescos;
+ - valida coerência temporal;
+ - rejeita amostras repetidas;
+ - exige distribuição espacial pelo FOV;
+ - rejeita amostras incompatíveis com o mesmo plano físico;
+ - faz RANSAC;
+ - faz validação por AMOSTRA (leave-one-sample-out);
+ - calcula overlap útil;
+ - só promove perfil que passa no acceptance test.
+
+Perfis de altura/plano
+----------------------
+Uma única homografia é válida para UM plano físico.
+
+Exemplos do projeto:
+ baixa -> plano mais distante / chão
+ media -> plano médio
+ alta -> plano mais próximo
+
+Execute uma sessão por perfil, mantendo o ChArUco NO MESMO PLANO durante
+todas as amostras daquela sessão.
+
+Exemplos:
+---------
+ python homography_calibration_production.py ^
+ --profile-name media ^
+ --depth-cm 66
+
+ python homography_calibration_production.py ^
+ --profile-name baixa ^
+ --depth-cm 120
+
+ python homography_calibration_production.py ^
+ --profile-name alta ^
+ --depth-cm 36
+
+Fluxo
+-----
+1. PRE-FLIGHT:
+ - ChArUco visível nas três câmeras
+ - AE estabiliza
+ - ENTER congela exposição/ISO
+
+2. CAPTURA:
+ - mova o ChArUco lateralmente DENTRO DO MESMO PLANO
+ - mantenha o tabuleiro coplanar ao plano calibrado
+ - G captura uma amostra tripla nova
+ - a ferramenta rejeita detecção ruim / repetida / dessincronizada
+
+3. Quando houver amostras suficientes e boa cobertura:
+ - A calcula e executa acceptance
+
+4. PASS:
+ - candidate auditável é salvo
+ - perfil é promovido para homography_calibration_v4.json
+ - outros perfis previamente aprovados são preservados
+
+5. FAIL/CANCEL:
+ - candidate é preservado
+ - calibração ativa anterior NÃO é alterada
+
+Teclas
+------
+ G captura nova amostra ChArUco
+ A calcula / tenta aprovar perfil
+ 2 overlay RGB + RE
+ 3 overlay RGB + NIR
+ TAB alterna RE/NIR
+ V limpa todas as amostras da sessão
+ S salva snapshot da tela
+ Q/ESC cancela
+
+Dependências
+------------
+ pip install depthai opencv-contrib-python numpy
+
+Artefato consumível pelo assembler
+----------------------------------
+O JSON ativo contém um module_params_fragment com fusion_config/homography_profiles.
+O assembler final continua sendo a autoridade que monta module_params.
+
+Schema:
+ multispec_homography_calibration_v4
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import math
+import os
+import shutil
+import time
+from collections import deque
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Optional, Tuple, List, Any
+
+import cv2
+import depthai as dai
+import numpy as np
+
+
+# ============================================================
+# Contrato do produto
+# ============================================================
+
+ROLES = ("rgb", "re", "nir")
+SPEC_ROLES = ("re", "nir")
+
+PRODUCT_TOPOLOGY = {
+ "rgb": {
+ "socket": "CAM_A",
+ "allowed_sensors": ("OV9782", "AR0234"),
+ },
+ "re": {
+ "socket": "CAM_B",
+ "allowed_sensors": ("OV9282",),
+ },
+ "nir": {
+ "socket": "CAM_C",
+ "allowed_sensors": ("OV9282",),
+ },
+}
+
+RGB_SENSOR_MODES = {
+ "OV9782": {
+ "resolution_enum": "THE_800_P",
+ "width": 1280,
+ "height": 800,
+ },
+ "AR0234": {
+ "resolution_enum": "THE_1200_P",
+ "width": 1920,
+ "height": 1200,
+ },
+}
+
+MONO_SENSOR_MODES = {
+ "OV9282": {
+ "resolution_enum": "THE_800_P",
+ "width": 1280,
+ "height": 800,
+ },
+}
+
+SCHEMA = "multispec_homography_calibration_v4"
+COORDINATE_SPACE = "native_stream_no_external_undistort"
+
+
+# ============================================================
+# QA defaults
+# ============================================================
+
+DEFAULT_QA = {
+ # Captura
+ "sync_tolerance_ms": 35.0,
+ "min_markers_each": 4,
+ "min_charuco_corners_each": 8,
+ "min_common_corners_each_pair": 8,
+
+ # Dataset
+ "min_samples": 8,
+ "recommended_samples": 12,
+ "min_total_pairs": 70,
+ "min_unique_charuco_ids": 18,
+
+ # Repetição de pose
+ "duplicate_center_distance_norm": 0.025,
+ "duplicate_scale_delta": 0.045,
+
+ # RANSAC
+ "ransac_reproj_threshold_px": 3.0,
+ "ransac_confidence": 0.999,
+ "ransac_max_iters": 10000,
+
+ # Rejeição de amostra planar incompatível
+ "sample_reject_median_error_px": 4.0,
+ "max_sample_rejection_rounds": 2,
+
+ # Acceptance in-sample final
+ "inlier_ratio_warning": 0.95,
+ "inlier_ratio_bad": 0.90,
+
+ "fit_median_error_warning_px": 1.20,
+ "fit_median_error_bad_px": 2.00,
+
+ "fit_p95_error_warning_px": 2.50,
+ "fit_p95_error_bad_px": 4.00,
+
+ # Leave-one-sample-out
+ "cv_median_error_warning_px": 1.60,
+ "cv_median_error_bad_px": 2.60,
+
+ "cv_p95_error_warning_px": 3.20,
+ "cv_p95_error_bad_px": 4.80,
+
+ "cv_max_sample_median_warning_px": 2.60,
+ "cv_max_sample_median_bad_px": 4.00,
+
+ # Cobertura no destino RGB
+ "coverage_span_x_warning": 0.60,
+ "coverage_span_x_bad": 0.48,
+
+ "coverage_span_y_warning": 0.55,
+ "coverage_span_y_bad": 0.43,
+
+ "coverage_hull_warning": 0.24,
+ "coverage_hull_bad": 0.15,
+
+ # Overlap útil das imagens
+ "overlap_warning": 0.80,
+ "overlap_bad": 0.70,
+
+ # Sanidade matricial
+ "condition_number_warning": 1.0e5,
+ "condition_number_bad": 1.0e7,
+
+ # Exposição manual
+ "exposure_rel_tolerance": 0.01,
+ "exposure_abs_tolerance_us": 20,
+ "iso_tolerance": 5,
+}
+
+
+# ============================================================
+# Helpers
+# ============================================================
+
+def now_str() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def session_stamp() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+
+
+def ensure_dir(path: str | Path):
+ Path(path).mkdir(parents=True, exist_ok=True)
+
+
+def save_json_atomic(path: str | Path, data: dict):
+ p = Path(path)
+ ensure_dir(p.parent)
+
+ tmp = p.with_suffix(p.suffix + ".tmp")
+
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.flush()
+ os.fsync(f.fileno())
+
+ os.replace(tmp, p)
+
+
+def load_json(path: str | Path) -> dict:
+ p = Path(path)
+ if not p.is_file():
+ return {}
+
+ with p.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def sha256_file(path: str | Path) -> str:
+ h = hashlib.sha256()
+
+ with open(path, "rb") as f:
+ while True:
+ chunk = f.read(1024 * 1024)
+ if not chunk:
+ break
+ h.update(chunk)
+
+ return h.hexdigest()
+
+
+def safe_int(v, default=None):
+ try:
+ if v is None:
+ return default
+ return int(v)
+ except Exception:
+ return default
+
+
+def safe_float(v, default=None):
+ try:
+ if v is None:
+ return default
+ return float(v)
+ except Exception:
+ return default
+
+
+def status_rank(s: str) -> int:
+ return {
+ "good": 0,
+ "warning": 1,
+ "bad": 2,
+ }.get(str(s).lower(), 2)
+
+
+def merge_status(a: str, b: str) -> str:
+ return a if status_rank(a) >= status_rank(b) else b
+
+
+def overlay_hud(
+ img,
+ lines,
+ x=12,
+ y=24,
+ font_scale=0.50,
+ line_step=20,
+ color=(255, 255, 255),
+):
+ yy = int(y)
+ h = img.shape[0]
+
+ for line in lines:
+ if yy > h - 6:
+ break
+
+ text = str(line)
+
+ cv2.putText(
+ img,
+ text,
+ (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale,
+ (0, 0, 0),
+ 3,
+ cv2.LINE_AA,
+ )
+
+ cv2.putText(
+ img,
+ text,
+ (int(x), yy),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ font_scale,
+ color,
+ 1,
+ cv2.LINE_AA,
+ )
+
+ yy += int(line_step)
+
+
+def socket_name(socket) -> str:
+ name = getattr(socket, "name", None)
+ if name:
+ return str(name)
+
+ text = str(socket)
+
+ for candidate in ("CAM_A", "CAM_B", "CAM_C", "CAM_D"):
+ if candidate in text:
+ return candidate
+
+ return text
+
+
+def get_socket(name: str):
+ mapping = {
+ "CAM_A": dai.CameraBoardSocket.CAM_A,
+ "CAM_B": dai.CameraBoardSocket.CAM_B,
+ "CAM_C": dai.CameraBoardSocket.CAM_C,
+ }
+
+ key = str(name).upper()
+
+ if key not in mapping:
+ raise ValueError(f"Socket inválido: {name}")
+
+ return mapping[key]
+
+
+def enum_if_exists(enum_cls, name: str):
+ return getattr(enum_cls, name, None)
+
+
+def supported_type_strings(feature) -> List[str]:
+ return [
+ str(v).upper()
+ for v in (getattr(feature, "supportedTypes", []) or [])
+ ]
+
+
+def feature_is_color(feature) -> bool:
+ types = supported_type_strings(feature)
+ sensor = str(getattr(feature, "sensorName", "") or "").upper()
+
+ if any("COLOR" in x for x in types):
+ return True
+
+ if any("MONO" in x for x in types):
+ return False
+
+ return sensor in {"OV9782", "AR0234"}
+
+
+def feature_is_mono(feature) -> bool:
+ types = supported_type_strings(feature)
+
+ if any("MONO" in x for x in types):
+ return True
+
+ if any("COLOR" in x for x in types):
+ return False
+
+ return not feature_is_color(feature)
+
+
+def resize_panel(img: np.ndarray, width: int, height: int) -> np.ndarray:
+ return cv2.resize(
+ img,
+ (int(width), int(height)),
+ interpolation=cv2.INTER_AREA,
+ )
+
+
+def gray_bgr(gray_u8: np.ndarray) -> np.ndarray:
+ if gray_u8.ndim == 3:
+ return gray_u8
+
+ return cv2.cvtColor(
+ gray_u8,
+ cv2.COLOR_GRAY2BGR,
+ )
+
+
+def colorize_spec(gray_u8: np.ndarray, role: str) -> np.ndarray:
+ if gray_u8.ndim == 3:
+ gray_u8 = cv2.cvtColor(
+ gray_u8,
+ cv2.COLOR_BGR2GRAY,
+ )
+
+ z = np.zeros_like(gray_u8)
+
+ if role == "re":
+ return np.dstack([z, z, gray_u8])
+
+ if role == "nir":
+ return np.dstack([gray_u8, gray_u8, z])
+
+ return gray_bgr(gray_u8)
+
+
+def normalize_gray_u8(img) -> np.ndarray:
+ if img is None:
+ raise ValueError("Imagem None")
+
+ if img.ndim == 3:
+ gray = cv2.cvtColor(
+ img,
+ cv2.COLOR_BGR2GRAY,
+ )
+ else:
+ gray = img
+
+ if gray.dtype == np.uint8:
+ return gray
+
+ arr = gray.astype(np.float32)
+ finite = arr[np.isfinite(arr)]
+
+ if finite.size == 0:
+ return np.zeros(arr.shape[:2], dtype=np.uint8)
+
+ lo = float(np.percentile(finite, 1.0))
+ hi = float(np.percentile(finite, 99.5))
+
+ if hi <= lo:
+ hi = lo + 1e-6
+
+ out = (arr - lo) / (hi - lo)
+
+ return np.clip(out * 255.0, 0, 255).astype(np.uint8)
+
+
+# ============================================================
+# Hardware
+# ============================================================
+
+@dataclass
+class CameraSpec:
+ role: str
+ socket_name: str
+ sensor_name: str
+ width: int
+ height: int
+ resolution_name: str
+ stream_name: str
+ control_name: str
+ is_color: bool
+ source: str
+
+
+def discover_cameras(mx_id: Optional[str]):
+ device_info = dai.DeviceInfo(mx_id) if mx_id else None
+
+ ctx = (
+ dai.Device(device_info)
+ if device_info is not None
+ else dai.Device()
+ )
+
+ with ctx as device:
+ actual_mx = None
+
+ for method_name in ("getMxId", "getDeviceId"):
+ fn = getattr(device, method_name, None)
+
+ if callable(fn):
+ try:
+ value = fn()
+
+ if value:
+ actual_mx = str(value)
+ break
+
+ except Exception:
+ pass
+
+ usb_speed = None
+
+ try:
+ usb_speed = str(device.getUsbSpeed())
+ except Exception:
+ pass
+
+ rows = []
+
+ for feature in device.getConnectedCameraFeatures():
+ rows.append({
+ "socket_name": socket_name(feature.socket),
+ "sensor_name": str(feature.sensorName or "").upper(),
+ "width": int(getattr(feature, "width", 0) or 0),
+ "height": int(getattr(feature, "height", 0) or 0),
+ "supported_types": supported_type_strings(feature),
+ "is_color": feature_is_color(feature),
+ "is_mono": feature_is_mono(feature),
+ "has_autofocus_ic": int(
+ getattr(feature, "hasAutofocusIC", 0) or 0
+ ),
+ })
+
+ return rows, actual_mx, usb_speed
+
+
+def validate_product_topology(camera_rows: list[dict]):
+ by_socket = {
+ row["socket_name"]: row
+ for row in camera_rows
+ }
+
+ errors = []
+
+ for role in ROLES:
+ contract = PRODUCT_TOPOLOGY[role]
+ socket = contract["socket"]
+
+ row = by_socket.get(socket)
+
+ if row is None:
+ errors.append(
+ f"{role.upper()}: {socket} ausente"
+ )
+ continue
+
+ sensor = row["sensor_name"]
+
+ if sensor not in contract["allowed_sensors"]:
+ errors.append(
+ f"{role.upper()}: {socket} sensor={sensor}, "
+ f"esperado={contract['allowed_sensors']}"
+ )
+
+ if role == "rgb" and not row["is_color"]:
+ errors.append(
+ f"RGB {socket}/{sensor} não anunciado COLOR"
+ )
+
+ if role in SPEC_ROLES and not row["is_mono"]:
+ errors.append(
+ f"{role.upper()} {socket}/{sensor} não anunciado MONO"
+ )
+
+ if errors:
+ raise RuntimeError(
+ "Topologia do produto inválida:\n - "
+ + "\n - ".join(errors)
+ )
+
+ return {
+ "valid": True,
+ "errors": [],
+ }
+
+
+def build_specs(camera_rows: list[dict]) -> Dict[str, CameraSpec]:
+ by_socket = {
+ row["socket_name"]: row
+ for row in camera_rows
+ }
+
+ specs = {}
+
+ for role in ROLES:
+ socket = PRODUCT_TOPOLOGY[role]["socket"]
+ row = by_socket[socket]
+ sensor = row["sensor_name"]
+
+ if role == "rgb":
+ cfg = RGB_SENSOR_MODES[sensor]
+ else:
+ cfg = MONO_SENSOR_MODES[sensor]
+
+ if row["width"] and row["height"]:
+ if (
+ row["width"],
+ row["height"],
+ ) != (
+ cfg["width"],
+ cfg["height"],
+ ):
+ raise RuntimeError(
+ f"{socket}/{sensor}: dimensão anunciada "
+ f"{row['width']}x{row['height']} != "
+ f"{cfg['width']}x{cfg['height']}"
+ )
+
+ specs[role] = CameraSpec(
+ role=role,
+ socket_name=socket,
+ sensor_name=sensor,
+ width=int(cfg["width"]),
+ height=int(cfg["height"]),
+ resolution_name=cfg["resolution_enum"],
+ stream_name=f"hcal_{role}",
+ control_name=f"hcal_ctrl_{role}",
+ is_color=(role == "rgb"),
+ source="ISP" if role == "rgb" else "MONO_OUT",
+ )
+
+ return specs
+
+
+def build_pipeline(specs: Dict[str, CameraSpec], args):
+ pipeline = dai.Pipeline()
+
+ isp_settings = {
+ "sharpness": None,
+ "luma_denoise": None,
+ "chroma_denoise": None,
+ "errors": [],
+ }
+
+ for role in ROLES:
+ spec = specs[role]
+ socket = get_socket(spec.socket_name)
+
+ if spec.is_color:
+ cam = pipeline.createColorCamera()
+ cam.setBoardSocket(socket)
+
+ enum_value = enum_if_exists(
+ dai.ColorCameraProperties.SensorResolution,
+ spec.resolution_name,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ f"DepthAI não expõe ColorCamera {spec.resolution_name}"
+ )
+
+ cam.setResolution(enum_value)
+ cam.setFps(float(args.fps))
+ cam.setInterleaved(False)
+
+ try:
+ cam.setColorOrder(
+ dai.ColorCameraProperties.ColorOrder.BGR
+ )
+ except Exception:
+ pass
+
+ try:
+ cam.setIspScale(1, 1)
+ except Exception:
+ pass
+
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+
+ try:
+ cam.initialControl.setAutoWhiteBalanceLock(False)
+ except Exception:
+ pass
+
+ for key, method_name, value in (
+ ("sharpness", "setSharpness", int(args.isp_sharpness)),
+ ("luma_denoise", "setLumaDenoise", int(args.isp_luma_denoise)),
+ ("chroma_denoise", "setChromaDenoise", int(args.isp_chroma_denoise)),
+ ):
+ fn = getattr(cam.initialControl, method_name, None)
+
+ if not callable(fn):
+ isp_settings["errors"].append(
+ f"{method_name}:unsupported"
+ )
+ continue
+
+ try:
+ fn(value)
+ isp_settings[key] = value
+ except Exception as exc:
+ isp_settings["errors"].append(
+ f"{method_name}:{exc}"
+ )
+
+ output = cam.isp
+
+ else:
+ cam = pipeline.createMonoCamera()
+ cam.setBoardSocket(socket)
+
+ enum_value = enum_if_exists(
+ dai.MonoCameraProperties.SensorResolution,
+ spec.resolution_name,
+ )
+
+ if enum_value is None:
+ raise RuntimeError(
+ f"DepthAI não expõe MonoCamera {spec.resolution_name}"
+ )
+
+ cam.setResolution(enum_value)
+ cam.setFps(float(args.fps))
+
+ try:
+ cam.initialControl.setAutoExposureEnable()
+ except Exception:
+ pass
+
+ output = cam.out
+
+ xout = pipeline.createXLinkOut()
+ xout.setStreamName(spec.stream_name)
+ output.link(xout.input)
+
+ xin = pipeline.createXLinkIn()
+ xin.setStreamName(spec.control_name)
+
+ if not hasattr(cam, "inputControl"):
+ raise RuntimeError(
+ f"{spec.socket_name}/{spec.sensor_name} sem inputControl"
+ )
+
+ xin.out.link(cam.inputControl)
+
+ return pipeline, isp_settings
+
+
+# ============================================================
+# Controles de exposição
+# ============================================================
+
+@dataclass
+class LockedControl:
+ role: str
+ exposure_time_us: int
+ sensitivity_iso: int
+ source: str
+
+
+def packet_controls(packet) -> dict:
+ exposure_us = None
+ iso = None
+ color_temperature = None
+ sequence_num = None
+ timestamp_s = None
+
+ try:
+ exp = packet.getExposureTime()
+
+ if hasattr(exp, "total_seconds"):
+ exposure_us = int(
+ round(exp.total_seconds() * 1_000_000)
+ )
+ else:
+ exposure_us = int(exp)
+
+ except Exception:
+ pass
+
+ try:
+ iso = int(packet.getSensitivity())
+ except Exception:
+ pass
+
+ try:
+ color_temperature = int(
+ packet.getColorTemperature()
+ )
+ except Exception:
+ pass
+
+ try:
+ sequence_num = int(
+ packet.getSequenceNum()
+ )
+ except Exception:
+ pass
+
+ for method_name in (
+ "getTimestampDevice",
+ "getTimestamp",
+ ):
+ fn = getattr(packet, method_name, None)
+
+ if callable(fn):
+ try:
+ ts = fn()
+
+ if hasattr(ts, "total_seconds"):
+ timestamp_s = float(
+ ts.total_seconds()
+ )
+ else:
+ timestamp_s = float(ts)
+
+ break
+ except Exception:
+ pass
+
+ return {
+ "exposure_time_us": exposure_us,
+ "sensitivity_iso": iso,
+ "color_temperature_k": color_temperature,
+ "sequence_num": sequence_num,
+ "timestamp_s": timestamp_s,
+ }
+
+
+def controls_match(
+ actual: dict,
+ target: LockedControl,
+ qa: dict,
+) -> bool:
+ exp = safe_int(
+ actual.get("exposure_time_us"),
+ None,
+ )
+
+ iso = safe_int(
+ actual.get("sensitivity_iso"),
+ None,
+ )
+
+ if exp is None or iso is None:
+ return False
+
+ exp_tol = max(
+ int(qa["exposure_abs_tolerance_us"]),
+ int(
+ round(
+ target.exposure_time_us
+ * qa["exposure_rel_tolerance"]
+ )
+ ),
+ )
+
+ return (
+ abs(exp - target.exposure_time_us)
+ <= exp_tol
+ and abs(iso - target.sensitivity_iso)
+ <= int(qa["iso_tolerance"])
+ )
+
+
+def send_manual_control(
+ queue,
+ lock: LockedControl,
+ rgb=False,
+):
+ ctrl = dai.CameraControl()
+
+ ctrl.setManualExposure(
+ int(lock.exposure_time_us),
+ int(lock.sensitivity_iso),
+ )
+
+ if rgb:
+ try:
+ ctrl.setAutoWhiteBalanceLock(True)
+ except Exception:
+ pass
+
+ queue.send(ctrl)
+
+
+# ============================================================
+# Runtime de aquisição
+# ============================================================
+
+class AcquisitionRuntime:
+ def __init__(
+ self,
+ device,
+ specs: Dict[str, CameraSpec],
+ args,
+ qa,
+ ):
+ self.device = device
+ self.specs = specs
+ self.args = args
+ self.qa = qa
+
+ self.output_queues = {
+ role: device.getOutputQueue(
+ name=spec.stream_name,
+ maxSize=3,
+ blocking=False,
+ )
+ for role, spec in specs.items()
+ }
+
+ self.control_queues = {
+ role: device.getInputQueue(
+ spec.control_name
+ )
+ for role, spec in specs.items()
+ }
+
+ self.frames = {
+ role: None
+ for role in ROLES
+ }
+
+ self.controls = {
+ role: {}
+ for role in ROLES
+ }
+
+ self.last_seq = {
+ role: None
+ for role in ROLES
+ }
+
+ self.frame_counter = {
+ role: 0
+ for role in ROLES
+ }
+
+ self.fps = {
+ role: 0.0
+ for role in ROLES
+ }
+
+ self._fps_counter = {
+ role: 0
+ for role in ROLES
+ }
+
+ self._fps_t0 = {
+ role: time.time()
+ for role in ROLES
+ }
+
+ def poll(self) -> set[str]:
+ fresh = set()
+
+ for role in ROLES:
+ packet = self.output_queues[role].tryGet()
+
+ if packet is None:
+ continue
+
+ ctrl = packet_controls(packet)
+ seq = ctrl.get("sequence_num")
+
+ if (
+ seq is not None
+ and seq == self.last_seq[role]
+ ):
+ continue
+
+ img = packet.getCvFrame()
+
+ if img is None:
+ continue
+
+ if img.ndim == 2:
+ img = np.ascontiguousarray(img)
+ else:
+ img = np.ascontiguousarray(img)
+
+ expected = (
+ self.specs[role].height,
+ self.specs[role].width,
+ )
+
+ if img.shape[:2] != expected:
+ raise RuntimeError(
+ f"{role.upper()}: frame {img.shape[:2]} != "
+ f"esperado {expected}"
+ )
+
+ self.frames[role] = img
+ self.controls[role] = ctrl
+ self.last_seq[role] = seq
+ self.frame_counter[role] += 1
+
+ fresh.add(role)
+
+ self._fps_counter[role] += 1
+
+ dt = time.time() - self._fps_t0[role]
+
+ if dt >= 1.0:
+ self.fps[role] = (
+ self._fps_counter[role]
+ / dt
+ )
+
+ self._fps_counter[role] = 0
+ self._fps_t0[role] = time.time()
+
+ return fresh
+
+ def wait_all(self, timeout=8.0):
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ self.poll()
+
+ if all(
+ self.frames[r] is not None
+ for r in ROLES
+ ):
+ return
+
+ time.sleep(0.003)
+
+ missing = [
+ r
+ for r in ROLES
+ if self.frames[r] is None
+ ]
+
+ raise TimeoutError(
+ f"Timeout aguardando câmeras: {missing}"
+ )
+
+ def send_manual(self, locked):
+ for role in ROLES:
+ send_manual_control(
+ self.control_queues[role],
+ locked[role],
+ rgb=(role == "rgb"),
+ )
+
+ def verify_manual(
+ self,
+ locked,
+ settle_frames=8,
+ timeout=10.0,
+ ):
+ matched = {
+ r: 0
+ for r in ROLES
+ }
+
+ seen = dict(self.last_seq)
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ fresh = self.poll()
+
+ for role in fresh:
+ seq = self.last_seq[role]
+
+ if seq == seen.get(role):
+ continue
+
+ seen[role] = seq
+
+ if controls_match(
+ self.controls[role],
+ locked[role],
+ self.qa,
+ ):
+ matched[role] += 1
+ else:
+ matched[role] = 0
+
+ if all(
+ matched[r] >= int(settle_frames)
+ for r in ROLES
+ ):
+ return
+
+ time.sleep(0.002)
+
+ raise RuntimeError(
+ "Não foi possível confirmar exposição manual: "
+ f"{matched}"
+ )
+
+ def fresh_triplet(
+ self,
+ timeout=3.0,
+ ):
+ """
+ Aguarda um frame NOVO de cada role após o pedido.
+ Retorna snapshots e metadados.
+ """
+ start_seq = dict(self.last_seq)
+
+ captured = {}
+ captured_controls = {}
+
+ t0 = time.time()
+
+ while time.time() - t0 < timeout:
+ fresh = self.poll()
+
+ for role in fresh:
+ seq = self.last_seq[role]
+
+ if (
+ role not in captured
+ and seq != start_seq.get(role)
+ ):
+ captured[role] = (
+ self.frames[role].copy()
+ )
+ captured_controls[role] = dict(
+ self.controls[role]
+ )
+
+ if len(captured) == 3:
+ break
+
+ time.sleep(0.001)
+
+ if len(captured) != 3:
+ missing = [
+ r
+ for r in ROLES
+ if r not in captured
+ ]
+
+ raise TimeoutError(
+ f"Triplet incompleto. Faltando: {missing}"
+ )
+
+ timestamps = [
+ captured_controls[r].get("timestamp_s")
+ for r in ROLES
+ ]
+
+ timestamps = [
+ float(x)
+ for x in timestamps
+ if x is not None
+ ]
+
+ if len(timestamps) == 3:
+ sync_dt_ms = (
+ max(timestamps) - min(timestamps)
+ ) * 1000.0
+ else:
+ sync_dt_ms = None
+
+ return {
+ "frames": captured,
+ "controls": captured_controls,
+ "sync_dt_ms": sync_dt_ms,
+ }
+
+
+# ============================================================
+# ChArUco compatibility layer
+# ============================================================
+
+def require_aruco():
+ if not hasattr(cv2, "aruco"):
+ raise RuntimeError(
+ "cv2.aruco indisponível. "
+ "Instale opencv-contrib-python."
+ )
+
+ if not (
+ hasattr(cv2.aruco, "CharucoBoard")
+ or hasattr(cv2.aruco, "CharucoBoard_create")
+ ):
+ raise RuntimeError(
+ "OpenCV sem CharucoBoard. "
+ "Use opencv-contrib-python."
+ )
+
+
+def get_aruco_dictionary(name: str):
+ require_aruco()
+
+ if not hasattr(cv2.aruco, name):
+ available = sorted(
+ x
+ for x in dir(cv2.aruco)
+ if x.startswith("DICT_")
+ )
+
+ raise ValueError(
+ f"Dicionário ArUco inválido: {name}. "
+ f"Disponíveis: {available}"
+ )
+
+ dict_id = getattr(cv2.aruco, name)
+
+ if hasattr(
+ cv2.aruco,
+ "getPredefinedDictionary",
+ ):
+ return cv2.aruco.getPredefinedDictionary(
+ dict_id
+ )
+
+ return cv2.aruco.Dictionary_get(
+ dict_id
+ )
+
+
+def create_charuco_board(
+ squares_x,
+ squares_y,
+ square_length,
+ marker_length,
+ dictionary,
+):
+ require_aruco()
+
+ if hasattr(cv2.aruco, "CharucoBoard"):
+ try:
+ return cv2.aruco.CharucoBoard(
+ (
+ int(squares_x),
+ int(squares_y),
+ ),
+ float(square_length),
+ float(marker_length),
+ dictionary,
+ )
+ except TypeError:
+ pass
+
+ if hasattr(
+ cv2.aruco,
+ "CharucoBoard_create",
+ ):
+ return cv2.aruco.CharucoBoard_create(
+ int(squares_x),
+ int(squares_y),
+ float(square_length),
+ float(marker_length),
+ dictionary,
+ )
+
+ raise RuntimeError(
+ "API CharucoBoard incompatível."
+ )
+
+
+def create_detector_params():
+ if hasattr(
+ cv2.aruco,
+ "DetectorParameters",
+ ):
+ params = cv2.aruco.DetectorParameters()
+ else:
+ params = cv2.aruco.DetectorParameters_create()
+
+ # Conservador, prioriza robustez.
+ for name, value in (
+ ("cornerRefinementMethod", getattr(cv2.aruco, "CORNER_REFINE_SUBPIX", 1)),
+ ("cornerRefinementWinSize", 5),
+ ("cornerRefinementMaxIterations", 40),
+ ("cornerRefinementMinAccuracy", 0.01),
+ ):
+ try:
+ setattr(params, name, value)
+ except Exception:
+ pass
+
+ return params
+
+
+def prepare_charuco_gray(
+ img,
+ equalize=False,
+ invert=False,
+):
+ gray = normalize_gray_u8(img)
+
+ if invert:
+ gray = 255 - gray
+
+ if equalize:
+ try:
+ clahe = cv2.createCLAHE(
+ clipLimit=2.0,
+ tileGridSize=(8, 8),
+ )
+ gray = clahe.apply(gray)
+ except Exception:
+ gray = cv2.equalizeHist(gray)
+
+ return gray
+
+
+def detect_markers_compat(
+ gray,
+ dictionary,
+ params,
+):
+ if hasattr(cv2.aruco, "ArucoDetector"):
+ detector = cv2.aruco.ArucoDetector(
+ dictionary,
+ params,
+ )
+
+ return detector.detectMarkers(
+ gray
+ )
+
+ return cv2.aruco.detectMarkers(
+ gray,
+ dictionary,
+ parameters=params,
+ )
+
+
+def interpolate_charuco_compat(
+ gray,
+ board,
+ marker_corners,
+ marker_ids,
+):
+ if marker_ids is None or len(marker_ids) == 0:
+ return None, None
+
+ # API moderna.
+ # IMPORTANTE: em OpenCV recente os argumentos posicionais 2/3 são
+ # charucoCorners/charucoIds de saída, não markerCorners/markerIds.
+ # Portanto usamos nomes explícitos para não trocar a semântica.
+ if hasattr(cv2.aruco, "CharucoDetector"):
+ try:
+ detector = cv2.aruco.CharucoDetector(
+ board
+ )
+
+ result = detector.detectBoard(
+ image=gray,
+ markerCorners=marker_corners,
+ markerIds=marker_ids,
+ )
+
+ if (
+ isinstance(result, tuple)
+ and len(result) >= 2
+ ):
+ return result[0], result[1]
+
+ except Exception:
+ # Fallback: deixa o próprio CharucoDetector detectar os markers.
+ try:
+ result = detector.detectBoard(
+ gray
+ )
+
+ if (
+ isinstance(result, tuple)
+ and len(result) >= 2
+ ):
+ return result[0], result[1]
+
+ except Exception:
+ pass
+
+ # API legacy estável
+ if hasattr(
+ cv2.aruco,
+ "interpolateCornersCharuco",
+ ):
+ ret, corners, ids = (
+ cv2.aruco.interpolateCornersCharuco(
+ markerCorners=marker_corners,
+ markerIds=marker_ids,
+ image=gray,
+ board=board,
+ )
+ )
+
+ if ret is None or ret <= 0:
+ return None, None
+
+ return corners, ids
+
+ raise RuntimeError(
+ "Nenhuma API ChArUco compatível encontrada."
+ )
+
+
+def refine_charuco_subpix(
+ gray,
+ corners,
+):
+ if corners is None or len(corners) == 0:
+ return corners
+
+ pts = np.asarray(
+ corners,
+ dtype=np.float32,
+ ).reshape(-1, 1, 2)
+
+ try:
+ cv2.cornerSubPix(
+ gray,
+ pts,
+ (4, 4),
+ (-1, -1),
+ (
+ cv2.TERM_CRITERIA_EPS
+ + cv2.TERM_CRITERIA_MAX_ITER,
+ 40,
+ 0.01,
+ ),
+ )
+ except Exception:
+ pass
+
+ return pts
+
+
+def detect_charuco_variant(
+ img,
+ board,
+ dictionary,
+ params,
+ equalize,
+ invert,
+):
+ gray = prepare_charuco_gray(
+ img,
+ equalize=equalize,
+ invert=invert,
+ )
+
+ marker_corners, marker_ids, rejected = (
+ detect_markers_compat(
+ gray,
+ dictionary,
+ params,
+ )
+ )
+
+ marker_count = (
+ 0
+ if marker_ids is None
+ else int(len(marker_ids))
+ )
+
+ if marker_count == 0:
+ return {
+ "points": {},
+ "markers": 0,
+ "corners": 0,
+ "equalize": equalize,
+ "invert": invert,
+ "gray": gray,
+ }
+
+ charuco_corners, charuco_ids = (
+ interpolate_charuco_compat(
+ gray,
+ board,
+ marker_corners,
+ marker_ids,
+ )
+ )
+
+ if (
+ charuco_corners is None
+ or charuco_ids is None
+ ):
+ return {
+ "points": {},
+ "markers": marker_count,
+ "corners": 0,
+ "equalize": equalize,
+ "invert": invert,
+ "gray": gray,
+ }
+
+ charuco_corners = (
+ refine_charuco_subpix(
+ gray,
+ charuco_corners,
+ )
+ )
+
+ ids_flat = np.asarray(
+ charuco_ids
+ ).reshape(-1)
+
+ pts = np.asarray(
+ charuco_corners
+ ).reshape(-1, 2)
+
+ point_by_id = {
+ int(cid): (
+ float(pt[0]),
+ float(pt[1]),
+ )
+ for cid, pt in zip(
+ ids_flat,
+ pts,
+ )
+ }
+
+ return {
+ "points": point_by_id,
+ "markers": marker_count,
+ "corners": len(point_by_id),
+ "equalize": bool(equalize),
+ "invert": bool(invert),
+ "gray": gray,
+ }
+
+
+def detect_charuco_best(
+ img,
+ board,
+ dictionary,
+ params,
+ allow_invert=True,
+):
+ """
+ Tenta variantes de contraste e escolhe a que produz mais cantos.
+ As coordenadas continuam no frame nativo original.
+ """
+ variants = [
+ (False, False),
+ (True, False),
+ ]
+
+ if allow_invert:
+ variants += [
+ (False, True),
+ (True, True),
+ ]
+
+ results = [
+ detect_charuco_variant(
+ img,
+ board,
+ dictionary,
+ params,
+ equalize=eq,
+ invert=inv,
+ )
+ for eq, inv in variants
+ ]
+
+ results.sort(
+ key=lambda x: (
+ x["corners"],
+ x["markers"],
+ ),
+ reverse=True,
+ )
+
+ return results[0]
+
+
+# ============================================================
+# Sample
+# ============================================================
+
+@dataclass
+class PoseSignature:
+ center_x_norm: float
+ center_y_norm: float
+ bbox_area_norm: float
+
+
+def pose_signature(
+ rgb_points: dict,
+ rgb_shape_hw,
+) -> PoseSignature:
+ if not rgb_points:
+ return PoseSignature(
+ 0.0,
+ 0.0,
+ 0.0,
+ )
+
+ pts = np.array(
+ list(rgb_points.values()),
+ dtype=np.float32,
+ )
+
+ h, w = rgb_shape_hw
+
+ x0 = float(np.min(pts[:, 0]))
+ x1 = float(np.max(pts[:, 0]))
+ y0 = float(np.min(pts[:, 1]))
+ y1 = float(np.max(pts[:, 1]))
+
+ cx = 0.5 * (x0 + x1)
+ cy = 0.5 * (y0 + y1)
+
+ area = (
+ max(0.0, x1 - x0)
+ * max(0.0, y1 - y0)
+ )
+
+ return PoseSignature(
+ center_x_norm=cx / max(float(w), 1.0),
+ center_y_norm=cy / max(float(h), 1.0),
+ bbox_area_norm=area / max(
+ float(w * h),
+ 1.0,
+ ),
+ )
+
+
+def is_duplicate_pose(
+ signature: PoseSignature,
+ prior_signatures: list[PoseSignature],
+ qa: dict,
+):
+ for prior in prior_signatures:
+ dc = math.hypot(
+ signature.center_x_norm
+ - prior.center_x_norm,
+ signature.center_y_norm
+ - prior.center_y_norm,
+ )
+
+ da = abs(
+ signature.bbox_area_norm
+ - prior.bbox_area_norm
+ )
+
+ if (
+ dc
+ < qa["duplicate_center_distance_norm"]
+ and da
+ < qa["duplicate_scale_delta"]
+ ):
+ return True, {
+ "center_distance_norm": dc,
+ "area_delta_norm": da,
+ }
+
+ return False, None
+
+
+def common_pairs(
+ rgb_points: dict,
+ spec_points: dict,
+):
+ ids = sorted(
+ set(rgb_points.keys())
+ & set(spec_points.keys())
+ )
+
+ src = [
+ spec_points[cid]
+ for cid in ids
+ ]
+
+ dst = [
+ rgb_points[cid]
+ for cid in ids
+ ]
+
+ return ids, src, dst
+
+
+# ============================================================
+# Homography math
+# ============================================================
+
+def find_homography_ransac(
+ src_pts,
+ dst_pts,
+ qa,
+):
+ if (
+ len(src_pts) < 4
+ or len(dst_pts) < 4
+ or len(src_pts) != len(dst_pts)
+ ):
+ return None, None
+
+ src = np.asarray(
+ src_pts,
+ dtype=np.float32,
+ )
+
+ dst = np.asarray(
+ dst_pts,
+ dtype=np.float32,
+ )
+
+ H, mask = cv2.findHomography(
+ src,
+ dst,
+ method=cv2.RANSAC,
+ ransacReprojThreshold=float(
+ qa["ransac_reproj_threshold_px"]
+ ),
+ maxIters=int(
+ qa["ransac_max_iters"]
+ ),
+ confidence=float(
+ qa["ransac_confidence"]
+ ),
+ )
+
+ if H is None:
+ return None, mask
+
+ H = np.asarray(
+ H,
+ dtype=np.float64,
+ )
+
+ if abs(H[2, 2]) > 1e-12:
+ H = H / H[2, 2]
+
+ return H, mask
+
+
+def project_points(
+ H,
+ src_pts,
+):
+ src = np.asarray(
+ src_pts,
+ dtype=np.float32,
+ ).reshape(-1, 1, 2)
+
+ out = cv2.perspectiveTransform(
+ src,
+ np.asarray(H, dtype=np.float64),
+ )
+
+ return out.reshape(-1, 2)
+
+
+def reprojection_errors(
+ H,
+ src_pts,
+ dst_pts,
+):
+ projected = project_points(
+ H,
+ src_pts,
+ )
+
+ dst = np.asarray(
+ dst_pts,
+ dtype=np.float32,
+ )
+
+ return np.linalg.norm(
+ projected - dst,
+ axis=1,
+ )
+
+
+def error_stats(errors):
+ e = np.asarray(
+ errors,
+ dtype=np.float64,
+ )
+
+ if e.size == 0:
+ return {
+ "count": 0,
+ "mean_px": None,
+ "median_px": None,
+ "p95_px": None,
+ "max_px": None,
+ }
+
+ return {
+ "count": int(e.size),
+ "mean_px": float(np.mean(e)),
+ "median_px": float(np.median(e)),
+ "p95_px": float(
+ np.percentile(e, 95)
+ ),
+ "max_px": float(np.max(e)),
+ }
+
+
+def coverage_stats(
+ dst_pts,
+ image_size_wh,
+):
+ w, h = image_size_wh
+
+ pts = np.asarray(
+ dst_pts,
+ dtype=np.float32,
+ )
+
+ if len(pts) < 3:
+ return {
+ "span_x": 0.0,
+ "span_y": 0.0,
+ "hull_area_fraction": 0.0,
+ }
+
+ x0 = float(np.min(pts[:, 0]))
+ x1 = float(np.max(pts[:, 0]))
+ y0 = float(np.min(pts[:, 1]))
+ y1 = float(np.max(pts[:, 1]))
+
+ hull = cv2.convexHull(
+ pts.reshape(-1, 1, 2)
+ )
+
+ hull_area = float(
+ cv2.contourArea(hull)
+ )
+
+ return {
+ "span_x": (
+ (x1 - x0)
+ / max(float(w), 1.0)
+ ),
+ "span_y": (
+ (y1 - y0)
+ / max(float(h), 1.0)
+ ),
+ "hull_area_fraction": (
+ hull_area
+ / max(float(w * h), 1.0)
+ ),
+ "bbox": [
+ x0,
+ y0,
+ x1,
+ y1,
+ ],
+ }
+
+
+def overlap_fraction(
+ H,
+ source_size_wh,
+ destination_size_wh,
+):
+ sw, sh = source_size_wh
+ dw, dh = destination_size_wh
+
+ mask = np.ones(
+ (int(sh), int(sw)),
+ dtype=np.uint8,
+ )
+
+ warped = cv2.warpPerspective(
+ mask,
+ np.asarray(H, dtype=np.float64),
+ (int(dw), int(dh)),
+ flags=cv2.INTER_NEAREST,
+ borderMode=cv2.BORDER_CONSTANT,
+ borderValue=0,
+ )
+
+ return float(
+ (warped > 0).mean()
+ )
+
+
+def homography_condition(H):
+ Hn = np.asarray(
+ H,
+ dtype=np.float64,
+ )
+
+ if abs(Hn[2, 2]) > 1e-12:
+ Hn = Hn / Hn[2, 2]
+
+ try:
+ return float(
+ np.linalg.cond(Hn)
+ )
+ except Exception:
+ return float("inf")
+
+
+# ============================================================
+# Dataset assembly / sample rejection
+# ============================================================
+
+def role_pairs_from_samples(
+ samples,
+ role,
+ sample_ids_filter=None,
+):
+ src = []
+ dst = []
+ sample_ids = []
+ charuco_ids = []
+
+ allowed = (
+ None
+ if sample_ids_filter is None
+ else set(sample_ids_filter)
+ )
+
+ for sample in samples:
+ sid = int(sample["sample_id"])
+
+ if (
+ allowed is not None
+ and sid not in allowed
+ ):
+ continue
+
+ pair = sample["pairs"][role]
+
+ for cid, spt, dpt in zip(
+ pair["charuco_ids"],
+ pair["src_points"],
+ pair["dst_points"],
+ ):
+ src.append(spt)
+ dst.append(dpt)
+ sample_ids.append(sid)
+ charuco_ids.append(int(cid))
+
+ return {
+ "src": src,
+ "dst": dst,
+ "sample_ids": sample_ids,
+ "charuco_ids": charuco_ids,
+ }
+
+
+def per_sample_residuals(
+ H,
+ pairs,
+):
+ errors = reprojection_errors(
+ H,
+ pairs["src"],
+ pairs["dst"],
+ )
+
+ by_sample = {}
+
+ for err, sid in zip(
+ errors,
+ pairs["sample_ids"],
+ ):
+ by_sample.setdefault(
+ int(sid),
+ [],
+ ).append(float(err))
+
+ result = {}
+
+ for sid, values in by_sample.items():
+ st = error_stats(values)
+
+ result[int(sid)] = st
+
+ return result
+
+
+def reject_incompatible_samples(
+ samples,
+ role,
+ qa,
+):
+ retained = [
+ int(s["sample_id"])
+ for s in samples
+ ]
+
+ rejected = []
+
+ rounds = []
+
+ for round_idx in range(
+ int(qa["max_sample_rejection_rounds"]) + 1
+ ):
+ pairs = role_pairs_from_samples(
+ samples,
+ role,
+ sample_ids_filter=retained,
+ )
+
+ if len(pairs["src"]) < 4:
+ break
+
+ H, mask = find_homography_ransac(
+ pairs["src"],
+ pairs["dst"],
+ qa,
+ )
+
+ if H is None:
+ break
+
+ residual = per_sample_residuals(
+ H,
+ pairs,
+ )
+
+ bad = [
+ sid
+ for sid, st in residual.items()
+ if (
+ st["median_px"] is not None
+ and st["median_px"]
+ > qa["sample_reject_median_error_px"]
+ )
+ ]
+
+ rounds.append({
+ "round": round_idx,
+ "retained": list(retained),
+ "bad_candidates": list(bad),
+ "per_sample": residual,
+ })
+
+ if not bad:
+ break
+
+ # Remove primeiro o pior, mantendo mínimo de amostras.
+ worst = max(
+ bad,
+ key=lambda sid: residual[sid]["median_px"],
+ )
+
+ if (
+ len(retained) - 1
+ < int(qa["min_samples"])
+ ):
+ break
+
+ retained.remove(worst)
+ rejected.append(worst)
+
+ return {
+ "retained_sample_ids": retained,
+ "rejected_sample_ids": rejected,
+ "rounds": rounds,
+ }
+
+
+# ============================================================
+# Leave-one-sample-out validation
+# ============================================================
+
+def leave_one_sample_out_cv(
+ samples,
+ role,
+ retained_sample_ids,
+ qa,
+):
+ retained = list(
+ sorted(set(retained_sample_ids))
+ )
+
+ sample_reports = []
+ all_errors = []
+
+ if len(retained) < 5:
+ return {
+ "valid": False,
+ "reason": "menos_de_5_amostras",
+ "samples": [],
+ "overall": error_stats([]),
+ }
+
+ for held_out in retained:
+ train_ids = [
+ sid
+ for sid in retained
+ if sid != held_out
+ ]
+
+ train = role_pairs_from_samples(
+ samples,
+ role,
+ train_ids,
+ )
+
+ test = role_pairs_from_samples(
+ samples,
+ role,
+ [held_out],
+ )
+
+ if (
+ len(train["src"]) < 4
+ or len(test["src"]) == 0
+ ):
+ continue
+
+ H, _ = find_homography_ransac(
+ train["src"],
+ train["dst"],
+ qa,
+ )
+
+ if H is None:
+ continue
+
+ errors = reprojection_errors(
+ H,
+ test["src"],
+ test["dst"],
+ )
+
+ all_errors.extend(
+ [float(x) for x in errors]
+ )
+
+ sample_reports.append({
+ "held_out_sample_id": int(held_out),
+ "train_samples": len(train_ids),
+ "test_points": len(test["src"]),
+ "error": error_stats(errors),
+ })
+
+ return {
+ "valid": len(sample_reports) == len(retained),
+ "samples": sample_reports,
+ "overall": error_stats(all_errors),
+ }
+
+
+# ============================================================
+# Role model
+# ============================================================
+
+def build_role_model(
+ samples,
+ role,
+ specs,
+ qa,
+):
+ rejection = reject_incompatible_samples(
+ samples,
+ role,
+ qa,
+ )
+
+ retained = rejection[
+ "retained_sample_ids"
+ ]
+
+ pairs = role_pairs_from_samples(
+ samples,
+ role,
+ retained,
+ )
+
+ H, inlier_mask = find_homography_ransac(
+ pairs["src"],
+ pairs["dst"],
+ qa,
+ )
+
+ if H is None:
+ return {
+ "status": "bad",
+ "reasons": [
+ "findHomography_failed"
+ ],
+ "role": role,
+ "rejection": rejection,
+ }
+
+ errors = reprojection_errors(
+ H,
+ pairs["src"],
+ pairs["dst"],
+ )
+
+ if inlier_mask is None:
+ inlier = np.ones(
+ len(errors),
+ dtype=bool,
+ )
+ else:
+ inlier = (
+ np.asarray(inlier_mask)
+ .reshape(-1)
+ .astype(bool)
+ )
+
+ inlier_errors = (
+ errors[inlier]
+ if np.any(inlier)
+ else errors
+ )
+
+ fit = error_stats(
+ inlier_errors
+ )
+
+ inlier_ratio = float(
+ np.sum(inlier)
+ / max(len(inlier), 1)
+ )
+
+ unique_ids = sorted(
+ set(pairs["charuco_ids"])
+ )
+
+ dst_size = (
+ specs["rgb"].width,
+ specs["rgb"].height,
+ )
+
+ src_size = (
+ specs[role].width,
+ specs[role].height,
+ )
+
+ coverage = coverage_stats(
+ pairs["dst"],
+ dst_size,
+ )
+
+ overlap = overlap_fraction(
+ H,
+ src_size,
+ dst_size,
+ )
+
+ condition = homography_condition(H)
+
+ cv_report = leave_one_sample_out_cv(
+ samples,
+ role,
+ retained,
+ qa,
+ )
+
+ cv_overall = cv_report["overall"]
+
+ cv_sample_medians = [
+ float(x["error"]["median_px"])
+ for x in cv_report["samples"]
+ if x["error"]["median_px"] is not None
+ ]
+
+ cv_max_sample_median = (
+ max(cv_sample_medians)
+ if cv_sample_medians
+ else None
+ )
+
+ # Inverse validation no conjunto final
+ try:
+ H_inv = np.linalg.inv(H)
+
+ reverse_errors = reprojection_errors(
+ H_inv,
+ pairs["dst"],
+ pairs["src"],
+ )
+
+ reverse_fit = error_stats(
+ reverse_errors
+ )
+
+ except Exception:
+ H_inv = None
+ reverse_fit = error_stats([])
+
+ # QA
+ status = "good"
+ reasons = []
+
+ def check_low(
+ value,
+ warning,
+ bad,
+ name,
+ ):
+ nonlocal status
+
+ if value < bad:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"{name}_bad:{value:.4f}"
+ )
+
+ elif value < warning:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"{name}_warning:{value:.4f}"
+ )
+
+ def check_high(
+ value,
+ warning,
+ bad,
+ name,
+ ):
+ nonlocal status
+
+ if value > bad:
+ status = merge_status(status, "bad")
+ reasons.append(
+ f"{name}_bad:{value:.4f}"
+ )
+
+ elif value > warning:
+ status = merge_status(status, "warning")
+ reasons.append(
+ f"{name}_warning:{value:.4f}"
+ )
+
+ if len(retained) < qa["min_samples"]:
+ status = "bad"
+ reasons.append(
+ f"samples_bad:{len(retained)}"
+ )
+
+ if len(pairs["src"]) < qa["min_total_pairs"]:
+ status = "bad"
+ reasons.append(
+ f"pairs_bad:{len(pairs['src'])}"
+ )
+
+ if len(unique_ids) < qa["min_unique_charuco_ids"]:
+ status = "bad"
+ reasons.append(
+ f"unique_ids_bad:{len(unique_ids)}"
+ )
+
+ check_low(
+ inlier_ratio,
+ qa["inlier_ratio_warning"],
+ qa["inlier_ratio_bad"],
+ "inlier_ratio",
+ )
+
+ if fit["median_px"] is not None:
+ check_high(
+ fit["median_px"],
+ qa["fit_median_error_warning_px"],
+ qa["fit_median_error_bad_px"],
+ "fit_median_px",
+ )
+
+ if fit["p95_px"] is not None:
+ check_high(
+ fit["p95_px"],
+ qa["fit_p95_error_warning_px"],
+ qa["fit_p95_error_bad_px"],
+ "fit_p95_px",
+ )
+
+ if not cv_report["valid"]:
+ status = "bad"
+ reasons.append(
+ "cross_validation_invalid"
+ )
+
+ if cv_overall["median_px"] is not None:
+ check_high(
+ cv_overall["median_px"],
+ qa["cv_median_error_warning_px"],
+ qa["cv_median_error_bad_px"],
+ "cv_median_px",
+ )
+
+ if cv_overall["p95_px"] is not None:
+ check_high(
+ cv_overall["p95_px"],
+ qa["cv_p95_error_warning_px"],
+ qa["cv_p95_error_bad_px"],
+ "cv_p95_px",
+ )
+
+ if cv_max_sample_median is not None:
+ check_high(
+ cv_max_sample_median,
+ qa["cv_max_sample_median_warning_px"],
+ qa["cv_max_sample_median_bad_px"],
+ "cv_max_sample_median_px",
+ )
+
+ check_low(
+ coverage["span_x"],
+ qa["coverage_span_x_warning"],
+ qa["coverage_span_x_bad"],
+ "coverage_span_x",
+ )
+
+ check_low(
+ coverage["span_y"],
+ qa["coverage_span_y_warning"],
+ qa["coverage_span_y_bad"],
+ "coverage_span_y",
+ )
+
+ check_low(
+ coverage["hull_area_fraction"],
+ qa["coverage_hull_warning"],
+ qa["coverage_hull_bad"],
+ "coverage_hull",
+ )
+
+ check_low(
+ overlap,
+ qa["overlap_warning"],
+ qa["overlap_bad"],
+ "overlap",
+ )
+
+ check_high(
+ condition,
+ qa["condition_number_warning"],
+ qa["condition_number_bad"],
+ "condition_number",
+ )
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "role": role,
+ "homography": H.tolist(),
+ "homography_inverse": (
+ H_inv.tolist()
+ if H_inv is not None
+ else None
+ ),
+ "source_size": [
+ int(src_size[0]),
+ int(src_size[1]),
+ ],
+ "destination_size": [
+ int(dst_size[0]),
+ int(dst_size[1]),
+ ],
+ "retained_sample_ids": retained,
+ "rejected_sample_ids": rejection[
+ "rejected_sample_ids"
+ ],
+ "sample_rejection": rejection,
+ "points": int(len(pairs["src"])),
+ "unique_charuco_ids": len(unique_ids),
+ "unique_charuco_id_list": unique_ids,
+ "inliers": int(np.sum(inlier)),
+ "outliers": int(
+ len(inlier) - np.sum(inlier)
+ ),
+ "inlier_ratio": inlier_ratio,
+ "fit_error": fit,
+ "reverse_fit_error_source_px": reverse_fit,
+ "cross_validation": cv_report,
+ "cv_max_sample_median_px": cv_max_sample_median,
+ "coverage": coverage,
+ "overlap_fraction": overlap,
+ "condition_number": condition,
+ }
+
+
+# ============================================================
+# Profile QA
+# ============================================================
+
+def evaluate_profile(
+ samples,
+ specs,
+ qa,
+):
+ models = {
+ role: build_role_model(
+ samples,
+ role,
+ specs,
+ qa,
+ )
+ for role in SPEC_ROLES
+ }
+
+ status = "good"
+ reasons = []
+
+ for role in SPEC_ROLES:
+ model = models[role]
+
+ status = merge_status(
+ status,
+ model["status"],
+ )
+
+ reasons.extend(
+ [
+ f"{role}:{x}"
+ for x in model.get(
+ "reasons",
+ [],
+ )
+ ]
+ )
+
+ retained_common = sorted(
+ set(
+ models["re"].get(
+ "retained_sample_ids",
+ [],
+ )
+ )
+ & set(
+ models["nir"].get(
+ "retained_sample_ids",
+ [],
+ )
+ )
+ )
+
+ return {
+ "status": status,
+ "reasons": reasons,
+ "models": models,
+ "common_retained_sample_ids": retained_common,
+ "common_frames_used": len(
+ retained_common
+ ),
+ }
+
+
+# ============================================================
+# Detection / sample capture
+# ============================================================
+
+def detect_triplet(
+ triplet,
+ board,
+ dictionary,
+ detector_params,
+ qa,
+ allow_invert=True,
+):
+ detections = {}
+
+ for role in ROLES:
+ detections[role] = detect_charuco_best(
+ triplet["frames"][role],
+ board,
+ dictionary,
+ detector_params,
+ allow_invert=allow_invert,
+ )
+
+ reasons = []
+
+ for role in ROLES:
+ det = detections[role]
+
+ if det["markers"] < qa["min_markers_each"]:
+ reasons.append(
+ f"{role}:markers={det['markers']}"
+ )
+
+ if det["corners"] < qa["min_charuco_corners_each"]:
+ reasons.append(
+ f"{role}:corners={det['corners']}"
+ )
+
+ pairs = {}
+
+ rgb_points = detections["rgb"]["points"]
+
+ for role in SPEC_ROLES:
+ ids, src, dst = common_pairs(
+ rgb_points,
+ detections[role]["points"],
+ )
+
+ pairs[role] = {
+ "charuco_ids": ids,
+ "src_points": [
+ [float(x), float(y)]
+ for x, y in src
+ ],
+ "dst_points": [
+ [float(x), float(y)]
+ for x, y in dst
+ ],
+ }
+
+ if len(ids) < qa["min_common_corners_each_pair"]:
+ reasons.append(
+ f"{role}:common={len(ids)}"
+ )
+
+ sync_dt_ms = triplet["sync_dt_ms"]
+
+ if (
+ sync_dt_ms is not None
+ and sync_dt_ms > qa["sync_tolerance_ms"]
+ ):
+ reasons.append(
+ f"sync_dt_ms={sync_dt_ms:.2f}"
+ )
+
+ signature = pose_signature(
+ rgb_points,
+ triplet["frames"]["rgb"].shape[:2],
+ )
+
+ return {
+ "valid": len(reasons) == 0,
+ "reasons": reasons,
+ "detections": detections,
+ "pairs": pairs,
+ "pose_signature": asdict(signature),
+ "sync_dt_ms": sync_dt_ms,
+ }
+
+
+# ============================================================
+# Preflight
+# ============================================================
+
+def sample_ae_controls(
+ runtime: AcquisitionRuntime,
+ args,
+):
+ locked = {}
+
+ for role in ROLES:
+ exp_override = getattr(
+ args,
+ f"{role}_exp_us",
+ )
+
+ iso_override = getattr(
+ args,
+ f"{role}_iso",
+ )
+
+ if (
+ exp_override is not None
+ or iso_override is not None
+ ):
+ if (
+ exp_override is None
+ or iso_override is None
+ ):
+ raise ValueError(
+ f"Override {role}: informe EXP e ISO."
+ )
+
+ locked[role] = LockedControl(
+ role=role,
+ exposure_time_us=int(
+ exp_override
+ ),
+ sensitivity_iso=int(
+ iso_override
+ ),
+ source="cli_manual",
+ )
+
+ continue
+
+ exp_values = []
+ iso_values = []
+
+ t0 = time.time()
+
+ while time.time() - t0 < 0.8:
+ fresh = runtime.poll()
+
+ if role not in fresh:
+ time.sleep(0.002)
+ continue
+
+ ctrl = runtime.controls[role]
+
+ exp = safe_int(
+ ctrl.get(
+ "exposure_time_us"
+ ),
+ None,
+ )
+
+ iso = safe_int(
+ ctrl.get(
+ "sensitivity_iso"
+ ),
+ None,
+ )
+
+ if exp is not None and exp > 0:
+ exp_values.append(exp)
+
+ if iso is not None and iso > 0:
+ iso_values.append(iso)
+
+ if not exp_values or not iso_values:
+ raise RuntimeError(
+ f"Não consegui ler EXP/ISO de {role.upper()}."
+ )
+
+ locked[role] = LockedControl(
+ role=role,
+ exposure_time_us=int(
+ round(
+ np.median(exp_values)
+ )
+ ),
+ sensitivity_iso=int(
+ round(
+ np.median(iso_values)
+ )
+ ),
+ source="ae_snapshot",
+ )
+
+ return locked
+
+
+def preflight_detection(
+ runtime,
+ board,
+ dictionary,
+ detector_params,
+ qa,
+ allow_invert,
+):
+ frames = runtime.frames
+
+ result = {
+ "status": "good",
+ "roles": {},
+ "reasons": [],
+ }
+
+ for role in ROLES:
+ if frames[role] is None:
+ result["status"] = "bad"
+ result["roles"][role] = {
+ "status": "bad",
+ "markers": 0,
+ "corners": 0,
+ "reasons": ["sem_frame"],
+ }
+ continue
+
+ det = detect_charuco_best(
+ frames[role],
+ board,
+ dictionary,
+ detector_params,
+ allow_invert=allow_invert,
+ )
+
+ role_status = "good"
+ reasons = []
+
+ if det["markers"] < qa["min_markers_each"]:
+ role_status = "bad"
+ reasons.append(
+ f"markers={det['markers']}"
+ )
+
+ if det["corners"] < qa["min_charuco_corners_each"]:
+ role_status = "bad"
+ reasons.append(
+ f"corners={det['corners']}"
+ )
+
+ result["roles"][role] = {
+ "status": role_status,
+ "markers": det["markers"],
+ "corners": det["corners"],
+ "equalize": det["equalize"],
+ "invert": det["invert"],
+ "reasons": reasons,
+ }
+
+ result["status"] = merge_status(
+ result["status"],
+ role_status,
+ )
+
+ result["reasons"].extend(
+ [
+ f"{role}:{x}"
+ for x in reasons
+ ]
+ )
+
+ return result
+
+
+# ============================================================
+# Visualization
+# ============================================================
+
+def draw_detected_points(
+ panel,
+ point_by_id,
+ source_shape_hw,
+ color,
+ max_labels=60,
+):
+ if not point_by_id:
+ return
+
+ ph, pw = panel.shape[:2]
+ sh, sw = source_shape_hw
+
+ for idx, (cid, pt) in enumerate(
+ sorted(point_by_id.items())
+ ):
+ x = int(
+ pt[0]
+ * pw
+ / max(sw, 1)
+ )
+
+ y = int(
+ pt[1]
+ * ph
+ / max(sh, 1)
+ )
+
+ cv2.circle(
+ panel,
+ (x, y),
+ 3,
+ color,
+ -1,
+ )
+
+ if idx < max_labels:
+ cv2.putText(
+ panel,
+ str(cid),
+ (x + 4, y - 4),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.34,
+ color,
+ 1,
+ cv2.LINE_AA,
+ )
+
+
+def provisional_h(
+ samples,
+ role,
+ qa,
+):
+ if not samples:
+ return None
+
+ pairs = role_pairs_from_samples(
+ samples,
+ role,
+ )
+
+ if len(pairs["src"]) < 4:
+ return None
+
+ H, _ = find_homography_ransac(
+ pairs["src"],
+ pairs["dst"],
+ qa,
+ )
+
+ return H
+
+
+def build_overlay(
+ rgb,
+ spec,
+ role,
+ H,
+ alpha=0.45,
+):
+ if rgb is None:
+ return None
+
+ if rgb.ndim == 2:
+ base = cv2.cvtColor(
+ rgb,
+ cv2.COLOR_GRAY2BGR,
+ )
+ else:
+ base = rgb.copy()
+
+ if spec is None or H is None:
+ return base
+
+ dw = base.shape[1]
+ dh = base.shape[0]
+
+ if spec.ndim == 3:
+ spec_gray = cv2.cvtColor(
+ spec,
+ cv2.COLOR_BGR2GRAY,
+ )
+ else:
+ spec_gray = spec
+
+ warped = cv2.warpPerspective(
+ spec_gray,
+ np.asarray(H, dtype=np.float64),
+ (dw, dh),
+ flags=cv2.INTER_LINEAR,
+ borderMode=cv2.BORDER_CONSTANT,
+ borderValue=0,
+ )
+
+ colored = colorize_spec(
+ warped,
+ role,
+ )
+
+ return cv2.addWeighted(
+ base,
+ 1.0 - alpha,
+ colored,
+ alpha,
+ 0.0,
+ )
+
+
+def save_sample_preview(
+ path,
+ frames,
+ detection,
+ panel_width=480,
+ panel_height=300,
+):
+ panels = []
+
+ colors = {
+ "rgb": (0, 255, 0),
+ "re": (0, 255, 255),
+ "nir": (255, 255, 0),
+ }
+
+ for role in ROLES:
+ img = frames[role]
+
+ if img.ndim == 2:
+ view = cv2.cvtColor(
+ img,
+ cv2.COLOR_GRAY2BGR,
+ )
+ else:
+ view = img.copy()
+
+ panel = resize_panel(
+ view,
+ panel_width,
+ panel_height,
+ )
+
+ draw_detected_points(
+ panel,
+ detection["detections"][role]["points"],
+ img.shape[:2],
+ colors[role],
+ )
+
+ overlay_hud(
+ panel,
+ [
+ role.upper(),
+ f"markers={detection['detections'][role]['markers']}",
+ f"corners={detection['detections'][role]['corners']}",
+ ],
+ x=8,
+ y=18,
+ font_scale=0.38,
+ line_step=16,
+ )
+
+ panels.append(panel)
+
+ board = np.hstack(panels)
+
+ cv2.imwrite(
+ str(path),
+ board,
+ )
+
+
+def build_ui_board(
+ runtime,
+ specs,
+ samples,
+ selected_role,
+ last_detection,
+ profile_eval,
+ args,
+ qa,
+ message="",
+):
+ pw = int(args.panel_width)
+ ph = int(args.panel_height)
+
+ H_preview = provisional_h(
+ samples,
+ selected_role,
+ qa,
+ )
+
+ overlay_native = build_overlay(
+ runtime.frames["rgb"],
+ runtime.frames[selected_role],
+ selected_role,
+ H_preview,
+ alpha=args.overlay_alpha,
+ )
+
+ panels = {}
+
+ for role in ROLES:
+ img = runtime.frames[role]
+
+ if img is None:
+ panel = np.zeros(
+ (ph, pw, 3),
+ dtype=np.uint8,
+ )
+
+ overlay_hud(
+ panel,
+ [
+ role.upper(),
+ "sem frame",
+ ],
+ )
+
+ panels[role] = panel
+ continue
+
+ if img.ndim == 2:
+ if role in SPEC_ROLES:
+ view = colorize_spec(
+ img,
+ role,
+ )
+ else:
+ view = cv2.cvtColor(
+ img,
+ cv2.COLOR_GRAY2BGR,
+ )
+ else:
+ view = img.copy()
+
+ panel = resize_panel(
+ view,
+ pw,
+ ph,
+ )
+
+ if (
+ last_detection is not None
+ and role
+ in last_detection["detections"]
+ ):
+ colors = {
+ "rgb": (0, 255, 0),
+ "re": (0, 255, 255),
+ "nir": (255, 255, 0),
+ }
+
+ draw_detected_points(
+ panel,
+ last_detection[
+ "detections"
+ ][role]["points"],
+ img.shape[:2],
+ colors[role],
+ )
+
+ overlay_hud(
+ panel,
+ [
+ f"{role.upper()} | {specs[role].socket_name} | {specs[role].sensor_name}",
+ f"{img.shape[1]}x{img.shape[0]}",
+ f"EXP={runtime.controls[role].get('exposure_time_us')}us "
+ f"ISO={runtime.controls[role].get('sensitivity_iso')}",
+ f"FPS={runtime.fps[role]:.1f}",
+ ],
+ x=8,
+ y=18,
+ font_scale=0.38,
+ line_step=16,
+ )
+
+ panels[role] = panel
+
+ if overlay_native is None:
+ overlay_panel = np.zeros(
+ (ph, pw, 3),
+ dtype=np.uint8,
+ )
+ else:
+ overlay_panel = resize_panel(
+ overlay_native,
+ pw,
+ ph,
+ )
+
+ overlay_hud(
+ overlay_panel,
+ [
+ f"OVERLAY RGB + {selected_role.upper()}",
+ (
+ "H provisional"
+ if H_preview is not None
+ else "H ainda indisponivel"
+ ),
+ ],
+ x=8,
+ y=18,
+ font_scale=0.40,
+ line_step=18,
+ )
+
+ data = np.zeros(
+ (ph, pw, 3),
+ dtype=np.uint8,
+ )
+
+ signatures = [
+ PoseSignature(
+ **sample["pose_signature"]
+ )
+ for sample in samples
+ ]
+
+ rgb_pts_all = []
+
+ for sample in samples:
+ for role in SPEC_ROLES:
+ rgb_pts_all.extend(
+ sample["pairs"][role][
+ "dst_points"
+ ]
+ )
+
+ coverage = coverage_stats(
+ rgb_pts_all,
+ (
+ specs["rgb"].width,
+ specs["rgb"].height,
+ ),
+ ) if rgb_pts_all else {
+ "span_x": 0.0,
+ "span_y": 0.0,
+ "hull_area_fraction": 0.0,
+ }
+
+ lines = [
+ "HOMOGRAPHY CALIBRATION - PRODUCTION",
+ f"profile={args.profile_name} | depth={args.depth_cm} cm",
+ f"selected overlay={selected_role.upper()}",
+ "",
+ f"samples={len(samples)} / min={qa['min_samples']} rec={qa['recommended_samples']}",
+ f"coverage X={coverage['span_x']*100:.1f}%",
+ f"coverage Y={coverage['span_y']*100:.1f}%",
+ f"coverage hull={coverage['hull_area_fraction']*100:.1f}%",
+ "",
+ "G captura sample | A avalia",
+ "2/3 overlay RE/NIR | TAB alterna",
+ "V limpa sessão | S snapshot",
+ "Q/ESC cancela",
+ ]
+
+ if last_detection is not None:
+ lines.extend([
+ "",
+ f"last sync={last_detection.get('sync_dt_ms')} ms",
+ f"last RGB corners={last_detection['detections']['rgb']['corners']}",
+ f"last RE common={len(last_detection['pairs']['re']['charuco_ids'])}",
+ f"last NIR common={len(last_detection['pairs']['nir']['charuco_ids'])}",
+ ])
+
+ if profile_eval is not None:
+ lines.extend([
+ "",
+ f"LAST EVAL={profile_eval['status'].upper()}",
+ ])
+
+ for role in SPEC_ROLES:
+ model = profile_eval["models"][role]
+
+ if "fit_error" in model:
+ lines.append(
+ f"{role.upper()}: fit med="
+ f"{model['fit_error']['median_px']:.2f}px "
+ f"CV med="
+ f"{model['cross_validation']['overall']['median_px']:.2f}px "
+ f"overlap={model['overlap_fraction']*100:.1f}%"
+ )
+
+ overlay_hud(
+ data,
+ lines,
+ x=12,
+ y=20,
+ font_scale=0.40,
+ line_step=17,
+ )
+
+ board = np.vstack([
+ np.hstack([
+ overlay_panel,
+ panels["rgb"],
+ ]),
+ np.hstack([
+ panels["re"],
+ panels["nir"],
+ ]),
+ ])
+
+ # Data mini-panel sobre o overlay inferior direito por composição?
+ # Usamos uma janela maior com data à direita para não esconder imagens.
+ top = np.hstack([
+ overlay_panel,
+ panels["rgb"],
+ data,
+ ])
+
+ bottom = np.hstack([
+ panels["re"],
+ panels["nir"],
+ np.zeros_like(data),
+ ])
+
+ board = np.vstack([
+ top,
+ bottom,
+ ])
+
+ if message:
+ cv2.putText(
+ board,
+ message,
+ (
+ 16,
+ board.shape[0] - 14,
+ ),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.55,
+ (0, 255, 0),
+ 2,
+ cv2.LINE_AA,
+ )
+
+ return board
+
+
+# ============================================================
+# Active artifact / module fragment
+# ============================================================
+
+def hardware_signature(specs):
+ return {
+ role: {
+ "socket": specs[role].socket_name,
+ "sensor": specs[role].sensor_name,
+ "size": [
+ specs[role].width,
+ specs[role].height,
+ ],
+ }
+ for role in ROLES
+ }
+
+
+def profile_to_module_fragment(
+ profile_name,
+ profile_payload,
+ baseline_mm,
+):
+ return {
+ "fusion_config": {
+ "alignment_mode": "homography",
+ "baseline_mm": float(
+ baseline_mm
+ ),
+ "reference_camera": "rgb",
+ "homography_profile": profile_name,
+ "homography_profile_by_role": {
+ "re": profile_name,
+ "nir": profile_name,
+ },
+ "homography_profiles": {
+ profile_name: {
+ "description": profile_payload[
+ "description"
+ ],
+ "depth": profile_payload[
+ "depth_cm"
+ ],
+ "depth_unit": "cm",
+ "homography_source": (
+ "charuco_native_multi_sample_group_cv"
+ ),
+ "coordinate_space": COORDINATE_SPACE,
+ "reference_size": profile_payload[
+ "coordinate_spaces"
+ ]["rgb"]["size"],
+ "source_size_by_role": {
+ "re": profile_payload[
+ "coordinate_spaces"
+ ]["re"]["size"],
+ "nir": profile_payload[
+ "coordinate_spaces"
+ ]["nir"]["size"],
+ },
+ # Compatibilidade semântica com assembler antigo:
+ # agora significa TAMANHO DA REFERÊNCIA RGB.
+ "homography_calibration_size": profile_payload[
+ "coordinate_spaces"
+ ]["rgb"]["size"],
+ "homography_stats": profile_payload[
+ "homography_stats"
+ ],
+ "homographies": {
+ "re_to_rgb": profile_payload[
+ "homographies"
+ ]["re_to_rgb"],
+ "nir_to_rgb": profile_payload[
+ "homographies"
+ ]["nir_to_rgb"],
+ },
+ }
+ },
+ }
+ }
+
+
+def merge_all_profiles_fragment(
+ active_data,
+):
+ profiles = active_data.get(
+ "profiles",
+ {},
+ )
+
+ recommended = active_data.get(
+ "recommended_profile"
+ )
+
+ fusion_profiles = {}
+
+ for name, profile in profiles.items():
+ one = profile_to_module_fragment(
+ name,
+ profile,
+ active_data["baseline_mm"],
+ )
+
+ fusion_profiles[name] = (
+ one["fusion_config"][
+ "homography_profiles"
+ ][name]
+ )
+
+ return {
+ "fusion_config": {
+ "alignment_mode": "homography",
+ "baseline_mm": float(
+ active_data["baseline_mm"]
+ ),
+ "reference_camera": "rgb",
+ "homography_profile": recommended,
+ "homography_profile_by_role": {
+ "re": recommended,
+ "nir": recommended,
+ },
+ "homography_profiles": fusion_profiles,
+ }
+ }
+
+
+def create_or_update_active(
+ active_path: Path,
+ candidate_report: dict,
+ profile_payload: dict,
+ args,
+ specs,
+):
+ active = load_json(
+ active_path
+ )
+
+ signature = hardware_signature(
+ specs
+ )
+
+ if active:
+ if active.get("schema") != SCHEMA:
+ raise RuntimeError(
+ f"Arquivo ativo existente usa schema "
+ f"{active.get('schema')!r}, esperado {SCHEMA!r}. "
+ "Use outro --active-json ou arquive o antigo."
+ )
+
+ if (
+ active.get(
+ "hardware_signature"
+ )
+ != signature
+ ):
+ raise RuntimeError(
+ "Homography ativa pertence a outro conjunto de sensores/resoluções. "
+ "Não é seguro misturar perfis antigos com o hardware atual."
+ )
+
+ if (
+ active.get("coordinate_space")
+ != COORDINATE_SPACE
+ ):
+ raise RuntimeError(
+ "Coordinate space do ativo é incompatível."
+ )
+
+ else:
+ active = {
+ "schema": SCHEMA,
+ "created_at": now_str(),
+ "hardware_signature": signature,
+ "coordinate_space": COORDINATE_SPACE,
+ "reference_camera": "rgb",
+ "baseline_mm": float(
+ args.baseline_mm
+ ),
+ "profiles": {},
+ "recommended_profile": None,
+ "module_params_fragment": {},
+ }
+
+ profile_name = args.profile_name
+
+ active["updated_at"] = now_str()
+ active["profiles"][profile_name] = (
+ profile_payload
+ )
+
+ if (
+ active["recommended_profile"] is None
+ or args.set_recommended
+ ):
+ active["recommended_profile"] = (
+ profile_name
+ )
+
+ active["module_params_fragment"] = (
+ merge_all_profiles_fragment(
+ active
+ )
+ )
+
+ active["last_candidate_session"] = (
+ candidate_report["session_id"]
+ )
+
+ ensure_dir(
+ active_path.parent
+ )
+
+ if active_path.exists():
+ backup = active_path.with_name(
+ active_path.stem
+ + f".backup_{session_stamp()}"
+ + active_path.suffix
+ )
+
+ shutil.copy2(
+ active_path,
+ backup,
+ )
+
+ save_json_atomic(
+ active_path,
+ active,
+ )
+
+ return active
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=(
+ "Homography calibration de produção por ChArUco "
+ "para OAK-FFC-3P OV9782/AR0234 + 2x OV9282."
+ ),
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ # Perfil
+ parser.add_argument(
+ "--profile-name",
+ default="media",
+ )
+
+ parser.add_argument(
+ "--depth-cm",
+ type=float,
+ default=66.0,
+ )
+
+ parser.add_argument(
+ "--profile-description",
+ default="Plano de calibração ChArUco.",
+ )
+
+ parser.add_argument(
+ "--baseline-mm",
+ type=float,
+ default=75.0,
+ )
+
+ parser.add_argument(
+ "--set-recommended",
+ action="store_true",
+ help=(
+ "Define este perfil como recomendado no JSON ativo."
+ ),
+ )
+
+ # Hardware
+ parser.add_argument(
+ "--mx-id",
+ default=None,
+ )
+
+ parser.add_argument(
+ "--fps",
+ type=float,
+ default=20.0,
+ )
+
+ parser.add_argument(
+ "--isp-sharpness",
+ type=int,
+ default=0,
+ )
+
+ parser.add_argument(
+ "--isp-luma-denoise",
+ type=int,
+ default=0,
+ )
+
+ parser.add_argument(
+ "--isp-chroma-denoise",
+ type=int,
+ default=0,
+ )
+
+ # Controles manuais opcionais
+ for role in ROLES:
+ parser.add_argument(
+ f"--{role}-exp-us",
+ type=int,
+ default=None,
+ )
+
+ parser.add_argument(
+ f"--{role}-iso",
+ type=int,
+ default=None,
+ )
+
+ parser.add_argument(
+ "--lock-settle-frames",
+ type=int,
+ default=8,
+ )
+
+ # ChArUco
+ parser.add_argument(
+ "--charuco-dictionary",
+ default="DICT_4X4_50",
+ )
+
+ parser.add_argument(
+ "--charuco-squares-x",
+ type=int,
+ default=13,
+ )
+
+ parser.add_argument(
+ "--charuco-squares-y",
+ type=int,
+ default=7,
+ )
+
+ parser.add_argument(
+ "--charuco-square-length",
+ type=float,
+ default=0.031,
+ )
+
+ parser.add_argument(
+ "--charuco-marker-length",
+ type=float,
+ default=0.023,
+ )
+
+ parser.add_argument(
+ "--no-auto-invert",
+ action="store_true",
+ help=(
+ "Não tenta versões invertidas na detecção RE/NIR/RGB."
+ ),
+ )
+
+ # Política
+ parser.add_argument(
+ "--promote-warning",
+ action="store_true",
+ help=(
+ "Permite promover perfil WARNING. BAD nunca promove."
+ ),
+ )
+
+ # UI
+ parser.add_argument(
+ "--panel-width",
+ type=int,
+ default=560,
+ )
+
+ parser.add_argument(
+ "--panel-height",
+ type=int,
+ default=350,
+ )
+
+ parser.add_argument(
+ "--overlay-alpha",
+ type=float,
+ default=0.45,
+ )
+
+ # Output
+ parser.add_argument(
+ "--candidate-root",
+ default="calibration/homography_candidates",
+ )
+
+ parser.add_argument(
+ "--active-json",
+ default="calibration/homography_calibration_v4.json",
+ )
+
+ # Rastreabilidade
+ parser.add_argument(
+ "--module-id",
+ default="",
+ )
+
+ parser.add_argument(
+ "--operator",
+ default="",
+ )
+
+ parser.add_argument(
+ "--notes",
+ default="",
+ )
+
+ args = parser.parse_args()
+
+ qa = dict(DEFAULT_QA)
+
+ sid = session_stamp()
+ candidate_dir = (
+ Path(args.candidate_root)
+ / sid
+ )
+
+ sample_dir = (
+ candidate_dir
+ / "samples"
+ )
+
+ snapshot_dir = (
+ candidate_dir
+ / "snapshots"
+ )
+
+ candidate_report_path = (
+ candidate_dir
+ / "report.json"
+ )
+
+ ensure_dir(sample_dir)
+ ensure_dir(snapshot_dir)
+
+ require_aruco()
+
+ dictionary = get_aruco_dictionary(
+ args.charuco_dictionary
+ )
+
+ board = create_charuco_board(
+ args.charuco_squares_x,
+ args.charuco_squares_y,
+ args.charuco_square_length,
+ args.charuco_marker_length,
+ dictionary,
+ )
+
+ detector_params = create_detector_params()
+
+ camera_rows = []
+ specs = {}
+ report = None
+ samples = []
+ last_detection = None
+ profile_eval = None
+ actual_mx = None
+ usb_speed = None
+
+ window_name = (
+ "Homography Calibration - Production"
+ )
+
+ cv2.namedWindow(
+ window_name,
+ cv2.WINDOW_NORMAL,
+ )
+
+ try:
+ # ----------------------------------------------------
+ # Hardware discovery
+ # ----------------------------------------------------
+
+ (
+ camera_rows,
+ actual_mx,
+ usb_speed,
+ ) = discover_cameras(
+ args.mx_id
+ )
+
+ topology = validate_product_topology(
+ camera_rows
+ )
+
+ specs = build_specs(
+ camera_rows
+ )
+
+ print("=" * 88)
+ print("HOMOGRAPHY CALIBRATION - PRODUCTION")
+ print(f"Profile : {args.profile_name}")
+ print(f"Depth : {args.depth_cm} cm")
+ print(f"MX ID : {actual_mx}")
+ print(f"USB : {usb_speed}")
+ print("-" * 88)
+
+ for role in ROLES:
+ spec = specs[role]
+
+ print(
+ f"{spec.socket_name} -> "
+ f"{role.upper():3s} | "
+ f"{spec.sensor_name:8s} | "
+ f"{spec.width}x{spec.height} | "
+ f"{spec.source}"
+ )
+
+ print("-" * 88)
+ print(
+ f"H_RE : 1280x800 -> "
+ f"{specs['rgb'].width}x{specs['rgb'].height}"
+ )
+ print(
+ f"H_NIR: 1280x800 -> "
+ f"{specs['rgb'].width}x{specs['rgb'].height}"
+ )
+ print("=" * 88)
+
+ pipeline, isp_settings = (
+ build_pipeline(
+ specs,
+ args,
+ )
+ )
+
+ device_info = (
+ dai.DeviceInfo(actual_mx)
+ if actual_mx
+ else None
+ )
+
+ device_ctx = (
+ dai.Device(
+ pipeline,
+ device_info,
+ )
+ if device_info is not None
+ else dai.Device(pipeline)
+ )
+
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ "status": "running",
+ "promoted": False,
+ "depthai_version": getattr(
+ dai,
+ "__version__",
+ "unknown",
+ ),
+ "opencv_version": cv2.__version__,
+ "device_mx_id": actual_mx,
+ "usb_speed": usb_speed,
+ "topology": topology,
+ "camera_inventory": camera_rows,
+ "hardware_signature": hardware_signature(
+ specs
+ ),
+ "coordinate_space": COORDINATE_SPACE,
+ "coordinate_spaces": {
+ role: {
+ "role": role,
+ "sensor": specs[role].sensor_name,
+ "size": [
+ specs[role].width,
+ specs[role].height,
+ ],
+ "stream_source": specs[role].source,
+ "external_undistort_applied": False,
+ }
+ for role in ROLES
+ },
+ "profile": {
+ "name": args.profile_name,
+ "depth_cm": float(
+ args.depth_cm
+ ),
+ "description": args.profile_description,
+ "same_physical_plane_required": True,
+ },
+ "baseline_mm": float(
+ args.baseline_mm
+ ),
+ "charuco": {
+ "dictionary": args.charuco_dictionary,
+ "squares_x": int(
+ args.charuco_squares_x
+ ),
+ "squares_y": int(
+ args.charuco_squares_y
+ ),
+ "square_length": float(
+ args.charuco_square_length
+ ),
+ "marker_length": float(
+ args.charuco_marker_length
+ ),
+ "auto_invert": not args.no_auto_invert,
+ },
+ "isp_measurement_settings": isp_settings,
+ "qa_thresholds": qa,
+ "traceability": {
+ "module_id": args.module_id or None,
+ "operator": args.operator or None,
+ },
+ "notes": args.notes or "",
+ "locked_controls": None,
+ "preflight": None,
+ "samples": [],
+ "evaluation": None,
+ "module_params_fragment": None,
+ "candidate_dir": str(
+ candidate_dir
+ ),
+ "active_json": args.active_json,
+ }
+
+ with device_ctx as device:
+ runtime = AcquisitionRuntime(
+ device,
+ specs,
+ args,
+ qa,
+ )
+
+ runtime.wait_all()
+
+ # ------------------------------------------------
+ # Preflight
+ # ------------------------------------------------
+
+ print("")
+ print("[PRE-FLIGHT]")
+ print(
+ "Mostre o ChArUco às três câmeras no plano que será calibrado."
+ )
+ print(
+ "ENTER congela exposição quando as três detecções estiverem GOOD."
+ )
+
+ while True:
+ runtime.poll()
+
+ preflight = preflight_detection(
+ runtime,
+ board,
+ dictionary,
+ detector_params,
+ qa,
+ allow_invert=(
+ not args.no_auto_invert
+ ),
+ )
+
+ panels = []
+
+ for role in ROLES:
+ img = runtime.frames[role]
+
+ if img.ndim == 2:
+ view = cv2.cvtColor(
+ img,
+ cv2.COLOR_GRAY2BGR,
+ )
+ else:
+ view = img.copy()
+
+ panel = resize_panel(
+ view,
+ args.panel_width,
+ args.panel_height,
+ )
+
+ p = preflight["roles"][role]
+
+ overlay_hud(
+ panel,
+ [
+ f"{role.upper()} | {specs[role].sensor_name}",
+ f"PREFLIGHT={p['status'].upper()}",
+ f"markers={p['markers']} corners={p['corners']}",
+ f"EXP={runtime.controls[role].get('exposure_time_us')}us "
+ f"ISO={runtime.controls[role].get('sensitivity_iso')}",
+ ],
+ x=10,
+ y=22,
+ font_scale=0.42,
+ line_step=18,
+ )
+
+ panels.append(panel)
+
+ data = np.zeros(
+ (
+ args.panel_height,
+ args.panel_width,
+ 3,
+ ),
+ dtype=np.uint8,
+ )
+
+ overlay_hud(
+ data,
+ [
+ "HOMOGRAPHY PRE-FLIGHT",
+ "",
+ f"GLOBAL={preflight['status'].upper()}",
+ "",
+ "Mesmo plano físico durante toda a sessão.",
+ "Mova lateralmente, não mude altura/inclinação.",
+ "",
+ "ENTER = lock EXP/ISO",
+ "Q/ESC = cancelar",
+ "",
+ *[
+ f"! {x}"
+ for x in preflight[
+ "reasons"
+ ][:8]
+ ],
+ ],
+ x=14,
+ y=28,
+ font_scale=0.44,
+ line_step=20,
+ )
+
+ pre_board = np.vstack([
+ np.hstack([
+ panels[0],
+ panels[1],
+ ]),
+ np.hstack([
+ panels[2],
+ data,
+ ]),
+ ])
+
+ cv2.imshow(
+ window_name,
+ pre_board,
+ )
+
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (
+ ord("q"),
+ ord("Q"),
+ 27,
+ ):
+ raise KeyboardInterrupt(
+ "Cancelado no preflight."
+ )
+
+ if k in (13, 10):
+ if preflight["status"] != "good":
+ print(
+ "[BLOCK] Preflight ainda não está GOOD."
+ )
+ continue
+
+ locked = sample_ae_controls(
+ runtime,
+ args,
+ )
+
+ runtime.send_manual(
+ locked
+ )
+
+ runtime.verify_manual(
+ locked,
+ settle_frames=args.lock_settle_frames,
+ )
+
+ report["preflight"] = (
+ preflight
+ )
+
+ report["locked_controls"] = {
+ role: asdict(
+ locked[role]
+ )
+ for role in ROLES
+ }
+
+ break
+
+ time.sleep(0.002)
+
+ # ------------------------------------------------
+ # Capture loop
+ # ------------------------------------------------
+
+ selected_role = "re"
+ last_message = ""
+ last_message_t = 0.0
+
+ print("")
+ print("[CAPTURE]")
+ print(
+ "G = captura. Mova o chart pelo MESMO plano entre samples."
+ )
+ print(
+ f"Meta: >= {qa['recommended_samples']} samples, boa cobertura do FOV."
+ )
+
+ while True:
+ runtime.poll()
+
+ msg = (
+ last_message
+ if (
+ last_message
+ and time.time()
+ - last_message_t
+ < 4.0
+ )
+ else ""
+ )
+
+ board_ui = build_ui_board(
+ runtime,
+ specs,
+ samples,
+ selected_role,
+ last_detection,
+ profile_eval,
+ args,
+ qa,
+ message=msg,
+ )
+
+ cv2.imshow(
+ window_name,
+ board_ui,
+ )
+
+ k = cv2.waitKey(1) & 0xFF
+
+ if k in (
+ ord("q"),
+ ord("Q"),
+ 27,
+ ):
+ raise KeyboardInterrupt(
+ "Sessão cancelada."
+ )
+
+ elif k == ord("2"):
+ selected_role = "re"
+ last_message = (
+ "Overlay: RGB + RE"
+ )
+ last_message_t = time.time()
+
+ elif k == ord("3"):
+ selected_role = "nir"
+ last_message = (
+ "Overlay: RGB + NIR"
+ )
+ last_message_t = time.time()
+
+ elif k == 9:
+ selected_role = (
+ "nir"
+ if selected_role == "re"
+ else "re"
+ )
+ last_message = (
+ f"Overlay: RGB + {selected_role.upper()}"
+ )
+ last_message_t = time.time()
+
+ elif k in (
+ ord("v"),
+ ord("V"),
+ ):
+ samples.clear()
+ report["samples"] = []
+ last_detection = None
+ profile_eval = None
+
+ last_message = (
+ "Todas as amostras foram limpas."
+ )
+ last_message_t = time.time()
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ elif k in (
+ ord("s"),
+ ord("S"),
+ ):
+ snap_path = (
+ snapshot_dir
+ / (
+ "board_"
+ + session_stamp()
+ + ".png"
+ )
+ )
+
+ cv2.imwrite(
+ str(snap_path),
+ board_ui,
+ )
+
+ last_message = (
+ f"Snapshot salvo: {snap_path.name}"
+ )
+ last_message_t = time.time()
+
+ elif k in (
+ ord("g"),
+ ord("G"),
+ ):
+ try:
+ triplet = (
+ runtime.fresh_triplet(
+ timeout=3.0
+ )
+ )
+
+ detection = (
+ detect_triplet(
+ triplet,
+ board,
+ dictionary,
+ detector_params,
+ qa,
+ allow_invert=(
+ not args.no_auto_invert
+ ),
+ )
+ )
+
+ last_detection = (
+ detection
+ )
+
+ if not detection["valid"]:
+ last_message = (
+ "REJEITADO: "
+ + " | ".join(
+ detection[
+ "reasons"
+ ]
+ )
+ )
+ last_message_t = (
+ time.time()
+ )
+ continue
+
+ signature = PoseSignature(
+ **detection[
+ "pose_signature"
+ ]
+ )
+
+ prior = [
+ PoseSignature(
+ **x[
+ "pose_signature"
+ ]
+ )
+ for x in samples
+ ]
+
+ duplicate, duplicate_info = (
+ is_duplicate_pose(
+ signature,
+ prior,
+ qa,
+ )
+ )
+
+ if duplicate:
+ last_message = (
+ "REJEITADO: pose muito parecida; "
+ "mova o ChArUco para outra região."
+ )
+ last_message_t = (
+ time.time()
+ )
+ continue
+
+ sample_id = (
+ len(samples) + 1
+ )
+
+ sample = {
+ "sample_id": sample_id,
+ "captured_at": now_str(),
+ "sync_dt_ms": detection[
+ "sync_dt_ms"
+ ],
+ "pose_signature": detection[
+ "pose_signature"
+ ],
+ "frame_controls": triplet[
+ "controls"
+ ],
+ "detections": {
+ role: {
+ "markers": detection[
+ "detections"
+ ][role][
+ "markers"
+ ],
+ "corners": detection[
+ "detections"
+ ][role][
+ "corners"
+ ],
+ "equalize": detection[
+ "detections"
+ ][role][
+ "equalize"
+ ],
+ "invert": detection[
+ "detections"
+ ][role][
+ "invert"
+ ],
+ }
+ for role in ROLES
+ },
+ "pairs": detection[
+ "pairs"
+ ],
+ }
+
+ samples.append(
+ sample
+ )
+
+ preview_path = (
+ sample_dir
+ / f"sample_{sample_id:03d}.png"
+ )
+
+ save_sample_preview(
+ preview_path,
+ triplet["frames"],
+ detection,
+ )
+
+ sample[
+ "preview_png"
+ ] = str(preview_path)
+
+ report["samples"] = (
+ samples
+ )
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ last_message = (
+ f"ACCEPTED sample #{sample_id:03d} | "
+ f"RE={len(sample['pairs']['re']['charuco_ids'])} "
+ f"NIR={len(sample['pairs']['nir']['charuco_ids'])} "
+ f"sync={sample['sync_dt_ms']}"
+ )
+ last_message_t = (
+ time.time()
+ )
+
+ print(
+ "[SAMPLE] "
+ + last_message
+ )
+
+ except Exception as exc:
+ last_message = (
+ f"Captura rejeitada: {exc}"
+ )
+ last_message_t = (
+ time.time()
+ )
+
+ elif k in (
+ ord("a"),
+ ord("A"),
+ ):
+ if (
+ len(samples)
+ < qa["min_samples"]
+ ):
+ last_message = (
+ f"Precisa >= {qa['min_samples']} samples. "
+ f"Atual={len(samples)}"
+ )
+ last_message_t = (
+ time.time()
+ )
+ continue
+
+ profile_eval = (
+ evaluate_profile(
+ samples,
+ specs,
+ qa,
+ )
+ )
+
+ report["evaluation"] = (
+ profile_eval
+ )
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ if (
+ profile_eval["status"]
+ == "bad"
+ ):
+ last_message = (
+ "PROFILE BAD: "
+ + " | ".join(
+ profile_eval[
+ "reasons"
+ ][:4]
+ )
+ )
+ last_message_t = (
+ time.time()
+ )
+
+ print(
+ "[EVAL BAD] "
+ + last_message
+ )
+
+ continue
+
+ if (
+ profile_eval["status"]
+ == "warning"
+ and not args.promote_warning
+ ):
+ last_message = (
+ "PROFILE WARNING. "
+ "Não promove sem --promote-warning."
+ )
+ last_message_t = (
+ time.time()
+ )
+
+ print(
+ "[EVAL WARNING] "
+ + " | ".join(
+ profile_eval[
+ "reasons"
+ ]
+ )
+ )
+
+ continue
+
+ # ----------------------------------------
+ # PASS / profile payload
+ # ----------------------------------------
+
+ re_model = (
+ profile_eval[
+ "models"
+ ]["re"]
+ )
+
+ nir_model = (
+ profile_eval[
+ "models"
+ ]["nir"]
+ )
+
+ homography_stats = {
+ "re_total_points": re_model[
+ "points"
+ ],
+ "re_inliers": re_model[
+ "inliers"
+ ],
+ "re_inlier_pct": (
+ re_model[
+ "inlier_ratio"
+ ]
+ * 100.0
+ ),
+ "nir_total_points": nir_model[
+ "points"
+ ],
+ "nir_inliers": nir_model[
+ "inliers"
+ ],
+ "nir_inlier_pct": (
+ nir_model[
+ "inlier_ratio"
+ ]
+ * 100.0
+ ),
+ "re_frames_used": len(
+ re_model[
+ "retained_sample_ids"
+ ]
+ ),
+ "nir_frames_used": len(
+ nir_model[
+ "retained_sample_ids"
+ ]
+ ),
+ "common_frames_used": (
+ profile_eval[
+ "common_frames_used"
+ ]
+ ),
+ "re_fit_median_error_px": (
+ re_model[
+ "fit_error"
+ ]["median_px"]
+ ),
+ "nir_fit_median_error_px": (
+ nir_model[
+ "fit_error"
+ ]["median_px"]
+ ),
+ "re_cv_median_error_px": (
+ re_model[
+ "cross_validation"
+ ]["overall"][
+ "median_px"
+ ]
+ ),
+ "nir_cv_median_error_px": (
+ nir_model[
+ "cross_validation"
+ ]["overall"][
+ "median_px"
+ ]
+ ),
+ "re_overlap_pct": (
+ re_model[
+ "overlap_fraction"
+ ]
+ * 100.0
+ ),
+ "nir_overlap_pct": (
+ nir_model[
+ "overlap_fraction"
+ ]
+ * 100.0
+ ),
+ "overlap_common_pct": (
+ min(
+ re_model[
+ "overlap_fraction"
+ ],
+ nir_model[
+ "overlap_fraction"
+ ],
+ )
+ * 100.0
+ ),
+ }
+
+ profile_payload = {
+ "profile_name": args.profile_name,
+ "created_at": now_str(),
+ "description": args.profile_description,
+ "depth_cm": float(
+ args.depth_cm
+ ),
+ "same_physical_plane_required": True,
+ "coordinate_space": COORDINATE_SPACE,
+ "coordinate_spaces": {
+ role: {
+ "size": [
+ specs[role].width,
+ specs[role].height,
+ ],
+ "sensor": specs[role].sensor_name,
+ "stream_source": specs[role].source,
+ "external_undistort_applied": False,
+ }
+ for role in ROLES
+ },
+ "homography_source": (
+ "charuco_native_multi_sample_group_cv"
+ ),
+ "homography_stats": homography_stats,
+ "homographies": {
+ "re_to_rgb": re_model[
+ "homography"
+ ],
+ "nir_to_rgb": nir_model[
+ "homography"
+ ],
+ },
+ "quality": profile_eval,
+ "charuco": report[
+ "charuco"
+ ],
+ "locked_controls": report[
+ "locked_controls"
+ ],
+ "source_session_id": sid,
+ }
+
+ report[
+ "profile_payload"
+ ] = profile_payload
+
+ report[
+ "module_params_fragment"
+ ] = (
+ profile_to_module_fragment(
+ args.profile_name,
+ profile_payload,
+ args.baseline_mm,
+ )
+ )
+
+ report["status"] = (
+ "pass"
+ if profile_eval[
+ "status"
+ ] == "good"
+ else "warning_promoted"
+ )
+
+ report["finished_at"] = (
+ now_str()
+ )
+
+ report["promoted"] = True
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ active = (
+ create_or_update_active(
+ Path(
+ args.active_json
+ ),
+ report,
+ profile_payload,
+ args,
+ specs,
+ )
+ )
+
+ report[
+ "active_json_sha256"
+ ] = sha256_file(
+ args.active_json
+ )
+
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ marker = (
+ candidate_dir
+ / (
+ "PASS.txt"
+ if profile_eval[
+ "status"
+ ] == "good"
+ else "PASS_WARNING.txt"
+ )
+ )
+
+ marker.write_text(
+ (
+ f"status={report['status']}\n"
+ f"profile={args.profile_name}\n"
+ f"depth_cm={args.depth_cm}\n"
+ f"active_json={args.active_json}\n"
+ ),
+ encoding="utf-8",
+ )
+
+ print("")
+ print("=" * 88)
+ print(
+ "[PASS] HOMOGRAPHY PROFILE PROMOVIDO"
+ )
+ print(
+ f"[PROFILE] {args.profile_name} @ {args.depth_cm} cm"
+ )
+ print(
+ f"[ACTIVE] {args.active_json}"
+ )
+ print(
+ f"[CANDIDATE] {candidate_dir}"
+ )
+ print("=" * 88)
+
+ final = np.zeros(
+ (720, 1280, 3),
+ dtype=np.uint8,
+ )
+
+ final_lines = [
+ "HOMOGRAPHY CALIBRATION - PASS",
+ "",
+ f"profile={args.profile_name}",
+ f"depth={args.depth_cm} cm",
+ "",
+ f"RE fit med={re_model['fit_error']['median_px']:.2f}px",
+ f"RE CV med={re_model['cross_validation']['overall']['median_px']:.2f}px",
+ f"RE overlap={re_model['overlap_fraction']*100:.1f}%",
+ "",
+ f"NIR fit med={nir_model['fit_error']['median_px']:.2f}px",
+ f"NIR CV med={nir_model['cross_validation']['overall']['median_px']:.2f}px",
+ f"NIR overlap={nir_model['overlap_fraction']*100:.1f}%",
+ "",
+ f"Active: {args.active_json}",
+ "",
+ "Qualquer tecla fecha.",
+ ]
+
+ overlay_hud(
+ final,
+ final_lines,
+ x=46,
+ y=62,
+ font_scale=0.68,
+ line_step=31,
+ )
+
+ cv2.imshow(
+ window_name,
+ final,
+ )
+
+ cv2.waitKey(0)
+
+ break
+
+ time.sleep(0.001)
+
+ except KeyboardInterrupt as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ "coordinate_space": COORDINATE_SPACE,
+ }
+
+ report["status"] = "cancelled"
+ report["finished_at"] = now_str()
+ report["promoted"] = False
+ report["error"] = str(exc)
+ report["samples"] = samples
+
+ try:
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ (
+ candidate_dir
+ / "CANCELLED.txt"
+ ).write_text(
+ f"reason={exc}\n",
+ encoding="utf-8",
+ )
+
+ except Exception:
+ pass
+
+ print(f"[CANCELADO] {exc}")
+ print(
+ "[SAFE] Homography ativa anterior preservada."
+ )
+
+ except Exception as exc:
+ if report is None:
+ report = {
+ "schema": SCHEMA,
+ "session_id": sid,
+ "created_at": now_str(),
+ "coordinate_space": COORDINATE_SPACE,
+ }
+
+ report["status"] = "error"
+ report["finished_at"] = now_str()
+ report["promoted"] = False
+ report["error"] = (
+ f"{type(exc).__name__}: {exc}"
+ )
+ report["samples"] = samples
+
+ try:
+ save_json_atomic(
+ candidate_report_path,
+ report,
+ )
+
+ (
+ candidate_dir
+ / "FAIL.txt"
+ ).write_text(
+ (
+ f"error={type(exc).__name__}: {exc}\n"
+ ),
+ encoding="utf-8",
+ )
+
+ except Exception:
+ pass
+
+ print("")
+ print("=" * 88)
+ print(
+ "[ERRO] HOMOGRAPHY CALIBRATION ABORTADA"
+ )
+ print(
+ f"{type(exc).__name__}: {exc}"
+ )
+ print(
+ "[SAFE] Homography ativa anterior preservada."
+ )
+ print("=" * 88)
+
+ raise
+
+ finally:
+ cv2.destroyAllWindows()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/_8_build_module_params.py b/Python/OAK/datasets/oak-fcc-3/utils/_8_build_module_params.py
new file mode 100644
index 000000000..036f7d53f
--- /dev/null
+++ b/Python/OAK/datasets/oak-fcc-3/utils/_8_build_module_params.py
@@ -0,0 +1,2869 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+module_params_assembler_production.py
+=====================================
+
+Assembler FINAL de produção do module_params do módulo multiespectral.
+
+Este script NÃO calibra nada.
+Ele somente:
+ 1. carrega os artefatos HOMOLOGADOS das etapas anteriores;
+ 2. valida schema/status/promoção;
+ 3. cruza hardware/MX ID;
+ 4. cruza domínio geométrico Intrinsics <-> Homography;
+ 5. cruza Startup Profile <-> Radiometry;
+ 6. valida o NPZ do Flat-Field;
+ 7. preserva apenas políticas runtime explicitamente permitidas;
+ 8. monta o module_params final de forma fail-closed;
+ 9. faz validação estrutural final;
+ 10. salva atomicamente + backup + relatório de assembly.
+
+Linha oficial de entrada
+------------------------
+ Focus:
+ calibration/focus_qc_active.json
+
+ Flat-Field:
+ calibration/flatfield_maps_v1.json
+ calibration/flatfield_maps_v1.npz
+
+ Radiometry:
+ calibration/radiometry_calibration_v5.json
+
+ Camera Startup:
+ calibration/camera_startup_profile_v3.json
+
+ Intrinsics:
+ calibration/intrinsics_calibration_v1.json
+
+ Homography:
+ calibration/homography_calibration_v4.json
+
+ Runtime base opcional:
+ calibration/module_params.json
+
+O runtime base NÃO é fonte de calibração.
+Ele só pode fornecer políticas não calibradas, por exemplo:
+ - onnx_model_path
+ - frame_type / capture mode / raw policy
+ - rgb_processing
+ - alguns knobs de fusion runtime:
+ use_remap_cache
+ use_remap_for_rgb
+ use_remap_for_spec
+ crop_valid_common
+ resize_after_crop
+ target_size
+
+Todo bloco calibrado é substituído pelos artefatos homologados.
+
+Política final
+--------------
+ radiometric_config.enabled = false
+ patch_normalization.enabled = false
+ rgb_calibration.enabled = false / unity
+
+Essas políticas foram deliberadamente separadas da calibração de fábrica.
+
+Compatibilidade
+---------------
+O schema final continua:
+ multispec_module_params_v3
+
+para evitar quebrar consumers existentes.
+
+Entretanto o arquivo passa a conter também:
+ sensor_size_by_role
+ camera_hardware
+ intrinsics_config
+ calibration_provenance
+ assembly_metadata
+
+Root sensor_width/sensor_height:
+ representam a câmera RGB de referência.
+
+Para AR0234:
+ sensor_width = 1920
+ sensor_height = 1200
+
+RE/NIR permanecem explicitamente registrados em:
+ sensor_size_by_role
+
+Bayer
+-----
+O bayer_pattern root é obtido do Flat-Field de produção, que já resolveu
+o Bayer da câmera RGB durante a calibração.
+
+Fail-closed
+-----------
+O assembler ABORTA se:
+ - algum artefato obrigatório não existe;
+ - schema não é o esperado;
+ - artefato não foi promovido;
+ - Focus não é homologação normal;
+ - hardware signatures divergem;
+ - MX IDs divergem;
+ - Startup veio de outra Radiometry;
+ - reference_controls divergem;
+ - Homography e Intrinsics nasceram em espaços geométricos incompatíveis;
+ - runtime_undistort está ligado com Homography no espaço errado;
+ - Flat-Field NPZ não existe;
+ - Flat-Field NPZ não contém os gains obrigatórios;
+ - perfil de Homography selecionado não existe;
+ - matriz H ou K/D é estruturalmente inválida.
+
+Uso normal
+----------
+ python module_params_assembler_production.py
+
+Selecionando perfil de homografia:
+ python module_params_assembler_production.py ^
+ --homography-profile media
+
+Somente auditoria:
+ python module_params_assembler_production.py --check-only
+
+Saídas
+------
+ calibration/module_params.json
+ calibration/module_params_assembly_report.json
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import shutil
+import zipfile
+from copy import deepcopy
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Optional
+
+
+# ============================================================
+# Contratos
+# ============================================================
+
+FINAL_SCHEMA = "multispec_module_params_v3"
+ASSEMBLER_SCHEMA = "multispec_module_params_assembly_v1"
+
+EXPECTED_SCHEMAS = {
+ "focus": "multispec_focus_qc_v3",
+ "flatfield": "multispec_flatfield_production_v2",
+ "radiometry": "multispec_radiometric_calibration_v5",
+ "startup": "multispec_camera_startup_profile_v3",
+ "intrinsics": "multispec_intrinsics_calibration_v1",
+ "homography": "multispec_homography_calibration_v4",
+}
+
+ROLES = ("rgb", "re", "nir")
+CHANNELS = ("R", "G", "B", "RE", "NIR")
+
+EXPECTED_TOPOLOGY = {
+ "rgb": {
+ "socket": "CAM_A",
+ "allowed_sensors": ("OV9782", "AR0234"),
+ },
+ "re": {
+ "socket": "CAM_B",
+ "allowed_sensors": ("OV9282",),
+ },
+ "nir": {
+ "socket": "CAM_C",
+ "allowed_sensors": ("OV9282",),
+ },
+}
+
+EXPECTED_NATIVE_SIZES = {
+ "OV9782": [1280, 800],
+ "AR0234": [1920, 1200],
+ "OV9282": [1280, 800],
+}
+
+CALIBRATION_SPACE_NATIVE = "native_stream_no_external_undistort"
+
+
+# ============================================================
+# Helpers
+# ============================================================
+
+def now_str() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def stamp() -> str:
+ return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+
+
+def ensure_dir(path: str | Path):
+ Path(path).mkdir(parents=True, exist_ok=True)
+
+
+def load_json(path: str | Path, *, required: bool = True) -> dict:
+ p = Path(path)
+
+ if not p.is_file():
+ if required:
+ raise FileNotFoundError(
+ f"Arquivo obrigatório não encontrado: {p}"
+ )
+
+ return {}
+
+ with p.open("r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ if not isinstance(data, dict):
+ raise RuntimeError(
+ f"JSON root deve ser object/dict: {p}"
+ )
+
+ return data
+
+
+def save_json_atomic(path: str | Path, data: dict):
+ p = Path(path)
+ ensure_dir(p.parent)
+
+ tmp = p.with_suffix(
+ p.suffix + ".tmp"
+ )
+
+ with tmp.open(
+ "w",
+ encoding="utf-8",
+ ) as f:
+ json.dump(
+ data,
+ f,
+ ensure_ascii=False,
+ indent=2,
+ )
+ f.write("\n")
+ f.flush()
+ os.fsync(f.fileno())
+
+ os.replace(
+ tmp,
+ p,
+ )
+
+
+def sha256_file(path: str | Path) -> str:
+ h = hashlib.sha256()
+
+ with open(
+ path,
+ "rb",
+ ) as f:
+ while True:
+ chunk = f.read(
+ 1024 * 1024
+ )
+
+ if not chunk:
+ break
+
+ h.update(
+ chunk
+ )
+
+ return h.hexdigest()
+
+
+def norm_path(path: str | Path) -> str:
+ return str(
+ path
+ ).replace(
+ "\\",
+ "/",
+ )
+
+
+def deep_merge(
+ base: dict,
+ update: dict,
+) -> dict:
+ """
+ Merge simples para POLÍTICAS runtime.
+ Calibrações não devem depender disso.
+ """
+ out = deepcopy(
+ base
+ if isinstance(base, dict)
+ else {}
+ )
+
+ if not isinstance(
+ update,
+ dict,
+ ):
+ return out
+
+ for key, value in update.items():
+ if (
+ isinstance(value, dict)
+ and isinstance(
+ out.get(key),
+ dict,
+ )
+ ):
+ out[key] = deep_merge(
+ out[key],
+ value,
+ )
+ else:
+ out[key] = deepcopy(
+ value
+ )
+
+ return out
+
+
+def require_schema(
+ name: str,
+ data: dict,
+):
+ expected = EXPECTED_SCHEMAS[
+ name
+ ]
+
+ actual = data.get(
+ "schema"
+ )
+
+ if actual != expected:
+ raise RuntimeError(
+ f"{name}: schema={actual!r}, "
+ f"esperado={expected!r}"
+ )
+
+
+def require_promoted(
+ name: str,
+ data: dict,
+ *,
+ allowed_statuses: tuple[str, ...],
+):
+ if not bool(
+ data.get(
+ "promoted",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ f"{name}: artefato não promovido."
+ )
+
+ status = str(
+ data.get(
+ "status",
+ ""
+ )
+ ).lower()
+
+ if (
+ status
+ not in allowed_statuses
+ ):
+ raise RuntimeError(
+ f"{name}: status={status!r}, "
+ f"aceitos={allowed_statuses}"
+ )
+
+
+def is_matrix_3x3(value) -> bool:
+ if not (
+ isinstance(
+ value,
+ list,
+ )
+ and len(value) == 3
+ ):
+ return False
+
+ return all(
+ isinstance(
+ row,
+ list,
+ )
+ and len(row) == 3
+ for row in value
+ )
+
+
+def is_positive_int(v) -> bool:
+ try:
+ return int(v) > 0
+ except Exception:
+ return False
+
+
+def artifact_meta(
+ path: str | Path,
+ data: dict,
+) -> dict:
+ p = Path(path)
+
+ return {
+ "path": norm_path(p),
+ "sha256": sha256_file(
+ p
+ ),
+ "schema": data.get(
+ "schema"
+ ),
+ "status": data.get(
+ "status"
+ ),
+ "promoted": data.get(
+ "promoted"
+ ),
+ "session_id": data.get(
+ "session_id"
+ ),
+ "created_at": data.get(
+ "created_at"
+ ),
+ "finished_at": data.get(
+ "finished_at"
+ ),
+ }
+
+
+# ============================================================
+# Hardware signature normalization
+# ============================================================
+
+def normalize_role_item(
+ item: dict,
+) -> dict:
+ if not isinstance(
+ item,
+ dict,
+ ):
+ raise RuntimeError(
+ f"Hardware role inválido: {item!r}"
+ )
+
+ socket = (
+ item.get("socket")
+ or item.get(
+ "socket_name"
+ )
+ )
+
+ sensor = (
+ item.get("sensor")
+ or item.get(
+ "sensor_name"
+ )
+ )
+
+ size = item.get(
+ "size"
+ )
+
+ if size is None:
+ width = item.get(
+ "width"
+ )
+
+ height = item.get(
+ "height"
+ )
+
+ if (
+ width is not None
+ and height is not None
+ ):
+ size = [
+ int(width),
+ int(height),
+ ]
+
+ if (
+ socket is None
+ or sensor is None
+ or size is None
+ or len(size) != 2
+ ):
+ raise RuntimeError(
+ f"Hardware role incompleto: {item}"
+ )
+
+ return {
+ "socket": str(
+ socket
+ ),
+ "sensor": str(
+ sensor
+ ).upper(),
+ "size": [
+ int(
+ size[0]
+ ),
+ int(
+ size[1]
+ ),
+ ],
+ }
+
+
+def normalize_signature(
+ signature: dict,
+) -> dict:
+ if not isinstance(
+ signature,
+ dict,
+ ):
+ raise RuntimeError(
+ "hardware_signature inválida."
+ )
+
+ out = {}
+
+ for role in ROLES:
+ item = signature.get(
+ role
+ )
+
+ if item is None:
+ raise RuntimeError(
+ f"hardware_signature sem role {role}"
+ )
+
+ out[
+ role
+ ] = normalize_role_item(
+ item
+ )
+
+ return out
+
+
+def signature_from_resolved_setup(
+ resolved_setup: dict,
+) -> dict:
+ return normalize_signature(
+ resolved_setup
+ )
+
+
+def signature_from_focus(
+ data: dict,
+) -> dict:
+ return signature_from_resolved_setup(
+ data.get(
+ "resolved_setup",
+ {},
+ )
+ )
+
+
+def signature_from_flatfield(
+ data: dict,
+) -> dict:
+ return signature_from_resolved_setup(
+ data.get(
+ "resolved_setup",
+ {},
+ )
+ )
+
+
+def signature_from_direct(
+ data: dict,
+) -> dict:
+ return normalize_signature(
+ data.get(
+ "hardware_signature",
+ {},
+ )
+ )
+
+
+def validate_product_signature(
+ signature: dict,
+):
+ for role in ROLES:
+ item = signature[
+ role
+ ]
+
+ contract = EXPECTED_TOPOLOGY[
+ role
+ ]
+
+ if (
+ item["socket"]
+ != contract["socket"]
+ ):
+ raise RuntimeError(
+ f"{role}: socket={item['socket']}, "
+ f"esperado={contract['socket']}"
+ )
+
+ if (
+ item["sensor"]
+ not in contract[
+ "allowed_sensors"
+ ]
+ ):
+ raise RuntimeError(
+ f"{role}: sensor={item['sensor']}, "
+ f"permitidos={contract['allowed_sensors']}"
+ )
+
+ expected_size = (
+ EXPECTED_NATIVE_SIZES[
+ item["sensor"]
+ ]
+ )
+
+ if (
+ item["size"]
+ != expected_size
+ ):
+ raise RuntimeError(
+ f"{role}/{item['sensor']}: "
+ f"size={item['size']}, "
+ f"esperado={expected_size}"
+ )
+
+
+def compare_signatures(
+ named_signatures: dict[str, dict],
+) -> dict:
+ names = list(
+ named_signatures.keys()
+ )
+
+ if not names:
+ raise RuntimeError(
+ "Nenhuma assinatura de hardware."
+ )
+
+ reference_name = names[
+ 0
+ ]
+
+ reference = named_signatures[
+ reference_name
+ ]
+
+ mismatches = []
+
+ for name in names[
+ 1:
+ ]:
+ if (
+ named_signatures[
+ name
+ ]
+ != reference
+ ):
+ mismatches.append({
+ "artifact": name,
+ "signature": named_signatures[
+ name
+ ],
+ })
+
+ if mismatches:
+ raise RuntimeError(
+ "Artefatos pertencem a hardwares diferentes.\n"
+ f"Referência {reference_name}: {reference}\n"
+ f"Divergências: {mismatches}"
+ )
+
+ validate_product_signature(
+ reference
+ )
+
+ return reference
+
+
+# ============================================================
+# MX ID / module ID cross-check
+# ============================================================
+
+def extract_mx_ids(
+ artifacts: dict[str, dict],
+) -> dict:
+ out = {}
+
+ for name, data in artifacts.items():
+ candidates = [
+ data.get(
+ "device_mx_id"
+ ),
+ (
+ data.get(
+ "device",
+ {}
+ )
+ or {}
+ ).get(
+ "mx_id"
+ ),
+ ]
+
+ value = next(
+ (
+ str(x)
+ for x in candidates
+ if x
+ ),
+ None,
+ )
+
+ if value:
+ out[
+ name
+ ] = value
+
+ return out
+
+
+def validate_single_value(
+ values: dict[str, str],
+ label: str,
+) -> Optional[str]:
+ unique = sorted(
+ set(
+ values.values()
+ )
+ )
+
+ if len(
+ unique
+ ) > 1:
+ raise RuntimeError(
+ f"{label} divergente entre artefatos: {values}"
+ )
+
+ return (
+ unique[0]
+ if unique
+ else None
+ )
+
+
+def extract_module_ids(
+ artifacts: dict[str, dict],
+) -> dict:
+ out = {}
+
+ for name, data in artifacts.items():
+ trace = (
+ data.get(
+ "traceability",
+ {}
+ )
+ or {}
+ )
+
+ value = trace.get(
+ "module_id"
+ )
+
+ if value:
+ out[
+ name
+ ] = str(
+ value
+ )
+
+ return out
+
+
+# ============================================================
+# Artefact validators
+# ============================================================
+
+def validate_focus(
+ data: dict,
+):
+ require_schema(
+ "focus",
+ data,
+ )
+
+ require_promoted(
+ "focus",
+ data,
+ allowed_statuses=(
+ "pass",
+ ),
+ )
+
+ if not bool(
+ data.get(
+ "normal_production_homologation",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Focus não é homologação normal de produção."
+ )
+
+ if data.get(
+ "runtime_effect"
+ ) != "none":
+ raise RuntimeError(
+ "Focus inesperadamente declara efeito de runtime."
+ )
+
+
+def validate_flatfield(
+ data: dict,
+ flatfield_json_path: str | Path,
+ flatfield_npz_override: Optional[str],
+) -> tuple[dict, Path]:
+ require_schema(
+ "flatfield",
+ data,
+ )
+
+ require_promoted(
+ "flatfield",
+ data,
+ allowed_statuses=(
+ "good",
+ "warning",
+ ),
+ )
+
+ patch = data.get(
+ "suggested_module_params_patch"
+ )
+
+ if not isinstance(
+ patch,
+ dict,
+ ):
+ raise RuntimeError(
+ "Flat-Field sem suggested_module_params_patch."
+ )
+
+ cfg = patch.get(
+ "flatfield_config"
+ )
+
+ if not isinstance(
+ cfg,
+ dict,
+ ):
+ raise RuntimeError(
+ "Flat-Field sem suggested_module_params_patch.flatfield_config."
+ )
+
+ if not bool(
+ cfg.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Flat-Field homologado, mas patch está disabled."
+ )
+
+ outputs = (
+ data.get(
+ "outputs",
+ {}
+ )
+ or {}
+ )
+
+ npz_path = (
+ flatfield_npz_override
+ or outputs.get(
+ "active_npz"
+ )
+ or cfg.get(
+ "npz_file"
+ )
+ )
+
+ if not npz_path:
+ raise RuntimeError(
+ "Flat-Field não informa active NPZ."
+ )
+
+ npz = Path(
+ npz_path
+ )
+
+ if not npz.is_file():
+ # Se caminho foi gravado relativo ao projeto, tenta relativo ao JSON.
+ candidate = (
+ Path(
+ flatfield_json_path
+ )
+ .parent
+ / npz_path
+ )
+
+ if candidate.is_file():
+ npz = candidate
+ else:
+ raise FileNotFoundError(
+ f"Flat-Field NPZ não encontrado: {npz_path}"
+ )
+
+ required_keys = {
+ f"gain_{ch}"
+ for ch in CHANNELS
+ }
+
+ with zipfile.ZipFile(
+ npz,
+ "r",
+ ) as zf:
+ names = set(
+ zf.namelist()
+ )
+
+ # NPZ guarda .npy
+ expected_members = {
+ f"{key}.npy"
+ for key in required_keys
+ }
+
+ missing = sorted(
+ expected_members - names
+ )
+
+ if missing:
+ raise RuntimeError(
+ f"Flat-Field NPZ sem gains obrigatórios: {missing}"
+ )
+
+ runtime_cfg = deepcopy(
+ cfg
+ )
+
+ # Canonicaliza channel_maps para o contrato real do RawProcessorCore.
+ # O calibrador Flat-Field pode emitir:
+ # "R": "gain_R"
+ # enquanto o runtime atual consome:
+ # "R": {
+ # "gain_key": "gain_R",
+ # "dark_median_key": "dark_median_R",
+ # }
+ raw_channel_maps = (
+ runtime_cfg.get(
+ "channel_maps",
+ {},
+ )
+ or {}
+ )
+
+ normalized_channel_maps = {}
+
+ for ch in CHANNELS:
+ item = raw_channel_maps.get(
+ ch
+ )
+
+ if isinstance(
+ item,
+ str,
+ ):
+ normalized_channel_maps[
+ ch
+ ] = {
+ "gain_key": item,
+ "dark_median_key": (
+ f"dark_median_{ch}"
+ ),
+ }
+
+ elif isinstance(
+ item,
+ dict,
+ ):
+ entry = deepcopy(
+ item
+ )
+
+ entry.setdefault(
+ "gain_key",
+ f"gain_{ch}",
+ )
+
+ entry.setdefault(
+ "dark_median_key",
+ f"dark_median_{ch}",
+ )
+
+ normalized_channel_maps[
+ ch
+ ] = entry
+
+ else:
+ normalized_channel_maps[
+ ch
+ ] = {
+ "gain_key": (
+ f"gain_{ch}"
+ ),
+ "dark_median_key": (
+ f"dark_median_{ch}"
+ ),
+ }
+
+ runtime_cfg[
+ "channel_maps"
+ ] = normalized_channel_maps
+
+ runtime_cfg[
+ "npz_file"
+ ] = norm_path(
+ npz
+ )
+
+ runtime_cfg[
+ "json_file"
+ ] = norm_path(
+ flatfield_json_path
+ )
+
+ runtime_cfg[
+ "schema"
+ ] = data.get(
+ "schema"
+ )
+
+ runtime_cfg[
+ "created_at"
+ ] = data.get(
+ "created_at"
+ )
+
+ # Política final já decidida: não ativar dark model simples.
+ runtime_cfg[
+ "subtract_dark"
+ ] = False
+
+ return runtime_cfg, npz
+
+
+def validate_radiometry(
+ data: dict,
+) -> dict:
+ require_schema(
+ "radiometry",
+ data,
+ )
+
+ require_promoted(
+ "radiometry",
+ data,
+ allowed_statuses=(
+ "good",
+ "warning",
+ ),
+ )
+
+ frag = (
+ data.get(
+ "module_params_fragment",
+ {}
+ )
+ or {}
+ )
+
+ cfg = frag.get(
+ "radiometric_normalization"
+ )
+
+ if not isinstance(
+ cfg,
+ dict,
+ ):
+ raise RuntimeError(
+ "Radiometry sem module_params_fragment.radiometric_normalization."
+ )
+
+ if not bool(
+ cfg.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Radiometric normalization homologada, mas disabled."
+ )
+
+ if (
+ cfg.get(
+ "method"
+ )
+ != "oak_ae_frame_controls_v1"
+ ):
+ raise RuntimeError(
+ f"Método radiométrico inesperado: {cfg.get('method')}"
+ )
+
+ if (
+ cfg.get(
+ "factor_model"
+ )
+ != "exposure_time_us_x_iso"
+ ):
+ raise RuntimeError(
+ f"factor_model inesperado: {cfg.get('factor_model')}"
+ )
+
+ refs = (
+ cfg.get(
+ "reference_controls",
+ {}
+ )
+ or {}
+ )
+
+ for role in ROLES:
+ ref = refs.get(
+ role
+ )
+
+ if not isinstance(
+ ref,
+ dict,
+ ):
+ raise RuntimeError(
+ f"Radiometry sem reference_controls.{role}"
+ )
+
+ if not is_positive_int(
+ ref.get(
+ "exposure_time_us"
+ )
+ ):
+ raise RuntimeError(
+ f"Radiometry exposure inválida em {role}"
+ )
+
+ if not is_positive_int(
+ ref.get(
+ "sensitivity_iso"
+ )
+ ):
+ raise RuntimeError(
+ f"Radiometry ISO inválido em {role}"
+ )
+
+ return deepcopy(
+ cfg
+ )
+
+
+def validate_startup(
+ data: dict,
+ radiometry: dict,
+) -> tuple[dict, dict]:
+ require_schema(
+ "startup",
+ data,
+ )
+
+ require_promoted(
+ "startup",
+ data,
+ allowed_statuses=(
+ "good",
+ ),
+ )
+
+ frag = (
+ data.get(
+ "module_params_fragment",
+ {}
+ )
+ or {}
+ )
+
+ camera_settings = frag.get(
+ "camera_settings"
+ )
+
+ rgb_calibration = frag.get(
+ "rgb_calibration"
+ )
+
+ if not isinstance(
+ camera_settings,
+ dict,
+ ):
+ raise RuntimeError(
+ "Startup sem camera_settings."
+ )
+
+ if not isinstance(
+ rgb_calibration,
+ dict,
+ ):
+ raise RuntimeError(
+ "Startup sem rgb_calibration."
+ )
+
+ if bool(
+ rgb_calibration.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Startup tentou habilitar rgb_calibration."
+ )
+
+ gains = (
+ rgb_calibration.get(
+ "gains",
+ {}
+ )
+ or {}
+ )
+
+ for ch in (
+ "R",
+ "G",
+ "B",
+ ):
+ if float(
+ gains.get(
+ ch,
+ 0.0,
+ )
+ ) != 1.0:
+ raise RuntimeError(
+ f"Startup rgb gain {ch} não é unity."
+ )
+
+ for role in ROLES:
+ cfg = camera_settings.get(
+ role
+ )
+
+ if not isinstance(
+ cfg,
+ dict,
+ ):
+ raise RuntimeError(
+ f"Startup sem camera_settings.{role}"
+ )
+
+ if not bool(
+ cfg.get(
+ "ae_enable",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ f"Startup produto exige AE=true em {role}."
+ )
+
+ source_rad = (
+ data.get(
+ "source_radiometry",
+ {}
+ )
+ or {}
+ )
+
+ rad_session = radiometry.get(
+ "session_id"
+ )
+
+ source_session = source_rad.get(
+ "session_id"
+ )
+
+ if (
+ rad_session
+ and source_session
+ and rad_session
+ != source_session
+ ):
+ raise RuntimeError(
+ "Startup Profile foi gerado a partir de outra Radiometry: "
+ f"startup={source_session}, atual={rad_session}"
+ )
+
+ rad_refs = (
+ (
+ radiometry.get(
+ "module_params_fragment",
+ {}
+ )
+ or {}
+ )
+ .get(
+ "radiometric_normalization",
+ {}
+ )
+ .get(
+ "reference_controls",
+ {}
+ )
+ )
+
+ startup_refs = source_rad.get(
+ "reference_controls",
+ {}
+ )
+
+ if (
+ startup_refs
+ and startup_refs != rad_refs
+ ):
+ raise RuntimeError(
+ "Startup reference_controls divergem da Radiometry ativa."
+ )
+
+ return (
+ deepcopy(
+ camera_settings
+ ),
+ deepcopy(
+ rgb_calibration
+ ),
+ )
+
+
+def validate_intrinsics(
+ data: dict,
+) -> dict:
+ require_schema(
+ "intrinsics",
+ data,
+ )
+
+ require_promoted(
+ "intrinsics",
+ data,
+ allowed_statuses=(
+ "pass",
+ "warning_promoted",
+ ),
+ )
+
+ frag = (
+ data.get(
+ "module_params_fragment",
+ {}
+ )
+ or {}
+ )
+
+ cfg = frag.get(
+ "intrinsics_config"
+ )
+
+ if not isinstance(
+ cfg,
+ dict,
+ ):
+ raise RuntimeError(
+ "Intrinsics sem module_params_fragment.intrinsics_config."
+ )
+
+ if not bool(
+ cfg.get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "Intrinsics homologado, mas intrinsics_config disabled."
+ )
+
+ cameras = (
+ cfg.get(
+ "cameras",
+ {}
+ )
+ or {}
+ )
+
+ for role in ROLES:
+ cam = cameras.get(
+ role
+ )
+
+ if not isinstance(
+ cam,
+ dict,
+ ):
+ raise RuntimeError(
+ f"Intrinsics sem câmera {role}."
+ )
+
+ if not is_matrix_3x3(
+ cam.get(
+ "camera_matrix"
+ )
+ ):
+ raise RuntimeError(
+ f"Intrinsics K inválida em {role}."
+ )
+
+ D = cam.get(
+ "dist_coeffs"
+ )
+
+ if not (
+ isinstance(
+ D,
+ list,
+ )
+ and len(D) >= 4
+ ):
+ raise RuntimeError(
+ f"Intrinsics dist_coeffs inválida em {role}."
+ )
+
+ size = cam.get(
+ "image_size"
+ )
+
+ if not (
+ isinstance(
+ size,
+ list,
+ )
+ and len(size) == 2
+ and is_positive_int(
+ size[0]
+ )
+ and is_positive_int(
+ size[1]
+ )
+ ):
+ raise RuntimeError(
+ f"Intrinsics image_size inválida em {role}."
+ )
+
+ return deepcopy(
+ cfg
+ )
+
+
+def validate_homography(
+ data: dict,
+ profile_override: Optional[str],
+) -> dict:
+ require_schema(
+ "homography",
+ data,
+ )
+
+ # O active v4 é um container de perfis e não carrega promoted=true.
+ profiles = (
+ data.get(
+ "profiles",
+ {}
+ )
+ or {}
+ )
+
+ if not profiles:
+ raise RuntimeError(
+ "Homography ativa não possui perfis."
+ )
+
+ profile = (
+ profile_override
+ or data.get(
+ "recommended_profile"
+ )
+ )
+
+ if not profile:
+ raise RuntimeError(
+ "Homography sem recommended_profile. "
+ "Use --homography-profile."
+ )
+
+ if profile not in profiles:
+ raise RuntimeError(
+ f"Perfil de homografia {profile!r} não existe. "
+ f"Disponíveis={sorted(profiles)}"
+ )
+
+ # Garante que todos os perfis guardados são produtos de um acceptance válido.
+ for name, payload in profiles.items():
+ quality = (
+ payload.get(
+ "quality",
+ {}
+ )
+ or {}
+ )
+
+ status = str(
+ quality.get(
+ "status",
+ ""
+ )
+ ).lower()
+
+ if status not in (
+ "good",
+ "warning",
+ ):
+ raise RuntimeError(
+ f"Homography profile {name}: quality.status={status!r}"
+ )
+
+ homographies = (
+ payload.get(
+ "homographies",
+ {}
+ )
+ or {}
+ )
+
+ for key in (
+ "re_to_rgb",
+ "nir_to_rgb",
+ ):
+ if not is_matrix_3x3(
+ homographies.get(
+ key
+ )
+ ):
+ raise RuntimeError(
+ f"Homography profile {name}: matriz {key} inválida."
+ )
+
+ fragment = (
+ data.get(
+ "module_params_fragment",
+ {}
+ )
+ or {}
+ )
+
+ cfg = fragment.get(
+ "fusion_config"
+ )
+
+ if not isinstance(
+ cfg,
+ dict,
+ ):
+ raise RuntimeError(
+ "Homography active sem module_params_fragment.fusion_config."
+ )
+
+ out = deepcopy(
+ cfg
+ )
+
+ out[
+ "homography_profile"
+ ] = profile
+
+ out[
+ "homography_profile_by_role"
+ ] = {
+ "re": profile,
+ "nir": profile,
+ }
+
+ selected = (
+ out.get(
+ "homography_profiles",
+ {}
+ )
+ or {}
+ ).get(
+ profile
+ )
+
+ if not isinstance(
+ selected,
+ dict,
+ ):
+ raise RuntimeError(
+ "Fragmento fusion_config não contém o perfil selecionado."
+ )
+
+ out[
+ "homography_calibration_size"
+ ] = deepcopy(
+ selected.get(
+ "reference_size"
+ )
+ or selected.get(
+ "homography_calibration_size"
+ )
+ )
+
+ return out
+
+
+# ============================================================
+# Cross-artifact geometry checks
+# ============================================================
+
+def validate_geometry_contract(
+ intrinsics_cfg: dict,
+ homography_data: dict,
+ fusion_cfg: dict,
+):
+ intr_space = intrinsics_cfg.get(
+ "calibration_space"
+ )
+
+ hom_space = homography_data.get(
+ "coordinate_space"
+ )
+
+ runtime_und = (
+ intrinsics_cfg.get(
+ "runtime_undistort",
+ {}
+ )
+ or {}
+ )
+
+ und_enabled = bool(
+ runtime_und.get(
+ "enabled",
+ False,
+ )
+ )
+
+ if not und_enabled:
+ if (
+ intr_space != CALIBRATION_SPACE_NATIVE
+ or hom_space != CALIBRATION_SPACE_NATIVE
+ ):
+ raise RuntimeError(
+ "Domínio geométrico inconsistente: "
+ f"intrinsics={intr_space!r}, homography={hom_space!r}, "
+ "runtime_undistort=false."
+ )
+
+ else:
+ if (
+ hom_space
+ == CALIBRATION_SPACE_NATIVE
+ ):
+ raise RuntimeError(
+ "runtime_undistort=true, mas Homography foi calibrada "
+ "no espaço nativo distorcido. Recalibre a Homography."
+ )
+
+ selected_profile = fusion_cfg[
+ "homography_profile"
+ ]
+
+ profile = (
+ fusion_cfg[
+ "homography_profiles"
+ ][
+ selected_profile
+ ]
+ )
+
+ ref_size = (
+ profile.get(
+ "reference_size"
+ )
+ or profile.get(
+ "homography_calibration_size"
+ )
+ )
+
+ src_sizes = (
+ profile.get(
+ "source_size_by_role",
+ {}
+ )
+ or {}
+ )
+
+ cams = intrinsics_cfg[
+ "cameras"
+ ]
+
+ if (
+ ref_size
+ != cams[
+ "rgb"
+ ][
+ "image_size"
+ ]
+ ):
+ raise RuntimeError(
+ "Homography RGB reference_size diverge de Intrinsics RGB: "
+ f"{ref_size} != {cams['rgb']['image_size']}"
+ )
+
+ for role in (
+ "re",
+ "nir",
+ ):
+ source_size = src_sizes.get(
+ role
+ )
+
+ if (
+ source_size is not None
+ and source_size
+ != cams[
+ role
+ ][
+ "image_size"
+ ]
+ ):
+ raise RuntimeError(
+ f"Homography source_size {role} diverge de Intrinsics: "
+ f"{source_size} != {cams[role]['image_size']}"
+ )
+
+
+# ============================================================
+# Runtime base, whitelist only
+# ============================================================
+
+def runtime_defaults() -> dict:
+ return {
+ "onnx_model_path": None,
+ "frame_type": "RAW_BRUTO",
+ "capture_mode_requested": "AUTO",
+ "capture_mode_effective": "AUTO",
+ "raw_policy": "allow_single",
+ "rgb_processing": {
+ "mode": "bayer_planes",
+ },
+ "fusion_runtime": {
+ "use_remap_cache": True,
+ "use_remap_for_rgb": False,
+ "use_remap_for_spec": True,
+ "crop_valid_common": True,
+ "resize_after_crop": True,
+ "target_size": None,
+ },
+ }
+
+
+def read_runtime_policy(
+ base: dict,
+) -> dict:
+ """
+ Extrai SOMENTE campos que não são calibração.
+ """
+ defaults = runtime_defaults()
+
+ if not isinstance(
+ base,
+ dict,
+ ):
+ return defaults
+
+ out = deepcopy(
+ defaults
+ )
+
+ for key in (
+ "onnx_model_path",
+ "frame_type",
+ "capture_mode_requested",
+ "capture_mode_effective",
+ "raw_policy",
+ ):
+ if key in base:
+ out[
+ key
+ ] = deepcopy(
+ base[
+ key
+ ]
+ )
+
+ if isinstance(
+ base.get(
+ "rgb_processing"
+ ),
+ dict,
+ ):
+ out[
+ "rgb_processing"
+ ] = deepcopy(
+ base[
+ "rgb_processing"
+ ]
+ )
+
+ old_fusion = (
+ base.get(
+ "fusion_config",
+ {}
+ )
+ or {}
+ )
+
+ for key in (
+ "use_remap_cache",
+ "use_remap_for_rgb",
+ "use_remap_for_spec",
+ "crop_valid_common",
+ "resize_after_crop",
+ "target_size",
+ ):
+ if key in old_fusion:
+ out[
+ "fusion_runtime"
+ ][
+ key
+ ] = deepcopy(
+ old_fusion[
+ key
+ ]
+ )
+
+ return out
+
+
+# ============================================================
+# Bayer from Flat-Field
+# ============================================================
+
+def resolve_bayer_from_flatfield(
+ flatfield: dict,
+) -> str:
+ setup = (
+ flatfield.get(
+ "resolved_setup",
+ {}
+ )
+ or {}
+ )
+
+ rgb = (
+ setup.get(
+ "rgb",
+ {}
+ )
+ or {}
+ )
+
+ bayer = rgb.get(
+ "bayer_pattern"
+ )
+
+ if not bayer:
+ raise RuntimeError(
+ "Flat-Field homologado não registra RGB bayer_pattern. "
+ "Rode novamente o Flat-Field de produção."
+ )
+
+ bayer = str(
+ bayer
+ ).upper()
+
+ if bayer not in (
+ "RGGB",
+ "BGGR",
+ "GRBG",
+ "GBRG",
+ ):
+ raise RuntimeError(
+ f"Bayer inválido no Flat-Field: {bayer!r}"
+ )
+
+ return bayer
+
+
+# ============================================================
+# Final build
+# ============================================================
+
+def disabled_radiometric_controller() -> dict:
+ return {
+ "enabled": False,
+ "policy": "disabled_product_default",
+ "reason": (
+ "Field radiometric controller is not part of factory calibration. "
+ "Per-frame exposure variation is handled by "
+ "radiometric_normalization."
+ ),
+ }
+
+
+def disabled_patch_normalization() -> dict:
+ return {
+ "enabled": False,
+ "policy": "disabled_product_default",
+ "reason": (
+ "Reference-patch post-fusion normalization is not active "
+ "in the homologated product pipeline."
+ ),
+ }
+
+
+def build_final_module_params(
+ runtime_policy: dict,
+ hardware_signature: dict,
+ bayer_pattern: str,
+ camera_settings: dict,
+ rgb_calibration: dict,
+ radiometric_normalization: dict,
+ flatfield_config: dict,
+ intrinsics_config: dict,
+ fusion_config: dict,
+ provenance: dict,
+ assembly_report_path: str,
+) -> dict:
+ rgb_hw = hardware_signature[
+ "rgb"
+ ]
+
+ fusion = deepcopy(
+ fusion_config
+ )
+
+ for key, value in runtime_policy[
+ "fusion_runtime"
+ ].items():
+ fusion[
+ key
+ ] = deepcopy(
+ value
+ )
+
+ result = {
+ "schema": FINAL_SCHEMA,
+ "saved_at": now_str(),
+
+ # Runtime/model policy
+ "onnx_model_path": runtime_policy.get(
+ "onnx_model_path"
+ ),
+ "frame_type": runtime_policy[
+ "frame_type"
+ ],
+ "capture_mode_requested": runtime_policy[
+ "capture_mode_requested"
+ ],
+ "capture_mode_effective": runtime_policy[
+ "capture_mode_effective"
+ ],
+ "raw_policy": runtime_policy[
+ "raw_policy"
+ ],
+
+ # Compatibilidade root: RGB reference camera
+ "sensor_width": int(
+ rgb_hw[
+ "size"
+ ][0]
+ ),
+ "sensor_height": int(
+ rgb_hw[
+ "size"
+ ][1]
+ ),
+ "bayer_pattern": bayer_pattern,
+
+ # Novo contrato explícito de geometria física
+ "sensor_size_by_role": {
+ role: deepcopy(
+ hardware_signature[
+ role
+ ][
+ "size"
+ ]
+ )
+ for role in ROLES
+ },
+ "camera_hardware": deepcopy(
+ hardware_signature
+ ),
+
+ "rgb_processing": deepcopy(
+ runtime_policy[
+ "rgb_processing"
+ ]
+ ),
+
+ # Calibrações/políticas homologadas
+ "camera_settings": deepcopy(
+ camera_settings
+ ),
+ "fusion_config": fusion,
+ "intrinsics_config": deepcopy(
+ intrinsics_config
+ ),
+ "radiometric_config": (
+ disabled_radiometric_controller()
+ ),
+ "radiometric_normalization": deepcopy(
+ radiometric_normalization
+ ),
+ "patch_normalization": (
+ disabled_patch_normalization()
+ ),
+ "rgb_calibration": deepcopy(
+ rgb_calibration
+ ),
+ "flatfield_config": deepcopy(
+ flatfield_config
+ ),
+
+ # Auditoria. Consumers antigos podem ignorar.
+ "calibration_provenance": deepcopy(
+ provenance
+ ),
+ "assembly_metadata": {
+ "schema": ASSEMBLER_SCHEMA,
+ "assembled_at": now_str(),
+ "assembly_report": norm_path(
+ assembly_report_path
+ ),
+ "calibration_line": [
+ "focus",
+ "intrinsics",
+ "flatfield",
+ "radiometry",
+ "camera_startup",
+ "homography",
+ "module_params_assembler",
+ ],
+ "runtime_undistort_enabled": bool(
+ (
+ intrinsics_config.get(
+ "runtime_undistort",
+ {}
+ )
+ or {}
+ ).get(
+ "enabled",
+ False,
+ )
+ ),
+ },
+ }
+
+ # Não escreve null desnecessário para ONNX se base não tinha.
+ if (
+ result[
+ "onnx_model_path"
+ ]
+ is None
+ ):
+ result.pop(
+ "onnx_model_path",
+ None,
+ )
+
+ return result
+
+
+# ============================================================
+# Final structural validation
+# ============================================================
+
+def validate_final_module_params(
+ mp: dict,
+):
+ required_root = (
+ "schema",
+ "sensor_width",
+ "sensor_height",
+ "bayer_pattern",
+ "sensor_size_by_role",
+ "camera_hardware",
+ "rgb_processing",
+ "camera_settings",
+ "fusion_config",
+ "intrinsics_config",
+ "radiometric_config",
+ "radiometric_normalization",
+ "patch_normalization",
+ "rgb_calibration",
+ "flatfield_config",
+ )
+
+ missing = [
+ key
+ for key in required_root
+ if key not in mp
+ ]
+
+ if missing:
+ raise RuntimeError(
+ f"module_params final sem chaves: {missing}"
+ )
+
+ if (
+ mp[
+ "schema"
+ ]
+ != FINAL_SCHEMA
+ ):
+ raise RuntimeError(
+ "Schema final inválido."
+ )
+
+ if not is_positive_int(
+ mp[
+ "sensor_width"
+ ]
+ ) or not is_positive_int(
+ mp[
+ "sensor_height"
+ ]
+ ):
+ raise RuntimeError(
+ "sensor_width/height inválidos."
+ )
+
+ for role in ROLES:
+ if role not in mp[
+ "camera_settings"
+ ]:
+ raise RuntimeError(
+ f"camera_settings sem {role}"
+ )
+
+ if role not in mp[
+ "sensor_size_by_role"
+ ]:
+ raise RuntimeError(
+ f"sensor_size_by_role sem {role}"
+ )
+
+ fusion = mp[
+ "fusion_config"
+ ]
+
+ if (
+ fusion.get(
+ "alignment_mode"
+ )
+ != "homography"
+ ):
+ raise RuntimeError(
+ "fusion_config.alignment_mode deve ser homography."
+ )
+
+ selected = fusion.get(
+ "homography_profile"
+ )
+
+ profiles = (
+ fusion.get(
+ "homography_profiles",
+ {}
+ )
+ or {}
+ )
+
+ if (
+ not selected
+ or selected not in profiles
+ ):
+ raise RuntimeError(
+ "Perfil de homografia final inválido."
+ )
+
+ if bool(
+ mp[
+ "radiometric_config"
+ ].get(
+ "enabled",
+ True,
+ )
+ ):
+ raise RuntimeError(
+ "radiometric_config deve permanecer disabled."
+ )
+
+ if bool(
+ mp[
+ "patch_normalization"
+ ].get(
+ "enabled",
+ True,
+ )
+ ):
+ raise RuntimeError(
+ "patch_normalization deve permanecer disabled."
+ )
+
+ if bool(
+ mp[
+ "rgb_calibration"
+ ].get(
+ "enabled",
+ True,
+ )
+ ):
+ raise RuntimeError(
+ "rgb_calibration deve permanecer disabled."
+ )
+
+ if not bool(
+ mp[
+ "radiometric_normalization"
+ ].get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "radiometric_normalization deve estar enabled."
+ )
+
+ if not bool(
+ mp[
+ "flatfield_config"
+ ].get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "flatfield_config deve estar enabled."
+ )
+
+ flat_maps = (
+ mp[
+ "flatfield_config"
+ ].get(
+ "channel_maps",
+ {},
+ )
+ or {}
+ )
+
+ for ch in CHANNELS:
+ ch_cfg = flat_maps.get(
+ ch
+ )
+
+ if not isinstance(
+ ch_cfg,
+ dict,
+ ):
+ raise RuntimeError(
+ f"flatfield_config.channel_maps.{ch} deve ser dict."
+ )
+
+ if not ch_cfg.get(
+ "gain_key"
+ ):
+ raise RuntimeError(
+ f"flatfield_config.channel_maps.{ch}.gain_key ausente."
+ )
+
+ if not bool(
+ mp[
+ "intrinsics_config"
+ ].get(
+ "enabled",
+ False,
+ )
+ ):
+ raise RuntimeError(
+ "intrinsics_config deve estar enabled."
+ )
+
+ # Root deve refletir RGB.
+ rgb_size = mp[
+ "sensor_size_by_role"
+ ][
+ "rgb"
+ ]
+
+ if [
+ mp[
+ "sensor_width"
+ ],
+ mp[
+ "sensor_height"
+ ],
+ ] != rgb_size:
+ raise RuntimeError(
+ "sensor_width/height root não correspondem ao RGB."
+ )
+
+
+# ============================================================
+# Main
+# ============================================================
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=(
+ "Assembler fail-closed do module_params final "
+ "para o módulo multiespectral."
+ ),
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ parser.add_argument(
+ "--focus-json",
+ default="calibration/focus_qc_active.json",
+ )
+
+ parser.add_argument(
+ "--flatfield-json",
+ default="calibration/flatfield_maps_v1.json",
+ )
+
+ parser.add_argument(
+ "--flatfield-npz",
+ default=None,
+ help=(
+ "Override opcional do NPZ. Normalmente é obtido "
+ "do próprio flatfield JSON."
+ ),
+ )
+
+ parser.add_argument(
+ "--radiometry-json",
+ default="calibration/radiometry_calibration_v5.json",
+ )
+
+ parser.add_argument(
+ "--startup-json",
+ default="calibration/camera_startup_profile_v3.json",
+ )
+
+ parser.add_argument(
+ "--intrinsics-json",
+ default="calibration/intrinsics_calibration_v1.json",
+ )
+
+ parser.add_argument(
+ "--homography-json",
+ default="calibration/homography_calibration_v4.json",
+ )
+
+ parser.add_argument(
+ "--homography-profile",
+ default=None,
+ help=(
+ "Override do perfil ativo. Vazio usa recommended_profile "
+ "da calibração de homografia."
+ ),
+ )
+
+ parser.add_argument(
+ "--runtime-base-json",
+ default="calibration/module_params.json",
+ help=(
+ "Fonte SOMENTE de políticas runtime não calibradas. "
+ "Se inexistente, usa defaults seguros."
+ ),
+ )
+
+ parser.add_argument(
+ "--out",
+ default="calibration/module_params.json",
+ )
+
+ parser.add_argument(
+ "--report",
+ default="calibration/module_params_assembly_report.json",
+ )
+
+ parser.add_argument(
+ "--check-only",
+ action="store_true",
+ help="Audita tudo sem escrever module_params.",
+ )
+
+ args = parser.parse_args()
+
+ # --------------------------------------------------------
+ # Load all official artifacts
+ # --------------------------------------------------------
+
+ focus = load_json(
+ args.focus_json
+ )
+
+ flatfield = load_json(
+ args.flatfield_json
+ )
+
+ radiometry = load_json(
+ args.radiometry_json
+ )
+
+ startup = load_json(
+ args.startup_json
+ )
+
+ intrinsics = load_json(
+ args.intrinsics_json
+ )
+
+ homography = load_json(
+ args.homography_json
+ )
+
+ artifacts = {
+ "focus": focus,
+ "flatfield": flatfield,
+ "radiometry": radiometry,
+ "startup": startup,
+ "intrinsics": intrinsics,
+ "homography": homography,
+ }
+
+ # --------------------------------------------------------
+ # Individual validation
+ # --------------------------------------------------------
+
+ validate_focus(
+ focus
+ )
+
+ flatfield_cfg, flatfield_npz = (
+ validate_flatfield(
+ flatfield,
+ args.flatfield_json,
+ args.flatfield_npz,
+ )
+ )
+
+ rad_norm = validate_radiometry(
+ radiometry
+ )
+
+ camera_settings, rgb_calibration = (
+ validate_startup(
+ startup,
+ radiometry,
+ )
+ )
+
+ intrinsics_cfg = (
+ validate_intrinsics(
+ intrinsics
+ )
+ )
+
+ fusion_cfg = (
+ validate_homography(
+ homography,
+ args.homography_profile,
+ )
+ )
+
+ # --------------------------------------------------------
+ # Hardware consistency
+ # --------------------------------------------------------
+
+ signatures = {
+ "focus": signature_from_focus(
+ focus
+ ),
+ "flatfield": signature_from_flatfield(
+ flatfield
+ ),
+ "radiometry": signature_from_direct(
+ radiometry
+ ),
+ "startup": signature_from_direct(
+ startup
+ ),
+ "intrinsics": signature_from_direct(
+ intrinsics
+ ),
+ "homography": normalize_signature(
+ homography.get(
+ "hardware_signature",
+ {},
+ )
+ ),
+ }
+
+ common_signature = (
+ compare_signatures(
+ signatures
+ )
+ )
+
+ mx_ids = extract_mx_ids(
+ artifacts
+ )
+
+ common_mx = validate_single_value(
+ mx_ids,
+ "MX ID",
+ )
+
+ module_ids = extract_module_ids(
+ artifacts
+ )
+
+ common_module_id = (
+ validate_single_value(
+ module_ids,
+ "module_id",
+ )
+ )
+
+ # --------------------------------------------------------
+ # Cross-contract checks
+ # --------------------------------------------------------
+
+ validate_geometry_contract(
+ intrinsics_cfg,
+ homography,
+ fusion_cfg,
+ )
+
+ # Intrinsics dimensions must exactly match common hardware.
+ for role in ROLES:
+ intr_size = (
+ intrinsics_cfg[
+ "cameras"
+ ][role][
+ "image_size"
+ ]
+ )
+
+ if (
+ intr_size
+ != common_signature[
+ role
+ ][
+ "size"
+ ]
+ ):
+ raise RuntimeError(
+ f"Intrinsics {role} size={intr_size} "
+ f"!= hardware={common_signature[role]['size']}"
+ )
+
+ # Startup fallback must match calibrated reference controls.
+ refs = rad_norm[
+ "reference_controls"
+ ]
+
+ for role in ROLES:
+ startup_role = camera_settings[
+ role
+ ]
+
+ if int(
+ startup_role[
+ "exposure_time_us"
+ ]
+ ) != int(
+ refs[
+ role
+ ][
+ "exposure_time_us"
+ ]
+ ):
+ raise RuntimeError(
+ f"Startup fallback EXP de {role} diverge da Radiometry."
+ )
+
+ # --------------------------------------------------------
+ # Runtime policy whitelist
+ # --------------------------------------------------------
+
+ runtime_base = load_json(
+ args.runtime_base_json,
+ required=False,
+ )
+
+ runtime_policy = (
+ read_runtime_policy(
+ runtime_base
+ )
+ )
+
+ # Bayer comes from production Flat-Field hardware resolution.
+ bayer = resolve_bayer_from_flatfield(
+ flatfield
+ )
+
+ # --------------------------------------------------------
+ # Provenance
+ # --------------------------------------------------------
+
+ provenance = {
+ "hardware_signature": deepcopy(
+ common_signature
+ ),
+ "device_mx_id": common_mx,
+ "module_id": common_module_id,
+ "artifacts": {
+ "focus": artifact_meta(
+ args.focus_json,
+ focus,
+ ),
+ "flatfield": {
+ **artifact_meta(
+ args.flatfield_json,
+ flatfield,
+ ),
+ "npz_path": norm_path(
+ flatfield_npz
+ ),
+ "npz_sha256": sha256_file(
+ flatfield_npz
+ ),
+ },
+ "radiometry": artifact_meta(
+ args.radiometry_json,
+ radiometry,
+ ),
+ "startup": artifact_meta(
+ args.startup_json,
+ startup,
+ ),
+ "intrinsics": artifact_meta(
+ args.intrinsics_json,
+ intrinsics,
+ ),
+ "homography": {
+ **artifact_meta(
+ args.homography_json,
+ homography,
+ ),
+ "selected_profile": (
+ fusion_cfg[
+ "homography_profile"
+ ]
+ ),
+ },
+ },
+ }
+
+ # --------------------------------------------------------
+ # Assemble
+ # --------------------------------------------------------
+
+ module_params = build_final_module_params(
+ runtime_policy=runtime_policy,
+ hardware_signature=common_signature,
+ bayer_pattern=bayer,
+ camera_settings=camera_settings,
+ rgb_calibration=rgb_calibration,
+ radiometric_normalization=rad_norm,
+ flatfield_config=flatfield_cfg,
+ intrinsics_config=intrinsics_cfg,
+ fusion_config=fusion_cfg,
+ provenance=provenance,
+ assembly_report_path=args.report,
+ )
+
+ validate_final_module_params(
+ module_params
+ )
+
+ report = {
+ "schema": ASSEMBLER_SCHEMA,
+ "created_at": now_str(),
+ "status": "pass",
+ "check_only": bool(
+ args.check_only
+ ),
+ "output": norm_path(
+ args.out
+ ),
+ "runtime_base_json": norm_path(
+ args.runtime_base_json
+ ),
+ "runtime_base_found": bool(
+ runtime_base
+ ),
+ "hardware_signature": (
+ common_signature
+ ),
+ "device_mx_id": (
+ common_mx
+ ),
+ "module_id": (
+ common_module_id
+ ),
+ "bayer_pattern": bayer,
+ "homography_profile": (
+ fusion_cfg[
+ "homography_profile"
+ ]
+ ),
+ "geometric_contract": {
+ "intrinsics_space": (
+ intrinsics_cfg.get(
+ "calibration_space"
+ )
+ ),
+ "homography_space": (
+ homography.get(
+ "coordinate_space"
+ )
+ ),
+ "runtime_undistort_enabled": bool(
+ (
+ intrinsics_cfg.get(
+ "runtime_undistort",
+ {}
+ )
+ or {}
+ ).get(
+ "enabled",
+ False,
+ )
+ ),
+ },
+ "runtime_policy": runtime_policy,
+ "calibration_provenance": provenance,
+ "final_checks": {
+ "focus_gate": "pass",
+ "flatfield": "pass",
+ "radiometry": "pass",
+ "startup": "pass",
+ "intrinsics": "pass",
+ "homography": "pass",
+ "hardware_consistency": "pass",
+ "geometry_consistency": "pass",
+ "startup_radiometry_consistency": "pass",
+ "flatfield_npz_integrity": "pass",
+ "module_params_structure": "pass",
+ },
+ }
+
+ # --------------------------------------------------------
+ # Print audit before write
+ # --------------------------------------------------------
+
+ print("=" * 92)
+ print("MODULE PARAMS ASSEMBLER - PRODUCTION")
+ print(f"MX ID : {common_mx}")
+ print(f"Module ID : {common_module_id}")
+ print(
+ "Hardware : "
+ f"RGB={common_signature['rgb']['sensor']} "
+ f"{common_signature['rgb']['size'][0]}x{common_signature['rgb']['size'][1]} | "
+ f"RE={common_signature['re']['sensor']} "
+ f"{common_signature['re']['size'][0]}x{common_signature['re']['size'][1]} | "
+ f"NIR={common_signature['nir']['sensor']} "
+ f"{common_signature['nir']['size'][0]}x{common_signature['nir']['size'][1]}"
+ )
+ print(f"Bayer : {bayer}")
+ print(
+ f"RGB process : "
+ f"{runtime_policy['rgb_processing'].get('mode')}"
+ )
+ print(
+ f"Homography : "
+ f"{fusion_cfg['homography_profile']}"
+ )
+ print(
+ f"Undistort : "
+ f"{intrinsics_cfg['runtime_undistort'].get('enabled', False)}"
+ )
+ print(
+ "Policies : "
+ "radiometric_config=OFF | patch_normalization=OFF | rgb_calibration=OFF"
+ )
+ print(
+ "Active : "
+ "radiometric_normalization=ON | flatfield=ON | intrinsics=ON | homography=ON"
+ )
+ print("-" * 92)
+
+ for name in (
+ "focus",
+ "flatfield",
+ "radiometry",
+ "startup",
+ "intrinsics",
+ "homography",
+ ):
+ meta = provenance[
+ "artifacts"
+ ][name]
+
+ print(
+ f"[OK] {name:11s} "
+ f"schema={meta.get('schema')} "
+ f"sha256={meta.get('sha256', '')[:12]}..."
+ )
+
+ if args.check_only:
+ print("-" * 92)
+ print("[CHECK-ONLY] Tudo consistente. Nenhum arquivo foi alterado.")
+ print("=" * 92)
+ return
+
+ # --------------------------------------------------------
+ # Atomic write + backup
+ # --------------------------------------------------------
+
+ out = Path(
+ args.out
+ )
+
+ ensure_dir(
+ out.parent
+ )
+
+ if out.exists():
+ backup = out.with_name(
+ out.stem
+ + ".backup_"
+ + stamp()
+ + out.suffix
+ )
+
+ shutil.copy2(
+ out,
+ backup,
+ )
+
+ report[
+ "backup_created"
+ ] = norm_path(
+ backup
+ )
+
+ save_json_atomic(
+ out,
+ module_params,
+ )
+
+ report[
+ "output_sha256"
+ ] = sha256_file(
+ out
+ )
+
+ save_json_atomic(
+ args.report,
+ report,
+ )
+
+ print("-" * 92)
+ print(f"[PASS] module_params : {out}")
+ print(f"[PASS] assembly report: {args.report}")
+ print(f"[SHA256] {report['output_sha256']}")
+ print("=" * 92)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/build_module_params.py b/Python/OAK/datasets/oak-fcc-3/utils/build_module_params.py
deleted file mode 100644
index 798f1b23f..000000000
--- a/Python/OAK/datasets/oak-fcc-3/utils/build_module_params.py
+++ /dev/null
@@ -1,654 +0,0 @@
-import json
-import argparse
-import os
-from copy import deepcopy
-from datetime import datetime
-
-
-# ============================================================
-# Helpers
-# ============================================================
-
-
-def now_str():
- return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
-
-def load_json(path, required=True):
- if not path or not os.path.isfile(path):
- if required:
- raise FileNotFoundError(f"Arquivo não encontrado: {path}")
- return {}
-
- with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
-
-
-def save_json(path, data):
- out_dir = os.path.dirname(os.path.abspath(path))
- if out_dir:
- os.makedirs(out_dir, exist_ok=True)
-
- with open(path, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- f.write("\n")
-
-
-def rel_or_abs(path):
- """
- Mantém o caminho como veio, mas normaliza separadores.
- Isso evita quebrar projetos Windows/Linux e deixa o module_params legível.
- """
- if path is None:
- return None
- return str(path).replace("\\", "/")
-
-
-def deep_merge(base, update, *, skip_none=True):
- """
- Merge recursivo seguro.
-
- - dict + dict: combina recursivamente.
- - listas/escalares: valor novo substitui o antigo.
- - None: por padrão NÃO apaga valor antigo, para evitar perder calibração quando
- um arquivo fonte não conhece determinada chave.
- """
- if not isinstance(base, dict):
- base = {}
- out = deepcopy(base)
-
- if not isinstance(update, dict):
- return out
-
- for key, value in update.items():
- if value is None and skip_none:
- continue
-
- if isinstance(value, dict) and isinstance(out.get(key), dict):
- out[key] = deep_merge(out[key], value, skip_none=skip_none)
- else:
- out[key] = deepcopy(value)
-
- return out
-
-
-def first_dict(*values):
- for value in values:
- if isinstance(value, dict):
- return value
- return None
-
-
-# ============================================================
-# Defaults coerentes com RawProcessorCore + module_params atual
-# ============================================================
-
-
-DEFAULT_RGB_PROCESSING = {
- "mode": "bayer_planes",
-}
-
-
-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_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.5,
- "clip_output": True,
- "require_valid_gray": True,
- "use_black_for_offset": False,
- "save_patch_stats": True,
- "rgb_saturation_guard_enabled": True,
- "rgb_saturation_guard_mode": "fade_strength",
- "rgb_saturation_soft_start": 0.88,
- "rgb_saturation_hard": 0.97,
- "rgb_saturation_threshold": 0.97,
-}
-
-
-DEFAULT_FLATFIELD_RUNTIME = {
- "strength": 0.35,
- "strength_by_channel": {
- "R": 0.9,
- "G": 0.9,
- "B": 0.9,
- "RE": 0.25,
- "NIR": 0.25,
- },
- "gain_min_runtime": 0.75,
- "gain_max_runtime": 1.35,
- "runtime_smooth_ksize": 81,
- "saturation_guard_enabled": True,
- "saturation_guard_mode": "fade_strength",
- "saturation_guard_threshold": 0.97,
- "saturation_guard_soft_start": 0.88,
- "saturation_guard_hard": 0.97,
-}
-
-
-DEFAULT_RADIOMETRIC_NORMALIZATION = {
- "enabled": False,
- "method": "exposure_gain_reference",
- "apply_stage": "after_dark_before_flat_gain",
- "reference_controls": {
- "rgb": {"exposure_time_us": 3000, "analogue_gain": 1.0},
- "re": {"exposure_time_us": 7000, "analogue_gain": 1.0},
- "nir": {"exposure_time_us": 7000, "analogue_gain": 1.0},
- },
- "clip_output": True,
-}
-
-
-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.055,
- "p95_limit": 0.975,
- "saturation_limit_pct": 5.0,
- "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,
- "exp_apply_threshold_us": 15,
- "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.42,
- "ratio_min": 0.72,
- "ratio_max": 1.38,
- "reduce_fast_factor": 0.8,
- "factor_min": 0.62,
- "factor_max": 1.42,
- "gain_return_enabled": True,
- "gain_reduce_on_saturation": True,
- "gain_increase_required_cycles": 3,
- "gain_decrease_required_cycles": 1,
- "gain_step_up": 0.3,
- "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": 3500, "gain_min": 1.0, "gain_max": 2.0},
- "nir": {"exp_min_us": 100, "exp_max_us": 3500, "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": 5.0,
- "patch_white_p95_limit": 0.985,
- "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.76},
- "global_guard_sat_threshold": 0.985,
- "global_guard_near_sat_threshold": 0.94,
- "global_guard_sat_pct_soft": 0.50,
- "global_guard_sat_pct_hard": 1.5,
- "global_guard_sat_pct_extreme": 5.0,
- "global_guard_blob_pct_soft": 0.20,
- "global_guard_blob_pct_hard": 0.80,
- "global_guard_blob_pct_extreme": 2.2,
- "global_guard_min_blob_px": 48,
- "global_guard_downsample_max_side": 320,
- "global_guard_reduce_factor_soft": 0.96,
- "global_guard_reduce_factor_hard": 0.82,
- "global_guard_reduce_factor_extreme": 0.62,
- "sun_guard_enabled": True,
- "sun_guard_p99_threshold": 0.96,
- "sun_guard_near_sat_pct_threshold": 2.0,
- "sun_guard_freeze_increase_cycles": 1,
- "sun_guard_allow_decrease": True,
- "guard_force_apply_enabled": True,
- "guard_force_apply_soft": False,
- "guard_force_apply_hard": True,
- "guard_force_apply_extreme": True,
- "guard_force_apply_on_patch_saturation": True,
- "guard_freeze_cycles_soft": 1,
- "guard_freeze_cycles_hard": 2,
- "guard_freeze_cycles_extreme": 3,
- "guard_reapply_min_exp_on_emergency": True,
- "guard_min_exp_margin_us": 80,
- "patch_two_roi_soften_risk": True,
- "patch_two_roi_white_risk_percentile": 75,
- "patch_two_roi_other_risk_percentile": 50,
- "patch_white_single_roi_saturation_reject": True,
- "patch_white_roi_reject_sat_pct": 5.0,
- "patch_white_roi_reject_p95": 0.995,
-}
-
-
-def default_module_template():
- return {
- "schema": "multispec_module_params_v3",
- "saved_at": now_str(),
- "frame_type": "RAW_BRUTO",
- "capture_mode_requested": "AUTO",
- "capture_mode_effective": "AUTO",
- "raw_policy": "allow_single",
- "sensor_width": 1280,
- "sensor_height": 800,
- "bayer_pattern": "BGGR",
- "rgb_processing": deepcopy(DEFAULT_RGB_PROCESSING),
- "camera_settings": {},
- "fusion_config": {
- "alignment_mode": "manual_affine",
- "baseline_mm": 75.0,
- "reference_camera": "rgb",
- "manual_offsets": {
- "re": {"dx": 0, "dy": 0, "theta_deg": 0.0},
- "nir": {"dx": 0, "dy": 0, "theta_deg": 0.0},
- },
- "homographies": {
- "re_to_rgb": None,
- "nir_to_rgb": None,
- },
- "crop_valid_common": True,
- "resize_after_crop": True,
- "target_size": None,
- },
- "radiometric_config": deepcopy(DEFAULT_RADIOMETRIC_CONFIG),
- "radiometric_normalization": deepcopy(DEFAULT_RADIOMETRIC_NORMALIZATION),
- "patch_normalization": deepcopy(DEFAULT_PATCH_NORMALIZATION),
- "rgb_calibration": {
- "enabled": False,
- "gains": {"R": 1.0, "G": 1.0, "B": 1.0},
- },
- "flatfield_config": deep_merge(
- {
- "enabled": False,
- "reason": "flatfield não informado ou arquivo inexistente",
- "subtract_dark": True,
- "apply_before_fusion": True,
- "apply_after_decode": True,
- "apply_space": "native_camera_space",
- "map_type": "gain",
- "channels": ["R", "G", "B", "RE", "NIR"],
- "channel_maps": {},
- "clip_output": True,
- },
- DEFAULT_FLATFIELD_RUNTIME,
- ),
- }
-
-
-# ============================================================
-# Builders / normalizers
-# ============================================================
-
-
-def build_flatfield_config(flatfield_json_path, flatfield_data, previous_flatfield_config=None):
- """
- Espera o JSON gerado pelo flatfield_calibration_tool_v2.py.
-
- Importante: usa previous_flatfield_config como base para preservar knobs runtime
- que não existem no arquivo de calibração do flat-field, como strength,
- gain_min_runtime, runtime_smooth_ksize e saturation_guard_*.
- """
- base = deep_merge(
- deep_merge({}, previous_flatfield_config or {}),
- DEFAULT_FLATFIELD_RUNTIME,
- )
-
- if not isinstance(flatfield_data, dict):
- return deep_merge(base, {
- "enabled": False,
- "reason": "flatfield_json ausente ou inválido",
- })
-
- outputs = flatfield_data.get("outputs", {}) or {}
- maps = flatfield_data.get("maps", {}) or {}
-
- npz_path = outputs.get("npz")
- if not npz_path:
- base_name, _ = os.path.splitext(flatfield_json_path)
- npz_path = base_name + ".npz"
-
- channels = flatfield_data.get("channels") or base.get("channels") or ["R", "G", "B", "RE", "NIR"]
-
- channel_maps = {}
- previous_channel_maps = base.get("channel_maps", {}) or {}
- for ch in channels:
- m = maps.get(ch, {}) or {}
- prev = previous_channel_maps.get(ch, {}) or {}
- channel_maps[ch] = deep_merge(prev, {
- "gain_key": m.get("gain_key", f"gain_{ch}"),
- "flat_norm_key": m.get("flat_norm_key", f"flat_norm_{ch}"),
- "white_median_key": m.get("white_median_key", f"white_median_{ch}"),
- "dark_median_key": m.get("dark_median_key", f"dark_median_{ch}"),
- "shape": m.get("shape"),
- "gain_min": m.get("gain_min"),
- "gain_max": m.get("gain_max"),
- "gain_mean": m.get("gain_mean"),
- "gain_std": m.get("gain_std"),
- })
-
- generated = {
- "enabled": True,
- "subtract_dark": True,
- "schema": flatfield_data.get("schema", "multispec_flatfield_v1"),
- "created_at": flatfield_data.get("created_at"),
- "json_file": rel_or_abs(flatfield_json_path),
- "npz_file": rel_or_abs(npz_path),
- "apply_before_fusion": True,
- "apply_after_decode": True,
- "apply_space": "native_camera_space",
- "map_type": "gain",
- "formula": "channel_corrected = max(channel_linear - dark, 0) * gain_map",
- "channels": channels,
- "channel_maps": channel_maps,
- "exp_gain_correct_during_flat_capture": bool(flatfield_data.get("exp_gain_correct", False)),
- "smooth_ksize": flatfield_data.get("smooth_ksize"),
- "min_gain": flatfield_data.get("min_gain"),
- "max_gain": flatfield_data.get("max_gain"),
- "notes": flatfield_data.get("notes", ""),
- }
-
- return deep_merge(base, generated)
-
-
-def pick_radiometric_config(radiometric_data: dict, selected_profile: str | None = None):
- if not isinstance(radiometric_data, dict):
- return None
-
- root_cfg = radiometric_data.get("radiometric_config")
- if isinstance(root_cfg, dict):
- return root_cfg
-
- active_profile = radiometric_data.get("active_profile")
- if active_profile in ("global_scene_mode", "three_reference_patches_mode"):
- cfg = radiometric_data.get(active_profile, {}).get("radiometric_config")
- if isinstance(cfg, dict):
- return cfg
-
- if selected_profile:
- cfg = radiometric_data.get(selected_profile, {}).get("radiometric_config")
- if isinstance(cfg, dict):
- return cfg
-
- return None
-
-
-def normalize_patch_normalization_contract(base_patch_config, incoming_patch_config=None):
- """
- Garante o contrato atual do RawProcessorCore.
-
- - Sempre tem targets_by_patch_channel.
- - Preserva white_guard_max_by_channel.
- - Preserva rgb_saturation_guard_*.
- - Remove a chave legada targets, porque ela não é usada pelo core atual.
- """
- cfg = deep_merge(DEFAULT_PATCH_NORMALIZATION, base_patch_config or {})
- cfg = deep_merge(cfg, incoming_patch_config or {})
-
- legacy_targets = cfg.pop("targets", None)
- if isinstance(legacy_targets, dict) and "targets_by_patch_channel" not in cfg:
- # Fallback conservador. Na prática, com DEFAULT_PATCH_NORMALIZATION acima,
- # normalmente não entra aqui. Mantido só para arquivos muito antigos.
- t = deepcopy(DEFAULT_PATCH_NORMALIZATION["targets_by_patch_channel"])
- for patch_type in ("black", "gray", "white"):
- if patch_type in legacy_targets:
- scalar = legacy_targets.get(patch_type)
- try:
- scalar = float(scalar)
- for ch in ("R", "G", "B", "RE", "NIR"):
- t[patch_type][ch] = scalar
- except Exception:
- pass
- cfg["targets_by_patch_channel"] = t
-
- cfg = deep_merge(DEFAULT_PATCH_NORMALIZATION, cfg)
- return cfg
-
-
-def normalize_radiometric_config(base_rad_config, incoming_rad_config=None):
- cfg = deep_merge(DEFAULT_RADIOMETRIC_CONFIG, base_rad_config or {})
- cfg = deep_merge(cfg, incoming_rad_config or {})
- return cfg
-
-
-def normalize_radiometric_normalization(base_config, incoming_config=None):
- cfg = deep_merge(DEFAULT_RADIOMETRIC_NORMALIZATION, base_config or {})
- cfg = deep_merge(cfg, incoming_config or {})
- cfg["enabled"] = bool(cfg.get("enabled", False))
- return cfg
-
-
-def build_fusion_config(fusion_data, previous_fusion_config=None):
- base = previous_fusion_config or {}
- generated = {
- "alignment_mode": fusion_data.get("alignment_mode"),
- "baseline_mm": fusion_data.get("baseline_mm"),
- "reference_camera": fusion_data.get("reference_camera"),
- "manual_offsets": fusion_data.get("manual_offsets"),
- "homographies": fusion_data.get("homographies"),
- "crop_valid_common": fusion_data.get("crop_valid_common"),
- "resize_after_crop": fusion_data.get("resize_after_crop"),
- "target_size": fusion_data.get("target_size"),
- }
- cfg = deep_merge(default_module_template()["fusion_config"], base)
- cfg = deep_merge(cfg, generated)
- return cfg
-
-
-def load_base_module(args):
- """
- Carrega defaults do module_params atual.
-
- Prioridade:
- 1. --base_module_json, se informado.
- 2. --out, se já existir.
- 3. template interno coerente com o contrato atual.
- """
- candidates = []
- if args.base_module_json:
- candidates.append(args.base_module_json)
- if args.out:
- candidates.append(args.out)
-
- for path in candidates:
- if path and os.path.isfile(path):
- print(f"[INFO] Usando module_params base: {path}")
- return deep_merge(default_module_template(), load_json(path, required=True))
-
- print("[WARN] Nenhum module_params base encontrado. Usando defaults internos.")
- return default_module_template()
-
-
-# ============================================================
-# Main
-# ============================================================
-
-
-def main():
- parser = argparse.ArgumentParser(
- description="Monta o module_params.json preservando o contrato atual do RawProcessorCore.",
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
- )
- parser.add_argument("--camera_json", default="calibration/sensor_calibration.json")
- parser.add_argument("--fusion_json", default="calibration/manual_offsets.json")
- parser.add_argument("--radiometric_json", default="calibration/radiometric_config.json")
- parser.add_argument(
- "--radiometric_profile",
- default="global_scene_mode",
- choices=["global_scene_mode", "three_reference_patches_mode"],
- )
- parser.add_argument("--flatfield_json", default="calibration/flatfield_maps_v1.json")
- parser.add_argument("--disable_flatfield", action="store_true")
- parser.add_argument("--base_module_json", default=None, help="module_params atual usado como defaults antes de sobrescrever")
- parser.add_argument("--out", default="calibration/module_params.json")
- args = parser.parse_args()
-
- module_base = load_base_module(args)
-
- cam_data = load_json(args.camera_json, required=True)
- fusion_data = load_json(args.fusion_json, required=True)
- radiometric_data = load_json(args.radiometric_json, required=False) if args.radiometric_json else {}
-
- # =========================
- # MODULE PARAMS FINAL
- # =========================
- module_params = deepcopy(module_base)
- module_params["schema"] = "multispec_module_params_v3"
- module_params["saved_at"] = now_str()
-
- # =========================
- # ROOT / CAMERA
- # =========================
- root_updates = {
- "frame_type": cam_data.get("frame_type", fusion_data.get("frame_type")),
- "capture_mode_requested": cam_data.get("capture_mode_requested"),
- "capture_mode_effective": cam_data.get("capture_mode_effective"),
- "raw_policy": cam_data.get("raw_policy"),
- "sensor_width": cam_data.get("sensor_width", fusion_data.get("sensor_width")),
- "sensor_height": cam_data.get("sensor_height", fusion_data.get("sensor_height")),
- "bayer_pattern": cam_data.get("bayer_pattern", fusion_data.get("bayer_pattern")),
- }
- module_params = deep_merge(module_params, root_updates)
-
- module_params["rgb_processing"] = deep_merge(
- deep_merge(DEFAULT_RGB_PROCESSING, module_base.get("rgb_processing", {})),
- cam_data.get("rgb_processing") if isinstance(cam_data.get("rgb_processing"), dict) else {},
- )
-
- camera_settings = cam_data.get("camera_settings")
- if not isinstance(camera_settings, dict):
- camera_settings = module_base.get("camera_settings")
- if not isinstance(camera_settings, dict):
- raise RuntimeError("camera_json sem camera_settings válido e sem fallback no module_params base")
- module_params["camera_settings"] = camera_settings
-
- module_params["rgb_calibration"] = deep_merge(
- module_base.get("rgb_calibration", {}),
- cam_data.get("rgb_calibration") if isinstance(cam_data.get("rgb_calibration"), dict) else {},
- )
-
- # =========================
- # FUSION
- # =========================
- module_params["fusion_config"] = build_fusion_config(
- fusion_data,
- previous_fusion_config=module_base.get("fusion_config", {}),
- )
-
- # =========================
- # RADIOMETRIC
- # =========================
- incoming_rad = pick_radiometric_config(radiometric_data, selected_profile=args.radiometric_profile)
- if not isinstance(incoming_rad, dict):
- incoming_rad = cam_data.get("radiometric_config") if isinstance(cam_data.get("radiometric_config"), dict) else {}
-
- module_params["radiometric_config"] = normalize_radiometric_config(
- module_base.get("radiometric_config", {}),
- incoming_rad,
- )
-
- incoming_rad_norm = first_dict(
- radiometric_data.get("radiometric_normalization") if isinstance(radiometric_data, dict) else None,
- cam_data.get("radiometric_normalization") if isinstance(cam_data, dict) else None,
- ) or {}
- module_params["radiometric_normalization"] = normalize_radiometric_normalization(
- module_base.get("radiometric_normalization", {}),
- incoming_rad_norm,
- )
-
- incoming_patch_norm = first_dict(
- radiometric_data.get("patch_normalization") if isinstance(radiometric_data, dict) else None,
- cam_data.get("patch_normalization") if isinstance(cam_data, dict) else None,
- ) or {}
- module_params["patch_normalization"] = normalize_patch_normalization_contract(
- module_base.get("patch_normalization", {}),
- incoming_patch_norm,
- )
-
- # =========================
- # FLATFIELD
- # =========================
- previous_flatfield = module_base.get("flatfield_config", {}) or {}
- if args.disable_flatfield:
- module_params["flatfield_config"] = deep_merge(previous_flatfield, {
- "enabled": False,
- "reason": "desabilitado via --disable_flatfield",
- })
- elif args.flatfield_json and os.path.isfile(args.flatfield_json):
- flatfield_data = load_json(args.flatfield_json, required=True)
- module_params["flatfield_config"] = build_flatfield_config(
- args.flatfield_json,
- flatfield_data,
- previous_flatfield_config=previous_flatfield,
- )
- else:
- # Não achou novo flatfield: preserva o anterior se já existia.
- module_params["flatfield_config"] = deep_merge(previous_flatfield, DEFAULT_FLATFIELD_RUNTIME)
- if not module_params["flatfield_config"].get("enabled", False):
- module_params["flatfield_config"]["reason"] = "flatfield não informado ou arquivo inexistente"
-
- # =========================
- # Save
- # =========================
- save_json(args.out, module_params)
-
- print(f"[OK] module_params gerado em: {args.out}")
- print("[OK] contrato preservado: rgb_processing, patch_normalization, radiometric_config e knobs runtime do flatfield")
-
- flat_cfg = module_params.get("flatfield_config", {}) or {}
- if flat_cfg.get("enabled"):
- print(f"[OK] flatfield habilitado: {flat_cfg.get('npz_file')}")
- else:
- print(f"[WARN] flatfield desabilitado: {flat_cfg.get('reason')}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/flatfield_calibration_tool_quality.py b/Python/OAK/datasets/oak-fcc-3/utils/flatfield_calibration_tool_quality.py
deleted file mode 100644
index 0452a96c8..000000000
--- a/Python/OAK/datasets/oak-fcc-3/utils/flatfield_calibration_tool_quality.py
+++ /dev/null
@@ -1,1345 +0,0 @@
-import os
-import json
-import time
-import argparse
-from datetime import datetime
-from pathlib import Path
-
-import cv2
-import numpy as np
-
-from core.oak_fcc3_client import OakFcc3Client as MultiSpectralClient
-
-
-# ============================================================
-# Helpers gerais
-# ============================================================
-
-def now_str() -> str:
- return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
-
-def ensure_dir(path: str):
- os.makedirs(path, exist_ok=True)
-
-
-def safe_float(v, default=None):
- try:
- if v is None:
- return default
- return float(v)
- except Exception:
- return default
-
-
-def safe_int(v, default=None):
- try:
- if v is None:
- return default
- return int(v)
- except Exception:
- return default
-
-
-def overlay_hud(
- img_bgr,
- lines,
- x=12,
- y=24,
- font_scale=0.58,
- line_step=22,
- color=(255, 255, 255),
- shadow=(0, 0, 0),
-):
- yy = int(y)
- for s in lines:
- cv2.putText(img_bgr, str(s), (int(x), yy), cv2.FONT_HERSHEY_SIMPLEX,
- font_scale, shadow, 3, cv2.LINE_AA)
- cv2.putText(img_bgr, str(s), (int(x), yy), cv2.FONT_HERSHEY_SIMPLEX,
- font_scale, color, 1, cv2.LINE_AA)
- yy += int(line_step)
-
-
-def to_bgr_u8_from_rgb01(rgb01: np.ndarray) -> np.ndarray:
- rgb_u8 = np.clip(rgb01 * 255.0, 0, 255).astype(np.uint8)
- return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR)
-
-
-def gray_to_bgr_u8(gray01: np.ndarray) -> np.ndarray:
- g = np.clip(gray01 * 255.0, 0, 255).astype(np.uint8)
- return cv2.cvtColor(g, cv2.COLOR_GRAY2BGR)
-
-
-def resize_if_needed(img: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray:
- if img is None:
- return None
- th, tw = target_hw
- if img.shape[:2] == (th, tw):
- return img
- return cv2.resize(img, (tw, th), interpolation=cv2.INTER_LINEAR)
-
-
-def get_decoded_by_role(decoded: dict, role: str):
- role = str(role).lower()
- for cam_id, item in (decoded or {}).items():
- if str(item.get("role", "")).lower() == role:
- return cam_id, item
- return None, None
-
-
-def get_image_by_role(decoded: dict, role: str):
- cam_id, item = get_decoded_by_role(decoded, role)
- if item is None:
- return cam_id, None
- return cam_id, item.get("image")
-
-
-def validate_module_ready(status: dict, raw_policy: str):
- if not status.get("ok", True):
- raise RuntimeError(f"Status invalido retornado pelo modulo: {status}")
-
- active_roles = status.get("active_roles", {}) or {}
- active_count = int(status.get("camera_count_active", 0))
-
- if raw_policy == "require_triple":
- missing = [role for role in ("rgb", "nir", "re") if role not in active_roles]
- if missing:
- raise RuntimeError(
- "RAW_BRUTO com require_triple exige rgb/nir/re ativas. "
- f"Faltando: {missing}. Ativas: {active_roles}"
- )
- elif active_count < 1:
- raise RuntimeError("RAW_BRUTO requer ao menos uma câmera ativa.")
-
-
-def compute_image_stats(img01: np.ndarray) -> dict:
- if img01 is None:
- return {
- "valid": False,
- "mean": 0.0,
- "std": 0.0,
- "p01": 0.0,
- "p05": 0.0,
- "p50": 0.0,
- "p95": 0.0,
- "p99": 0.0,
- "sat_pct": 0.0,
- "dark_pct": 0.0,
- }
-
- arr = img01.astype(np.float32).reshape(-1)
- return {
- "valid": True,
- "mean": float(arr.mean()),
- "std": float(arr.std()),
- "p01": float(np.percentile(arr, 1)),
- "p05": float(np.percentile(arr, 5)),
- "p50": float(np.percentile(arr, 50)),
- "p95": float(np.percentile(arr, 95)),
- "p99": float(np.percentile(arr, 99)),
- "sat_pct": float((arr >= 0.985).mean() * 100.0),
- "dark_pct": float((arr <= 0.015).mean() * 100.0),
- }
-
-
-def robust_center(x: np.ndarray, low_pct=10.0, high_pct=90.0) -> float:
- arr = x[np.isfinite(x)].astype(np.float32).reshape(-1)
- if arr.size == 0:
- return 1.0
-
- lo = np.percentile(arr, low_pct)
- hi = np.percentile(arr, high_pct)
- core = arr[(arr >= lo) & (arr <= hi)]
- if core.size == 0:
- core = arr
-
- v = float(np.median(core))
- if not np.isfinite(v) or v <= 1e-8:
- return 1.0
- return v
-
-
-def normalize_for_display(img: np.ndarray) -> np.ndarray:
- if img is None:
- return None
-
- arr = img.astype(np.float32)
- finite = arr[np.isfinite(arr)]
- if finite.size == 0:
- return np.zeros_like(arr, dtype=np.float32)
-
- lo = np.percentile(finite, 1)
- hi = np.percentile(finite, 99)
- if hi <= lo:
- hi = lo + 1e-6
-
- out = (arr - lo) / (hi - lo)
- return np.clip(out, 0.0, 1.0)
-
-
-def smooth_map_gain(gain: np.ndarray, ksize: int) -> np.ndarray:
- if ksize is None or ksize <= 1:
- return gain.astype(np.float32)
-
- if ksize % 2 == 0:
- ksize += 1
-
- return cv2.GaussianBlur(
- gain.astype(np.float32),
- (ksize, ksize),
- sigmaX=0,
- sigmaY=0,
- borderType=cv2.BORDER_REFLECT,
- )
-
-
-def clip_gain_map(gain: np.ndarray, min_gain: float, max_gain: float) -> np.ndarray:
- return np.clip(gain.astype(np.float32), float(min_gain), float(max_gain)).astype(np.float32)
-
-
-# ============================================================
-# Qualidade de calibração flat-field
-# ============================================================
-
-QUALITY_THRESHOLDS = {
- # Frame WHITE antes/durante captura
- "white_sat_warning_pct": 0.10,
- "white_sat_bad_pct": 0.50,
- "white_dark_warning_pct": 1.00,
- "white_dark_bad_pct": 5.00,
- "white_uniformity_warning": 0.20,
- "white_uniformity_bad": 0.35,
- "white_side_ratio_low_warning": 0.80,
- "white_side_ratio_low_bad": 0.65,
- "white_side_ratio_high_warning": 1.25,
- "white_side_ratio_high_bad": 1.45,
-
- # Mapa final de ganho
- "gain_p99_warning": 1.60,
- "gain_p99_bad": 1.85,
- "gain_max_warning": 1.80,
- "gain_max_bad": 2.10,
- "gain_side_ratio_warning": 1.30,
- "gain_side_ratio_bad": 1.55,
- "gain_center_low_warning": 0.80,
- "gain_center_low_bad": 0.65,
-
- # Defeitos locais (sujeira/manchas) após remover tendência suave
- "white_local_defect_dev_warning": 0.12,
- "white_local_defect_dev_bad": 0.20,
- "white_local_outlier_pct_warning": 1.00,
- "white_local_outlier_pct_bad": 3.00,
- "gain_local_defect_dev_warning": 0.10,
- "gain_local_defect_dev_bad": 0.18,
- "gain_local_outlier_pct_warning": 1.00,
- "gain_local_outlier_pct_bad": 3.00,
-}
-
-
-def _status_rank(status: str) -> int:
- return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 0)
-
-
-def _merge_status(a: str, b: str) -> str:
- return a if _status_rank(a) >= _status_rank(b) else b
-
-
-def _append_reason(reasons: list[str], condition: bool, status_ref: str, reason: str):
- if condition:
- reasons.append(reason)
- return status_ref
- return "good"
-
-
-def _safe_ratio(num: float, den: float, default: float = 1.0) -> float:
- try:
- num = float(num)
- den = float(den)
- if not np.isfinite(num) or not np.isfinite(den) or abs(den) <= 1e-9:
- return default
- return num / den
- except Exception:
- return default
-
-
-def _crop_margin(arr: np.ndarray, margin_frac: float = 0.08) -> np.ndarray:
- h, w = arr.shape[:2]
- mx = int(w * margin_frac)
- my = int(h * margin_frac)
- x0, x1 = mx, max(mx + 1, w - mx)
- y0, y1 = my, max(my + 1, h - my)
- return arr[y0:y1, x0:x1]
-
-
-def region_profile_stats(img01: np.ndarray) -> dict:
- """
- Mede homogeneidade espacial em 3x3 e também em faixas esquerda/centro/direita.
- Usa p50 para ser robusto contra textura fina/ruído.
- """
- if img01 is None:
- return {"valid": False}
-
- arr = img01.astype(np.float32)
- if arr.ndim == 3:
- arr = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32)
-
- arr = _crop_margin(arr, margin_frac=0.04)
- h, w = arr.shape[:2]
- if h < 9 or w < 9:
- return {"valid": False}
-
- cells = []
- grid = []
- for gy in range(3):
- row = []
- y0 = int(round(gy * h / 3.0))
- y1 = int(round((gy + 1) * h / 3.0))
- for gx in range(3):
- x0 = int(round(gx * w / 3.0))
- x1 = int(round((gx + 1) * w / 3.0))
- cell = arr[y0:y1, x0:x1].reshape(-1)
- cell = cell[np.isfinite(cell)]
- p50 = float(np.percentile(cell, 50)) if cell.size else 0.0
- row.append(p50)
- cells.append(p50)
- grid.append(row)
-
- cells_np = np.array(cells, dtype=np.float32)
- center = float(grid[1][1])
- if center <= 1e-9 or not np.isfinite(center):
- center = float(np.median(cells_np)) if cells_np.size else 1.0
-
- left = float(np.median([grid[0][0], grid[1][0], grid[2][0]]))
- mid = float(np.median([grid[0][1], grid[1][1], grid[2][1]]))
- right = float(np.median([grid[0][2], grid[1][2], grid[2][2]]))
- top = float(np.median(grid[0]))
- bottom = float(np.median(grid[2]))
-
- return {
- "valid": True,
- "grid_p50": grid,
- "center_p50": center,
- "left_p50": left,
- "mid_p50": mid,
- "right_p50": right,
- "top_p50": top,
- "bottom_p50": bottom,
- "min_region_p50": float(cells_np.min()),
- "max_region_p50": float(cells_np.max()),
- "median_region_p50": float(np.median(cells_np)),
- "uniformity_delta": _safe_ratio(float(cells_np.max() - cells_np.min()), center, default=0.0),
- "left_center_ratio": _safe_ratio(left, center),
- "right_center_ratio": _safe_ratio(right, center),
- "top_center_ratio": _safe_ratio(top, center),
- "bottom_center_ratio": _safe_ratio(bottom, center),
- }
-
-
-
-
-def local_defect_stats(img01: np.ndarray, blur_frac: float = 0.18, margin_frac: float = 0.04, outlier_thr: float = 0.10) -> dict:
- """
- Detecta defeitos locais após remover tendência suave (vinheta/gradiente global).
- Útil para sujeira na lente, manchas, poeira, gotas e sombras localizadas.
- """
- if img01 is None:
- return {"valid": False}
-
- arr = img01.astype(np.float32)
- if arr.ndim == 3:
- arr = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32)
-
- arr = _crop_margin(arr, margin_frac=margin_frac)
- h, w = arr.shape[:2]
- if h < 32 or w < 32:
- return {"valid": False}
-
- k = int(round(min(h, w) * blur_frac))
- k = max(31, k)
- if k % 2 == 0:
- k += 1
-
- smooth = cv2.GaussianBlur(arr, (k, k), sigmaX=0, sigmaY=0, borderType=cv2.BORDER_REFLECT)
- ratio = arr / np.maximum(smooth, 1e-6)
- ratio = np.clip(ratio, 0.0, 4.0)
- resid = ratio - 1.0
- abs_resid = np.abs(resid)
-
- finite = abs_resid[np.isfinite(abs_resid)]
- if finite.size == 0:
- return {"valid": False}
-
- p95 = float(np.percentile(finite, 95))
- p99 = float(np.percentile(finite, 99))
- max_dev = float(np.max(finite))
- outlier_pct = float((finite >= float(outlier_thr)).mean() * 100.0)
-
- # Percentis direcionais para diagnosticar manchas escuras/claras.
- ratio_flat = ratio[np.isfinite(ratio)].reshape(-1)
- r_p01 = float(np.percentile(ratio_flat, 1)) if ratio_flat.size else 1.0
- r_p99 = float(np.percentile(ratio_flat, 99)) if ratio_flat.size else 1.0
-
- return {
- "valid": True,
- "blur_ksize": int(k),
- "ratio_p01": r_p01,
- "ratio_p99": r_p99,
- "local_dev_p95": p95,
- "local_dev_p99": p99,
- "local_dev_max": max_dev,
- "local_outlier_pct": outlier_pct,
- "outlier_threshold": float(outlier_thr),
- }
-
-def evaluate_white_frame_quality(channels: dict, thresholds: dict | None = None) -> dict:
- """Avalia se a cena WHITE/flat está homogênea antes de capturar."""
- th = dict(QUALITY_THRESHOLDS)
- if thresholds:
- th.update(thresholds)
-
- result = {
- "status": "good",
- "reasons": [],
- "channels": {},
- "thresholds": th,
- }
-
- for ch, img in (channels or {}).items():
- st = compute_image_stats(img)
- rp = region_profile_stats(img)
- ld = local_defect_stats(img, outlier_thr=th.get("white_local_defect_dev_warning", 0.10))
- status = "good"
- reasons = []
-
- sat = float(st.get("sat_pct", 0.0))
- dark = float(st.get("dark_pct", 0.0))
- uniformity = float(rp.get("uniformity_delta", 0.0)) if rp.get("valid") else 0.0
- lcr = float(rp.get("left_center_ratio", 1.0)) if rp.get("valid") else 1.0
- rcr = float(rp.get("right_center_ratio", 1.0)) if rp.get("valid") else 1.0
- tcr = float(rp.get("top_center_ratio", 1.0)) if rp.get("valid") else 1.0
- bcr = float(rp.get("bottom_center_ratio", 1.0)) if rp.get("valid") else 1.0
- ld_dev = float(ld.get("local_dev_p99", 0.0)) if ld.get("valid") else 0.0
- ld_out = float(ld.get("local_outlier_pct", 0.0)) if ld.get("valid") else 0.0
-
- if sat > th["white_sat_bad_pct"]:
- status = _merge_status(status, "bad"); reasons.append(f"sat_pct_bad:{sat:.2f}%")
- elif sat > th["white_sat_warning_pct"]:
- status = _merge_status(status, "warning"); reasons.append(f"sat_pct_warning:{sat:.2f}%")
-
- if dark > th["white_dark_bad_pct"]:
- status = _merge_status(status, "bad"); reasons.append(f"dark_pct_bad:{dark:.2f}%")
- elif dark > th["white_dark_warning_pct"]:
- status = _merge_status(status, "warning"); reasons.append(f"dark_pct_warning:{dark:.2f}%")
-
- if uniformity > th["white_uniformity_bad"]:
- status = _merge_status(status, "bad"); reasons.append(f"uniformity_bad:{uniformity:.2f}")
- elif uniformity > th["white_uniformity_warning"]:
- status = _merge_status(status, "warning"); reasons.append(f"uniformity_warning:{uniformity:.2f}")
-
- for name, val in (("left", lcr), ("right", rcr), ("top", tcr), ("bottom", bcr)):
- if val < th["white_side_ratio_low_bad"] or val > th["white_side_ratio_high_bad"]:
- status = _merge_status(status, "bad"); reasons.append(f"{name}_center_ratio_bad:{val:.2f}")
- elif val < th["white_side_ratio_low_warning"] or val > th["white_side_ratio_high_warning"]:
- status = _merge_status(status, "warning"); reasons.append(f"{name}_center_ratio_warning:{val:.2f}")
-
- if ld.get("valid"):
- if ld_dev > th["white_local_defect_dev_bad"]:
- status = _merge_status(status, "bad"); reasons.append(f"local_defect_dev_bad:{ld_dev:.2f}")
- elif ld_dev > th["white_local_defect_dev_warning"]:
- status = _merge_status(status, "warning"); reasons.append(f"local_defect_dev_warning:{ld_dev:.2f}")
-
- if ld_out > th["white_local_outlier_pct_bad"]:
- status = _merge_status(status, "bad"); reasons.append(f"local_outlier_pct_bad:{ld_out:.2f}%")
- elif ld_out > th["white_local_outlier_pct_warning"]:
- status = _merge_status(status, "warning"); reasons.append(f"local_outlier_pct_warning:{ld_out:.2f}%")
-
- result["channels"][ch] = {
- "status": status,
- "reasons": reasons,
- "stats": st,
- "region_profile": rp,
- "local_defect": ld,
- }
- result["status"] = _merge_status(result["status"], status)
- result["reasons"].extend([f"{ch}:{r}" for r in reasons[:3]])
-
- result["summary"] = {
- "channel_count": len(result["channels"]),
- "bad_count": sum(1 for v in result["channels"].values() if v.get("status") == "bad"),
- "warning_count": sum(1 for v in result["channels"].values() if v.get("status") == "warning"),
- }
- return result
-
-
-def evaluate_gain_maps_quality(gain_maps: dict, corrected_white: dict | None = None, thresholds: dict | None = None) -> dict:
- """Avalia se os mapas de ganho finais estão agressivos demais ou assimétricos."""
- th = dict(QUALITY_THRESHOLDS)
- if thresholds:
- th.update(thresholds)
-
- result = {
- "status": "good",
- "reasons": [],
- "channels": {},
- "thresholds": th,
- }
-
- for ch, gain in (gain_maps or {}).items():
- g = gain.astype(np.float32)
- finite = g[np.isfinite(g)]
- if finite.size == 0:
- ch_status = "bad"
- reasons = ["gain_map_invalid"]
- stats = {}
- rp = {"valid": False}
- ld = {"valid": False}
- else:
- stats = {
- "min": float(np.min(finite)),
- "p01": float(np.percentile(finite, 1)),
- "p05": float(np.percentile(finite, 5)),
- "p50": float(np.percentile(finite, 50)),
- "p95": float(np.percentile(finite, 95)),
- "p99": float(np.percentile(finite, 99)),
- "max": float(np.max(finite)),
- "mean": float(np.mean(finite)),
- "std": float(np.std(finite)),
- }
- rp = region_profile_stats(g)
- ld = local_defect_stats(g, outlier_thr=th.get("gain_local_defect_dev_warning", 0.08))
- ch_status = "good"
- reasons = []
-
- if stats["p99"] > th["gain_p99_bad"]:
- ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_p99_bad:{stats['p99']:.2f}")
- elif stats["p99"] > th["gain_p99_warning"]:
- ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_p99_warning:{stats['p99']:.2f}")
-
- if stats["max"] > th["gain_max_bad"]:
- ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_max_bad:{stats['max']:.2f}")
- elif stats["max"] > th["gain_max_warning"]:
- ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_max_warning:{stats['max']:.2f}")
-
- if rp.get("valid"):
- center = float(rp.get("center_p50", 1.0))
- rcr = float(rp.get("right_center_ratio", 1.0))
- lcr = float(rp.get("left_center_ratio", 1.0))
- center_gain = center
-
- for name, ratio in (("left", lcr), ("right", rcr)):
- inv = max(ratio, 1.0 / max(ratio, 1e-6))
- if inv > th["gain_side_ratio_bad"]:
- ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_{name}_center_ratio_bad:{ratio:.2f}")
- elif inv > th["gain_side_ratio_warning"]:
- ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_{name}_center_ratio_warning:{ratio:.2f}")
-
- if center_gain < th["gain_center_low_bad"]:
- ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_center_low_bad:{center_gain:.2f}")
- elif center_gain < th["gain_center_low_warning"]:
- ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_center_low_warning:{center_gain:.2f}")
-
- if ld.get("valid"):
- ld_dev = float(ld.get("local_dev_p99", 0.0))
- ld_out = float(ld.get("local_outlier_pct", 0.0))
- if ld_dev > th["gain_local_defect_dev_bad"]:
- ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_local_defect_dev_bad:{ld_dev:.2f}")
- elif ld_dev > th["gain_local_defect_dev_warning"]:
- ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_local_defect_dev_warning:{ld_dev:.2f}")
-
- if ld_out > th["gain_local_outlier_pct_bad"]:
- ch_status = _merge_status(ch_status, "bad"); reasons.append(f"gain_local_outlier_pct_bad:{ld_out:.2f}%")
- elif ld_out > th["gain_local_outlier_pct_warning"]:
- ch_status = _merge_status(ch_status, "warning"); reasons.append(f"gain_local_outlier_pct_warning:{ld_out:.2f}%")
-
- white_quality = None
- if corrected_white is not None and ch in corrected_white:
- white_quality = evaluate_white_frame_quality({ch: corrected_white[ch]}, thresholds=th).get("channels", {}).get(ch)
-
- result["channels"][ch] = {
- "status": ch_status,
- "reasons": reasons,
- "gain_stats": stats,
- "gain_region_profile": rp,
- "local_defect": ld,
- "white_signal_quality": white_quality,
- }
- result["status"] = _merge_status(result["status"], ch_status)
- result["reasons"].extend([f"{ch}:{r}" for r in reasons[:4]])
-
- result["summary"] = {
- "channel_count": len(result["channels"]),
- "bad_count": sum(1 for v in result["channels"].values() if v.get("status") == "bad"),
- "warning_count": sum(1 for v in result["channels"].values() if v.get("status") == "warning"),
- }
- return result
-
-
-def format_quality_lines(report: dict | None, title: str = "QUALITY", max_channels: int = 5, max_reasons: int = 5) -> list[str]:
- if not report:
- return [f"{title}: sem dados"]
-
- status = str(report.get("status", "unknown")).upper()
- lines = [f"{title}: {status}"]
-
- channels = report.get("channels", {}) or {}
- for ch in list(channels.keys())[:max_channels]:
- item = channels.get(ch, {}) or {}
- st = str(item.get("status", "unknown")).upper()
- rp = item.get("region_profile") or item.get("gain_region_profile") or {}
- stats = item.get("stats") or item.get("gain_stats") or {}
- ld = item.get("local_defect") or {}
- ld_txt = ""
- if isinstance(ld, dict) and ld.get("valid"):
- ld_txt = f" LD={ld.get('local_dev_p99', 0):.2f}/{ld.get('local_outlier_pct', 0):.1f}%"
- if rp.get("valid"):
- lines.append(
- f"{ch}: {st} | p50={stats.get('p50', stats.get('p50', 0)):.3f} "
- f"unif={rp.get('uniformity_delta', 0):.2f} "
- f"L/C={rp.get('left_center_ratio', 1):.2f} "
- f"R/C={rp.get('right_center_ratio', 1):.2f}{ld_txt}"
- )
- else:
- lines.append(f"{ch}: {st}{ld_txt}")
-
- reasons = report.get("reasons", []) or []
- for r in reasons[:max_reasons]:
- lines.append(f"! {r}")
-
- if len(reasons) > max_reasons:
- lines.append(f"! +{len(reasons) - max_reasons} avisos")
-
- return lines
-
-
-# ============================================================
-# Controle de câmera e extração de canais
-# ============================================================
-
-def get_controls_for_role(cam: MultiSpectralClient, role: str) -> dict:
- try:
- ctrl = cam.svc.get_camera_controls(role=role) or {}
- return {
- "ok": True,
- "role": role,
- "ae_enable": bool(ctrl.get("ae_enable", False)),
- "awb_enable": bool(ctrl.get("awb_enable", False)),
- "exposure_time_us": safe_int(ctrl.get("exposure_time_us"), None),
- "analogue_gain": safe_float(ctrl.get("analogue_gain"), None),
- "colour_gains": ctrl.get("colour_gains", None),
- "raw": ctrl,
- }
- except Exception as e:
- return {
- "ok": False,
- "role": role,
- "error": str(e),
- "ae_enable": None,
- "awb_enable": None,
- "exposure_time_us": None,
- "analogue_gain": None,
- "colour_gains": None,
- }
-
-
-def get_all_controls(cam: MultiSpectralClient) -> dict:
- return {role: get_controls_for_role(cam, role) for role in ("rgb", "re", "nir")}
-
-
-def exposure_gain_factor(ctrl: dict, fallback_exp=1.0, fallback_gain=1.0) -> float:
- exp = safe_float(ctrl.get("exposure_time_us"), fallback_exp)
- gain = safe_float(ctrl.get("analogue_gain"), fallback_gain)
-
- if exp is None or exp <= 0:
- exp = fallback_exp
- if gain is None or gain <= 0:
- gain = fallback_gain
-
- return float(exp * gain)
-
-
-def extract_channels_from_decoded(decoded: dict) -> dict:
- """
- Retorna canais em float32 0..1 no espaço nativo de cada câmera:
- R/G/B vêm do debayer da role rgb.
- RE vem da role re.
- NIR vem da role nir.
- """
- _, rgb01 = get_image_by_role(decoded, "rgb")
- _, re01 = get_image_by_role(decoded, "re")
- _, nir01 = get_image_by_role(decoded, "nir")
-
- def assert_not_raw10_packed_image(role, img, expected_w=1280):
- if img is None:
- return
-
- if img.ndim == 2 and img.shape[1] == int(expected_w * 10 / 8):
- raise RuntimeError(
- f"{role.upper()} parece RAW10_PACKED interpretado como imagem: "
- f"shape={img.shape}. Esperado decodificado com largura {expected_w}."
- )
-
- assert_not_raw10_packed_image("re", re01, expected_w=1280)
- assert_not_raw10_packed_image("nir", nir01, expected_w=1280)
-
- out = {}
-
- if rgb01 is not None:
- rgb01 = rgb01.astype(np.float32)
- if rgb01.ndim == 3 and rgb01.shape[2] >= 3:
- out["R"] = rgb01[:, :, 0].copy()
- out["G"] = rgb01[:, :, 1].copy()
- out["B"] = rgb01[:, :, 2].copy()
-
- if re01 is not None:
- out["RE"] = re01.astype(np.float32).copy()
-
- if nir01 is not None:
- out["NIR"] = nir01.astype(np.float32).copy()
-
- return out
-
-
-def channel_to_role(ch: str) -> str:
- ch = ch.upper()
- if ch in ("R", "G", "B"):
- return "rgb"
- if ch == "RE":
- return "re"
- if ch == "NIR":
- return "nir"
- raise ValueError(f"Canal desconhecido: {ch}")
-
-
-def apply_exp_gain_correction(channels: dict, controls: dict, enabled: bool) -> dict:
- if not enabled:
- return {k: v.astype(np.float32).copy() for k, v in channels.items()}
-
- corrected = {}
- for ch, img in channels.items():
- role = channel_to_role(ch)
- ctrl = controls.get(role, {}) or {}
- factor = exposure_gain_factor(ctrl, fallback_exp=1.0, fallback_gain=1.0)
- corrected[ch] = (img.astype(np.float32) / max(factor, 1e-6)).astype(np.float32)
-
- return corrected
-
-
-# ============================================================
-# UI
-# ============================================================
-
-def build_board(decoded, controls, state_lines, progress_lines, preview_scale=1.0, quality_lines=None):
- rgb_id, rgb01 = get_image_by_role(decoded, "rgb")
- re_id, re01 = get_image_by_role(decoded, "re")
- nir_id, nir01 = get_image_by_role(decoded, "nir")
-
- if rgb01 is not None:
- rgb_panel = to_bgr_u8_from_rgb01(rgb01)
- base_h, base_w = rgb01.shape[:2]
- else:
- base_h, base_w = 800, 1280
- rgb_panel = np.zeros((base_h, base_w, 3), dtype=np.uint8)
- overlay_hud(rgb_panel, ["RGB", "sem frame"])
-
- re01 = resize_if_needed(re01, (base_h, base_w)) if re01 is not None else None
- nir01 = resize_if_needed(nir01, (base_h, base_w)) if nir01 is not None else None
-
- re_panel = gray_to_bgr_u8(re01) if re01 is not None else np.zeros_like(rgb_panel)
- nir_panel = gray_to_bgr_u8(nir01) if nir01 is not None else np.zeros_like(rgb_panel)
-
- rgb_stats = compute_image_stats(rgb01[:, :, 1] if rgb01 is not None and rgb01.ndim == 3 else None)
- re_stats = compute_image_stats(re01)
- nir_stats = compute_image_stats(nir01)
-
- overlay_hud(rgb_panel, [
- f"RGB ({rgb_id})",
- f"p50={rgb_stats['p50']:.3f} p95={rgb_stats['p95']:.3f} sat={rgb_stats['sat_pct']:.2f}%",
- f"EXP={controls.get('rgb', {}).get('exposure_time_us')} GAIN={controls.get('rgb', {}).get('analogue_gain')}",
- ])
-
- overlay_hud(re_panel, [
- f"RE ({re_id})",
- f"p50={re_stats['p50']:.3f} p95={re_stats['p95']:.3f} sat={re_stats['sat_pct']:.2f}%",
- f"EXP={controls.get('re', {}).get('exposure_time_us')} GAIN={controls.get('re', {}).get('analogue_gain')}",
- ])
-
- overlay_hud(nir_panel, [
- f"NIR ({nir_id})",
- f"p50={nir_stats['p50']:.3f} p95={nir_stats['p95']:.3f} sat={nir_stats['sat_pct']:.2f}%",
- f"EXP={controls.get('nir', {}).get('exposure_time_us')} GAIN={controls.get('nir', {}).get('analogue_gain')}",
- ])
-
- data_panel = np.zeros_like(rgb_panel)
-
- lines = []
- lines.extend(state_lines)
- lines.append("")
- lines.extend(progress_lines)
- if quality_lines:
- lines.append("")
- lines.extend(quality_lines)
- lines.append("")
- lines.extend([
- "ENTER = iniciar etapa atual",
- "S = pular etapa dark/preto",
- "Q / ESC = sair sem salvar",
- "",
- "Dica: branco/preto devem preencher todo o campo de visao.",
- "Para dark-frame perfeito, tampe as lentes em vez de usar fundo preto.",
- ])
-
- overlay_hud(data_panel, lines, x=18, y=34, font_scale=0.58, line_step=24)
-
- top = np.hstack([rgb_panel, re_panel])
- bottom = np.hstack([nir_panel, data_panel])
- board = np.vstack([top, bottom])
-
- if preview_scale != 1.0:
- board = cv2.resize(
- board,
- (int(board.shape[1] * preview_scale), int(board.shape[0] * preview_scale)),
- interpolation=cv2.INTER_NEAREST,
- )
-
- return board
-
-
-# ============================================================
-# Captura e processamento
-# ============================================================
-
-def capture_stage(
- cam: MultiSpectralClient,
- stage_name: str,
- frames_count: int,
- discard_frames: int,
- exp_gain_correct: bool,
- preview_scale: float,
- window_name: str,
-):
- """
- Captura frames decodificados e retorna:
- channel_stack: dict canal -> list[np.ndarray]
- controls_log: lista dos controles reais lidos por frame
- meta_log: lista de metadados básicos por frame
- """
- channel_stack = {}
- controls_log = []
- meta_log = []
-
- total_target = int(frames_count)
- captured = 0
- seen_frame_ids = set()
- last_decoded = {}
- last_controls = {}
-
- while captured < total_target:
- frame, meta, decoded = cam.get_next_decoded(timeout=2.0)
- if meta is None or frame is None:
- continue
-
- frame_id = meta.get("frame_id")
- if frame_id in seen_frame_ids:
- continue
- seen_frame_ids.add(frame_id)
-
- last_decoded = decoded
- last_controls = get_all_controls(cam)
-
- if len(seen_frame_ids) <= discard_frames:
- progress_lines = [
- f"Etapa: {stage_name}",
- f"Descartando frames iniciais: {len(seen_frame_ids)}/{discard_frames}",
- "Aguardando estabilizacao de exposicao/stream...",
- ]
- board = build_board(
- decoded=last_decoded,
- controls=last_controls,
- state_lines=[f"CALIBRACAO FLAT-FIELD - {stage_name.upper()}"],
- progress_lines=progress_lines,
- preview_scale=preview_scale,
- )
- cv2.imshow(window_name, board)
- cv2.waitKey(1)
- continue
-
- channels = extract_channels_from_decoded(decoded)
- channels = apply_exp_gain_correction(channels, last_controls, enabled=exp_gain_correct)
-
- for ch, img in channels.items():
- channel_stack.setdefault(ch, []).append(img.astype(np.float32).copy())
-
- meta_log.append({
- "frame_id": frame_id,
- "sync_ok": meta.get("sync_ok"),
- "sync_dt_ms": meta.get("sync_dt_ms"),
- "timestamps": meta.get("timestamps"),
- })
- controls_log.append(last_controls)
-
- captured += 1
-
- progress_lines = [
- f"Etapa: {stage_name}",
- f"Capturando: {captured}/{total_target}",
- f"exp_gain_correction={'ON' if exp_gain_correct else 'OFF'}",
- ]
-
- board = build_board(
- decoded=last_decoded,
- controls=last_controls,
- state_lines=[f"CALIBRACAO FLAT-FIELD - {stage_name.upper()}"],
- progress_lines=progress_lines,
- preview_scale=preview_scale,
- )
-
- # Barra de progresso simples.
- h, w = board.shape[:2]
- pct = captured / max(total_target, 1)
- cv2.rectangle(board, (30, h - 38), (w - 30, h - 18), (80, 80, 80), -1)
- cv2.rectangle(board, (30, h - 38), (30 + int((w - 60) * pct), h - 18), (0, 220, 0), -1)
-
- cv2.imshow(window_name, board)
-
- k = cv2.waitKey(1) & 0xFF
- if k in (ord("q"), ord("Q"), 27):
- raise KeyboardInterrupt("Captura cancelada pelo usuario.")
-
- return channel_stack, controls_log, meta_log
-
-
-def median_stack(channel_stack: dict) -> dict:
- med = {}
- for ch, frames in channel_stack.items():
- if not frames:
- continue
- arr = np.stack(frames, axis=0).astype(np.float32)
- med[ch] = np.median(arr, axis=0).astype(np.float32)
- return med
-
-
-def build_gain_maps(
- white_med: dict,
- dark_med: dict | None,
- epsilon: float,
- smooth_ksize: int,
- min_gain: float,
- max_gain: float,
-):
- gain_maps = {}
- flat_norm_maps = {}
- corrected_white = {}
- dark_used = {}
-
- for ch, white in white_med.items():
- white = white.astype(np.float32)
-
- if dark_med is not None and ch in dark_med:
- dark = resize_if_needed(dark_med[ch].astype(np.float32), white.shape[:2])
- else:
- dark = np.zeros_like(white, dtype=np.float32)
-
- signal = white - dark
- signal = np.maximum(signal, float(epsilon)).astype(np.float32)
-
- center = robust_center(signal, low_pct=10.0, high_pct=90.0)
- flat_norm = signal / max(center, epsilon)
-
- gain = center / np.maximum(signal, epsilon)
- gain = smooth_map_gain(gain, smooth_ksize)
- gain = clip_gain_map(gain, min_gain=min_gain, max_gain=max_gain)
-
- gain_maps[ch] = gain.astype(np.float32)
- flat_norm_maps[ch] = flat_norm.astype(np.float32)
- corrected_white[ch] = signal.astype(np.float32)
- dark_used[ch] = dark.astype(np.float32)
-
- return gain_maps, flat_norm_maps, corrected_white, dark_used
-
-
-def save_preview_maps(out_dir: str, gain_maps: dict, corrected_white: dict):
- ensure_dir(out_dir)
-
- for ch, gain in gain_maps.items():
- gain_vis = normalize_for_display(gain)
- cv2.imwrite(os.path.join(out_dir, f"gain_{ch}.png"), gray_to_bgr_u8(gain_vis))
-
- for ch, white in corrected_white.items():
- white_vis = normalize_for_display(white)
- cv2.imwrite(os.path.join(out_dir, f"white_signal_{ch}.png"), gray_to_bgr_u8(white_vis))
-
-
-def show_final_preview(window_name: str, gain_maps: dict, preview_scale: float, quality_report: dict | None = None):
- order = ["R", "G", "B", "RE", "NIR"]
- panels = []
-
- # RGB e mono podem sair com tamanhos diferentes dependendo do decode/preview.
- # Para o painel final, padronizamos tudo para o maior H/W encontrado.
- shapes = [gain_maps[ch].shape[:2] for ch in order if ch in gain_maps]
- if not shapes:
- return
-
- target_h = max(s[0] for s in shapes)
- target_w = max(s[1] for s in shapes)
-
- def fit_panel(img_bgr: np.ndarray) -> np.ndarray:
- if img_bgr.shape[:2] == (target_h, target_w):
- return img_bgr
- return cv2.resize(img_bgr, (target_w, target_h), interpolation=cv2.INTER_NEAREST)
-
- for ch in order:
- if ch not in gain_maps:
- panel = np.zeros((target_h, target_w, 3), dtype=np.uint8)
- overlay_hud(panel, [ch, "sem mapa"])
- else:
- vis = normalize_for_display(gain_maps[ch])
- panel = gray_to_bgr_u8(vis)
- panel = fit_panel(panel)
- g = gain_maps[ch]
- overlay_hud(panel, [
- f"GAIN MAP {ch}",
- f"shape={list(g.shape)}",
- f"min={float(np.min(g)):.3f} max={float(np.max(g)):.3f}",
- f"mean={float(np.mean(g)):.3f} std={float(np.std(g)):.3f}",
- ])
- panels.append(panel)
-
- blank = np.zeros((target_h, target_w, 3), dtype=np.uint8)
- final_lines = [
- "Flat-field salvo com sucesso.",
- "ENTER/qualquer tecla = fechar",
- "",
- ]
- final_lines.extend(format_quality_lines(quality_report, title="FINAL QUALITY", max_channels=5, max_reasons=8))
- final_lines.extend([
- "",
- "Use estes mapas antes da fusao geometrica.",
- "",
- "Obs: RGB e mono podem ter shapes diferentes;",
- "isso e normal se o decode gerar resolucoes distintas.",
- ])
- overlay_hud(blank, final_lines, x=18, y=36)
-
- top = np.hstack([panels[0], panels[1], panels[2]])
- bottom = np.hstack([panels[3], panels[4], blank])
- board = np.vstack([top, bottom])
-
- if preview_scale != 1.0:
- board = cv2.resize(
- board,
- (int(board.shape[1] * preview_scale), int(board.shape[0] * preview_scale)),
- interpolation=cv2.INTER_NEAREST,
- )
-
- cv2.imshow(window_name, board)
- cv2.waitKey(0)
-
-
-def wait_for_enter_or_skip(
- cam: MultiSpectralClient,
- window_name: str,
- title: str,
- instruction_lines: list[str],
- preview_scale: float,
- allow_skip=False,
- quality_check_white=False,
- require_good_quality=False,
-):
- while True:
- frame, meta, decoded = cam.get_next_decoded(timeout=2.0)
- if meta is None or frame is None:
- continue
-
- controls = get_all_controls(cam)
-
- progress_lines = list(instruction_lines)
- quality_report = None
- quality_lines = None
- if quality_check_white:
- try:
- channels_now = extract_channels_from_decoded(decoded)
- quality_report = evaluate_white_frame_quality(channels_now)
- quality_lines = format_quality_lines(quality_report, title="WHITE PREFLIGHT")
- if require_good_quality and quality_report.get("status") != "good":
- progress_lines.append("")
- progress_lines.append("ENTER bloqueado: ajuste fundo/luz ate WHITE PREFLIGHT=GOOD.")
- else:
- progress_lines.append("")
- progress_lines.append("ENTER inicia. Use o status abaixo para decidir se esta seguro.")
- except Exception as e:
- quality_report = {"status": "bad", "reasons": [f"preflight_error:{e}"], "channels": {}}
- quality_lines = format_quality_lines(quality_report, title="WHITE PREFLIGHT")
-
- print(f"QUALITY_LINES: {quality_lines}")
-
- if allow_skip:
- progress_lines.append("")
- progress_lines.append("S = pular esta etapa")
-
- board = build_board(
- decoded=decoded,
- controls=controls,
- state_lines=[title],
- progress_lines=progress_lines,
- preview_scale=preview_scale,
- quality_lines=quality_lines,
- )
- cv2.imshow(window_name, board)
-
- k = cv2.waitKey(1) & 0xFF
- if k in (13, 10):
- if quality_check_white and require_good_quality and quality_report and quality_report.get("status") != "good":
- continue
- return "start"
- if allow_skip and k in (ord("s"), ord("S")):
- return "skip"
- if k in (ord("q"), ord("Q"), 27):
- raise KeyboardInterrupt("Cancelado pelo usuario.")
-
-
-# ============================================================
-# Main
-# ============================================================
-
-def main():
- parser = argparse.ArgumentParser(
- description="Calibrador automatico de flat-field/dark-frame para o módulo RGB/RE/NIR OAK-FCC-3.",
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
- )
-
- parser.add_argument("--fps", type=int, default=20)
- parser.add_argument("--width", type=int, default=1280)
- parser.add_argument("--height", type=int, default=800)
- parser.add_argument("--bayer", default="BGGR", choices=["GBRG", "GRBG", "RGGB", "BGGR"])
- parser.add_argument("--capture_mode", default="AUTO", choices=["AUTO", "SINGLE", "DOUBLE", "TRIPLE"])
- parser.add_argument("--raw_policy", default="require_triple", choices=["allow_single", "require_triple"])
- parser.add_argument("--module_calibration_json", default="calibration/module_params.json")
-
- parser.add_argument("--frames", type=int, default=60, help="Frames úteis capturados por etapa.")
- parser.add_argument("--discard_frames", type=int, default=15, help="Frames descartados antes de cada etapa.")
- parser.add_argument("--preview_scale", type=float, default=0.65)
-
- parser.add_argument("--out_npz", default="calibration/flatfield_maps_v1.npz")
- parser.add_argument("--out_json", default="calibration/flatfield_maps_v1.json")
- parser.add_argument("--preview_dir", default="calibration/flatfield_previews")
-
- parser.add_argument("--smooth_ksize", type=int, default=31, help="Kernel gaussiano para suavizar mapa de ganho. Use 1 para desligar.")
- parser.add_argument("--min_gain", type=float, default=0.25)
- parser.add_argument("--max_gain", type=float, default=4.0)
- parser.add_argument("--epsilon", type=float, default=1e-8)
-
- parser.add_argument("--exp_gain_correct", action="store_true",
- help="Divide frames por exposure_time_us*analogue_gain antes de calcular o flat.")
- parser.add_argument("--skip_dark", action="store_true",
- help="Pula etapa dark/preto. O mapa será calculado sem subtração dark.")
- parser.add_argument("--notes", default="")
- parser.add_argument("--require_good_preflight", action="store_true",
- help="Bloqueia ENTER na etapa WHITE se o preflight estiver BAD/WARNING.")
-
- args = parser.parse_args()
-
- ensure_dir(os.path.dirname(args.out_npz) or ".")
- ensure_dir(os.path.dirname(args.out_json) or ".")
- ensure_dir(args.preview_dir)
-
- window_name = "Flat Field Calibration Tool"
- cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
-
- session_meta = {
- "schema": "multispec_flatfield_v1",
- "created_at": now_str(),
- "sensor_width": args.width,
- "sensor_height": args.height,
- "bayer_pattern": args.bayer,
- "fps": args.fps,
- "capture_mode_requested": args.capture_mode,
- "raw_policy": args.raw_policy,
- "module_calibration_json": args.module_calibration_json,
- "frames_per_stage": args.frames,
- "discard_frames": args.discard_frames,
- "exp_gain_correct": bool(args.exp_gain_correct),
- "smooth_ksize": args.smooth_ksize,
- "min_gain": args.min_gain,
- "max_gain": args.max_gain,
- "epsilon": args.epsilon,
- "channels": ["R", "G", "B", "RE", "NIR"],
- "notes": args.notes,
- "require_good_preflight": bool(args.require_good_preflight),
- "stages": {},
- "outputs": {
- "npz": args.out_npz,
- "json": args.out_json,
- "preview_dir": args.preview_dir,
- },
- }
-
- try:
- with MultiSpectralClient(
- width=args.width,
- height=args.height,
- bayer=args.bayer,
- fps=args.fps,
- frame_type="RAW_BRUTO",
- output_dtype="uint8",
- capture_mode=args.capture_mode,
- raw_policy=args.raw_policy,
- module_calibration_json=args.module_calibration_json or None
- ) as cam:
-
- validate_module_ready(cam.get_status(), args.raw_policy)
-
- wait_for_enter_or_skip(
- cam=cam,
- window_name=window_name,
- title="ETAPA 1/2 - WHITE / FLAT FIELD",
- instruction_lines=[
- "Posicione o modulo na altura real de operacao.",
- "Aponte para uma superficie branca/cinza fosca, uniforme e sem textura.",
- "Evite reflexos, sombras laterais e saturacao.",
- "A superficie deve preencher todo o campo de visao.",
- "Pressione ENTER para comecar a captura WHITE.",
- ],
- preview_scale=args.preview_scale,
- allow_skip=False,
- quality_check_white=True,
- require_good_quality=args.require_good_preflight,
- )
-
- white_stack, white_controls, white_meta = capture_stage(
- cam=cam,
- stage_name="white",
- frames_count=args.frames,
- discard_frames=args.discard_frames,
- exp_gain_correct=args.exp_gain_correct,
- preview_scale=args.preview_scale,
- window_name=window_name,
- )
-
- session_meta["stages"]["white"] = {
- "controls_log": white_controls,
- "meta_log": white_meta,
- "captured_channels": {ch: len(v) for ch, v in white_stack.items()},
- }
-
- dark_stack = None
- dark_controls = []
- dark_meta = []
-
- if not args.skip_dark:
- action = wait_for_enter_or_skip(
- cam=cam,
- window_name=window_name,
- title="ETAPA 2/2 - DARK / PRETO",
- instruction_lines=[
- "Agora faca a captura dark/preto.",
- "Melhor opcao: tampe as lentes completamente.",
- "Alternativa: use fundo preto fosco preenchendo todo o frame.",
- "Mantenha exposicao/ganho iguais aos da etapa anterior, se possivel.",
- "Pressione ENTER para capturar DARK/PRETO.",
- ],
- preview_scale=args.preview_scale,
- allow_skip=True,
- )
-
- if action == "start":
- dark_stack, dark_controls, dark_meta = capture_stage(
- cam=cam,
- stage_name="dark",
- frames_count=args.frames,
- discard_frames=args.discard_frames,
- exp_gain_correct=args.exp_gain_correct,
- preview_scale=args.preview_scale,
- window_name=window_name,
- )
-
- session_meta["stages"]["dark"] = {
- "controls_log": dark_controls,
- "meta_log": dark_meta,
- "captured_channels": {ch: len(v) for ch, v in dark_stack.items()},
- }
- else:
- session_meta["stages"]["dark"] = {"skipped": True}
- else:
- session_meta["stages"]["dark"] = {"skipped": True}
-
- # Processamento robusto.
- processing_panel = np.zeros((720, 1280, 3), dtype=np.uint8)
- overlay_hud(processing_panel, [
- "Processando calibracao flat-field...",
- "Calculando medianas robustas por canal.",
- "Gerando mapas de ganho e previews.",
- ], x=40, y=80, font_scale=0.8, line_step=34)
- cv2.imshow(window_name, processing_panel)
- cv2.waitKey(1)
-
- white_med = median_stack(white_stack)
- dark_med = median_stack(dark_stack) if dark_stack is not None else None
-
- gain_maps, flat_norm_maps, corrected_white, dark_used = build_gain_maps(
- white_med=white_med,
- dark_med=dark_med,
- epsilon=args.epsilon,
- smooth_ksize=args.smooth_ksize,
- min_gain=args.min_gain,
- max_gain=args.max_gain,
- )
-
- white_median_quality = evaluate_white_frame_quality(white_med)
- final_quality_report = evaluate_gain_maps_quality(gain_maps, corrected_white=corrected_white)
- session_meta["white_median_quality"] = white_median_quality
- session_meta["quality_report"] = final_quality_report
-
- # Salva .npz.
- save_payload = {}
- for ch, arr in gain_maps.items():
- save_payload[f"gain_{ch}"] = arr.astype(np.float32)
- for ch, arr in flat_norm_maps.items():
- save_payload[f"flat_norm_{ch}"] = arr.astype(np.float32)
- for ch, arr in white_med.items():
- save_payload[f"white_median_{ch}"] = arr.astype(np.float32)
- if dark_med is not None:
- for ch, arr in dark_med.items():
- save_payload[f"dark_median_{ch}"] = arr.astype(np.float32)
-
- np.savez_compressed(args.out_npz, **save_payload)
-
- # Estatísticas finais.
- session_meta["maps"] = {}
- for ch, gain in gain_maps.items():
- session_meta["maps"][ch] = {
- "shape": list(gain.shape),
- "gain_key": f"gain_{ch}",
- "flat_norm_key": f"flat_norm_{ch}",
- "white_median_key": f"white_median_{ch}",
- "dark_median_key": f"dark_median_{ch}" if dark_med is not None and ch in dark_med else None,
- "gain_min": float(np.min(gain)),
- "gain_max": float(np.max(gain)),
- "gain_mean": float(np.mean(gain)),
- "gain_std": float(np.std(gain)),
- "white_signal_stats": compute_image_stats(normalize_for_display(corrected_white[ch])),
- "quality": (final_quality_report.get("channels", {}).get(ch, {}) if isinstance(final_quality_report, dict) else {}),
- }
-
- save_preview_maps(args.preview_dir, gain_maps, corrected_white)
-
- with open(args.out_json, "w", encoding="utf-8") as f:
- json.dump(session_meta, f, ensure_ascii=False, indent=2)
-
- show_final_preview(window_name, gain_maps, args.preview_scale, quality_report=final_quality_report)
-
- print("")
- print("[OK] Flat-field gerado com sucesso.")
- print(f"[OK] NPZ : {args.out_npz}")
- print(f"[OK] JSON: {args.out_json}")
- print(f"[OK] PNGs: {args.preview_dir}")
- print(f"[QUALITY] status={final_quality_report.get('status')} reasons={final_quality_report.get('reasons', [])[:6]}")
-
- except KeyboardInterrupt as e:
- print(f"[CANCELADO] {e}")
-
- finally:
- cv2.destroyAllWindows()
-
-
-if __name__ == "__main__":
- main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/focus_calibration_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/focus_calibration_tool.py
deleted file mode 100644
index 4a3a564a0..000000000
--- a/Python/OAK/datasets/oak-fcc-3/utils/focus_calibration_tool.py
+++ /dev/null
@@ -1,1673 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
-"""
-Focus Calibration Tool - OAK-FFC-3P - Sensor Aware
-====================================================
-
-Objetivo
---------
-Ferramenta standalone para:
- - detectar automaticamente os sensores conectados;
- - suportar CAM_A com OV9782 (1280x800) OU AR0234 (1920x1200);
- - visualizar RGB + RE + NIR simultaneamente;
- - medir foco manual por Laplacian / Tenengrad / Brenner;
- - permitir ROI por câmera;
- - salvar melhores scores e snapshots em JSON/PNG.
-
-Importante
-----------
-Este script NÃO usa o OakFcc3Client e NÃO depende do pipeline RAW_BRUTO.
-Para foco óptico ele usa:
- - saída ISP da câmera colorida (RGB);
- - saída nativa das câmeras mono (RE/NIR).
-
-Assim, Bayer pattern e normalização do dataset não interferem no teste de foco.
-
-Configuração padrão esperada:
- CAM_A = RGB -> OV9782 ou AR0234
- CAM_B = RE -> OV9282
- CAM_C = NIR -> OV9282
-
-Exemplos
---------
-# Detecta e abre tudo que estiver disponível:
-python focus_calibration_sensor_aware.py
-
-# Primeiro teste somente da RGB nova:
-python focus_calibration_sensor_aware.py --mode rgb
-
-# Exige as três câmeras:
-python focus_calibration_sensor_aware.py --mode triple
-
-# Se RE e NIR estiverem fisicamente invertidas:
-python focus_calibration_sensor_aware.py --re-socket CAM_C --nir-socket CAM_B
-
-Teclas
-------
-1 = RGB
-2 = RE
-3 = NIR
-M = troca métrica
-D = informa sentido atual da lente
-E = equalização ON/OFF
-L = trava/destrava ROI
-C = centraliza ROI
-R = reseta score da câmera/métrica ativa
-S = salva snapshot PNG + registro
-SPACE = salva JSON completo
-Q / ESC = sair
-"""
-
-import os
-import json
-import time
-import argparse
-from dataclasses import dataclass, asdict
-from datetime import datetime
-from collections import deque
-from typing import Dict, Optional, Tuple, List
-
-import cv2
-import numpy as np
-import depthai as dai
-
-
-# ============================================================
-# Helpers gerais
-# ============================================================
-
-METHODS = ("laplacian", "tenengrad", "brenner")
-ROLES = ("rgb", "re", "nir")
-
-
-def now_str() -> str:
- return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
-
-def now_file_str() -> str:
- return datetime.now().strftime("%Y%m%d_%H%M%S")
-
-
-def ensure_dir(path: str):
- if path:
- os.makedirs(path, exist_ok=True)
-
-
-def overlay_hud(
- img_bgr,
- lines,
- x=12,
- y=24,
- font_scale=0.58,
- line_step=22,
- color=(255, 255, 255),
- shadow=True,
-):
- yy = y
- h, _ = img_bgr.shape[:2]
-
- for s in lines:
- if yy > h - 8:
- break
-
- text = str(s)
-
- if shadow:
- cv2.putText(
- img_bgr,
- text,
- (x, yy),
- cv2.FONT_HERSHEY_SIMPLEX,
- font_scale,
- (0, 0, 0),
- 3,
- cv2.LINE_AA,
- )
-
- cv2.putText(
- img_bgr,
- text,
- (x, yy),
- cv2.FONT_HERSHEY_SIMPLEX,
- font_scale,
- color,
- 1,
- cv2.LINE_AA,
- )
-
- yy += line_step
-
-
-def build_empty_panel(shape_hw: Tuple[int, int], title: str, text="sem frame disponivel"):
- h, w = shape_hw
- img = np.zeros((h, w, 3), dtype=np.uint8)
- overlay_hud(
- img,
- [title, text],
- x=18,
- y=44,
- font_scale=0.75,
- line_step=32,
- )
- return img
-
-
-def socket_name(socket) -> str:
- name = getattr(socket, "name", None)
- if name:
- return str(name)
-
- s = str(socket)
- for candidate in ("CAM_A", "CAM_B", "CAM_C", "CAM_D"):
- if candidate in s:
- return candidate
-
- return s
-
-
-def get_socket(name: str):
- name = str(name).upper().strip()
- mapping = {
- "CAM_A": dai.CameraBoardSocket.CAM_A,
- "CAM_B": dai.CameraBoardSocket.CAM_B,
- "CAM_C": dai.CameraBoardSocket.CAM_C,
- }
-
- if hasattr(dai.CameraBoardSocket, "CAM_D"):
- mapping["CAM_D"] = dai.CameraBoardSocket.CAM_D
-
- if name not in mapping:
- raise ValueError(f"Socket inválido: {name}. Opções: {sorted(mapping)}")
-
- return mapping[name]
-
-
-def supported_type_strings(feature) -> List[str]:
- values = getattr(feature, "supportedTypes", []) or []
- return [str(v).upper() for v in values]
-
-
-def feature_is_color(feature) -> bool:
- types = supported_type_strings(feature)
- sensor = str(getattr(feature, "sensorName", "") or "").upper()
-
- if any("COLOR" in t for t in types):
- return True
-
- if any("MONO" in t for t in types):
- return False
-
- # Fallback conhecido do nosso módulo.
- return sensor in {"OV9782", "AR0234"}
-
-
-def feature_is_mono(feature) -> bool:
- types = supported_type_strings(feature)
-
- if any("MONO" in t for t in types):
- return True
-
- if any("COLOR" in t for t in types):
- return False
-
- return not feature_is_color(feature)
-
-
-def bgr_to_gray01(img_bgr: np.ndarray) -> Optional[np.ndarray]:
- if img_bgr is None:
- return None
-
- if img_bgr.ndim == 2:
- gray = img_bgr
- elif img_bgr.ndim == 3 and img_bgr.shape[2] == 1:
- gray = img_bgr[:, :, 0]
- else:
- gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
-
- gray = gray.astype(np.float32) / 255.0
- return np.clip(gray, 0.0, 1.0)
-
-
-def resize_panel(img: np.ndarray, target_hw: Tuple[int, int]) -> np.ndarray:
- th, tw = target_hw
- return cv2.resize(img, (tw, th), interpolation=cv2.INTER_AREA)
-
-
-def colorize_mono_for_view(img_bgr: np.ndarray, role: str) -> np.ndarray:
- if img_bgr is None:
- return None
-
- if img_bgr.ndim == 3:
- gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
- else:
- gray = img_bgr
-
- z = np.zeros_like(gray)
-
- role = str(role).lower()
-
- # Apenas visual. As métricas são calculadas no frame original.
- if role == "re":
- # Vermelho no BGR.
- return np.dstack([z, z, gray])
-
- if role == "nir":
- # Ciano no BGR.
- return np.dstack([gray, gray, z])
-
- return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
-
-
-def default_roi_for_shape(shape_hw, frac=0.42):
- h, w = shape_hw
- rw = max(8, int(w * frac))
- rh = max(8, int(h * frac))
- x0 = (w - rw) // 2
- y0 = (h - rh) // 2
- return (x0, y0, x0 + rw, y0 + rh)
-
-
-def sanitize_roi(rect, shape_hw):
- if rect is None:
- return None
-
- h, w = shape_hw
- x0, y0, x1, y1 = rect
-
- x0, x1 = sorted((int(x0), int(x1)))
- y0, y1 = sorted((int(y0), int(y1)))
-
- x0 = max(0, min(w - 1, x0))
- x1 = max(1, min(w, x1))
- y0 = max(0, min(h - 1, y0))
- y1 = max(1, min(h, y1))
-
- if x1 - x0 < 4 or y1 - y0 < 4:
- return None
-
- return (x0, y0, x1, y1)
-
-
-def crop_rect(img: np.ndarray, rect):
- if img is None or rect is None:
- return None
-
- rect = sanitize_roi(rect, img.shape[:2])
- if rect is None:
- return None
-
- x0, y0, x1, y1 = rect
- return img[y0:y1, x0:x1]
-
-
-# ============================================================
-# Descoberta / resolução sensor-aware
-# ============================================================
-
-@dataclass
-class SensorSpec:
- role: str
- socket_name: str
- sensor_name: str
- feature_width: int
- feature_height: int
- is_color: bool
- configured_width: int
- configured_height: int
- resolution_name: str
- stream_name: str
- source: str
-
-
-def discover_cameras(mx_id: Optional[str]):
- device_info = dai.DeviceInfo(mx_id) if mx_id else None
-
- if device_info is not None:
- ctx = dai.Device(device_info)
- else:
- ctx = dai.Device()
-
- with ctx as device:
- features = list(device.getConnectedCameraFeatures())
-
- actual_mx = None
- for attr in ("getMxId", "getDeviceId"):
- if hasattr(device, attr):
- try:
- actual_mx = str(getattr(device, attr)())
- if actual_mx:
- break
- except Exception:
- pass
-
- usb_speed = None
- try:
- usb_speed = str(device.getUsbSpeed())
- except Exception:
- pass
-
- rows = []
- for f in features:
- rows.append({
- "socket_obj": f.socket,
- "socket_name": socket_name(f.socket),
- "sensor_name": str(getattr(f, "sensorName", "") or ""),
- "width": int(getattr(f, "width", 0) or 0),
- "height": int(getattr(f, "height", 0) or 0),
- "supported_types": supported_type_strings(f),
- "is_color": feature_is_color(f),
- "is_mono": feature_is_mono(f),
- "has_autofocus_ic": int(getattr(f, "hasAutofocusIC", 0) or 0),
- })
-
- return rows, actual_mx, usb_speed
-
-
-def enum_if_exists(enum_cls, name: str):
- return getattr(enum_cls, name, None)
-
-
-def pick_color_resolution(sensor_name: str, width: int, height: int):
- """
- Retorna:
- (enum_resolution, resolution_name, configured_width, configured_height)
- """
- sensor = str(sensor_name or "").upper()
- enum_cls = dai.ColorCameraProperties.SensorResolution
-
- if "AR0234" in sensor:
- enum_value = enum_if_exists(enum_cls, "THE_1200_P")
- if enum_value is None:
- raise RuntimeError(
- "Seu depthai não possui ColorCameraProperties.SensorResolution.THE_1200_P. "
- "Atualize a biblioteca DepthAI antes de testar a AR0234."
- )
- return enum_value, "THE_1200_P", 1920, 1200
-
- if "OV9782" in sensor:
- enum_value = enum_if_exists(enum_cls, "THE_800_P")
- if enum_value is None:
- raise RuntimeError("DepthAI sem THE_800_P para ColorCamera.")
- return enum_value, "THE_800_P", 1280, 800
-
- # Fallback por resolução anunciada pelo próprio sensor.
- candidates = [
- ((1920, 1200), "THE_1200_P"),
- ((1280, 800), "THE_800_P"),
- ((1920, 1080), "THE_1080_P"),
- ((1280, 720), "THE_720_P"),
- ((3840, 2160), "THE_4_K"),
- ]
-
- for (w, h), enum_name in candidates:
- if (width, height) == (w, h):
- enum_value = enum_if_exists(enum_cls, enum_name)
- if enum_value is not None:
- return enum_value, enum_name, w, h
-
- raise RuntimeError(
- f"Sensor colorido não mapeado: {sensor_name} ({width}x{height}). "
- "Adicione o modo em pick_color_resolution()."
- )
-
-
-def pick_mono_resolution(sensor_name: str, width: int, height: int):
- sensor = str(sensor_name or "").upper()
- enum_cls = dai.MonoCameraProperties.SensorResolution
-
- if "OV9282" in sensor or (width, height) == (1280, 800):
- enum_value = enum_if_exists(enum_cls, "THE_800_P")
- if enum_value is None:
- raise RuntimeError("DepthAI sem THE_800_P para MonoCamera.")
- return enum_value, "THE_800_P", 1280, 800
-
- candidates = [
- ((1280, 800), "THE_800_P"),
- ((1280, 720), "THE_720_P"),
- ((640, 480), "THE_480_P"),
- ((640, 400), "THE_400_P"),
- ]
-
- for (w, h), enum_name in candidates:
- if (width, height) == (w, h):
- enum_value = enum_if_exists(enum_cls, enum_name)
- if enum_value is not None:
- return enum_value, enum_name, w, h
-
- raise RuntimeError(
- f"Sensor mono não mapeado: {sensor_name} ({width}x{height}). "
- "Adicione o modo em pick_mono_resolution()."
- )
-
-
-def build_specs(camera_rows, args) -> Dict[str, SensorSpec]:
- by_socket = {row["socket_name"]: row for row in camera_rows}
-
- role_socket = {
- "rgb": args.rgb_socket.upper(),
- "re": args.re_socket.upper(),
- "nir": args.nir_socket.upper(),
- }
-
- if args.mode == "rgb":
- requested_roles = ["rgb"]
- else:
- requested_roles = ["rgb", "re", "nir"]
-
- specs = {}
-
- for role in requested_roles:
- sock_name = role_socket[role]
- row = by_socket.get(sock_name)
-
- if row is None:
- if args.mode == "triple":
- raise RuntimeError(
- f"Modo triple exige {role.upper()} em {sock_name}, "
- f"mas esse socket não foi detectado."
- )
- continue
-
- if role == "rgb":
- if not row["is_color"]:
- raise RuntimeError(
- f"{sock_name} foi escolhido como RGB, mas o sensor detectado "
- f"({row['sensor_name']}) não foi anunciado como COLOR."
- )
-
- _, res_name, cw, ch = pick_color_resolution(
- row["sensor_name"], row["width"], row["height"]
- )
-
- specs[role] = SensorSpec(
- role=role,
- socket_name=sock_name,
- sensor_name=row["sensor_name"],
- feature_width=row["width"],
- feature_height=row["height"],
- is_color=True,
- configured_width=cw,
- configured_height=ch,
- resolution_name=res_name,
- stream_name="focus_rgb",
- source="ISP",
- )
-
- else:
- if not row["is_mono"]:
- raise RuntimeError(
- f"{sock_name} foi escolhido como {role.upper()}, mas o sensor detectado "
- f"({row['sensor_name']}) não foi anunciado como MONO."
- )
-
- _, res_name, cw, ch = pick_mono_resolution(
- row["sensor_name"], row["width"], row["height"]
- )
-
- specs[role] = SensorSpec(
- role=role,
- socket_name=sock_name,
- sensor_name=row["sensor_name"],
- feature_width=row["width"],
- feature_height=row["height"],
- is_color=False,
- configured_width=cw,
- configured_height=ch,
- resolution_name=res_name,
- stream_name=f"focus_{role}",
- source="MONO_OUT",
- )
-
- if "rgb" not in specs:
- raise RuntimeError(
- f"RGB não encontrada em {args.rgb_socket}. "
- "Confira os flats e/ou use --rgb-socket."
- )
-
- if args.mode == "triple":
- missing = [r for r in ROLES if r not in specs]
- if missing:
- raise RuntimeError(f"Modo triple: faltando roles {missing}")
-
- return specs
-
-
-def build_pipeline(specs: Dict[str, SensorSpec], fps: float):
- pipeline = dai.Pipeline()
-
- for role, spec in specs.items():
- socket = get_socket(spec.socket_name)
-
- if spec.is_color:
- cam = pipeline.createColorCamera()
- cam.setBoardSocket(socket)
-
- enum_value, _, _, _ = pick_color_resolution(
- spec.sensor_name,
- spec.feature_width,
- spec.feature_height,
- )
-
- cam.setResolution(enum_value)
- cam.setFps(float(fps))
- cam.setInterleaved(False)
-
- try:
- cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.BGR)
- except Exception:
- pass
-
- xout = pipeline.createXLinkOut()
- xout.setStreamName(spec.stream_name)
-
- # ISP mantém a resolução útil do sensor e getCvFrame() entrega
- # uma imagem pronta para inspeção, sem depender do nosso Bayer.
- cam.isp.link(xout.input)
-
- else:
- cam = pipeline.createMonoCamera()
- cam.setBoardSocket(socket)
-
- enum_value, _, _, _ = pick_mono_resolution(
- spec.sensor_name,
- spec.feature_width,
- spec.feature_height,
- )
-
- cam.setResolution(enum_value)
- cam.setFps(float(fps))
-
- xout = pipeline.createXLinkOut()
- xout.setStreamName(spec.stream_name)
- cam.out.link(xout.input)
-
- return pipeline
-
-
-# ============================================================
-# Métricas de foco
-# ============================================================
-
-def preprocess_focus_gray(gray01: np.ndarray, equalize=False):
- g = np.clip(gray01 * 255.0, 0, 255).astype(np.uint8)
-
- if equalize:
- g = cv2.equalizeHist(g)
-
- return g
-
-
-def focus_laplacian_var(gray_u8: np.ndarray) -> float:
- lap = cv2.Laplacian(gray_u8, cv2.CV_64F, ksize=3)
- return float(lap.var())
-
-
-def focus_tenengrad(gray_u8: np.ndarray) -> float:
- sx = cv2.Sobel(gray_u8, cv2.CV_64F, 1, 0, ksize=3)
- sy = cv2.Sobel(gray_u8, cv2.CV_64F, 0, 1, ksize=3)
- return float(np.mean(sx * sx + sy * sy))
-
-
-def focus_brenner(gray_u8: np.ndarray) -> float:
- arr = gray_u8.astype(np.float32)
-
- if arr.shape[1] < 3:
- return 0.0
-
- diff = arr[:, 2:] - arr[:, :-2]
- return float(np.mean(diff * diff))
-
-
-def compute_focus_metrics(img_bgr: np.ndarray, roi_rect, equalize=False):
- gray01 = bgr_to_gray01(img_bgr)
- roi = crop_rect(gray01, roi_rect)
-
- if roi is None or roi.size < 64:
- return {
- "valid": False,
- "laplacian": 0.0,
- "tenengrad": 0.0,
- "brenner": 0.0,
- "mean": 0.0,
- "std": 0.0,
- "p95": 0.0,
- "pct_saturated": 0.0,
- "pct_dark": 0.0,
- "pixels": 0,
- }
-
- gray_u8 = preprocess_focus_gray(roi, equalize=equalize)
- arr = roi.astype(np.float32).reshape(-1)
-
- return {
- "valid": True,
- "laplacian": focus_laplacian_var(gray_u8),
- "tenengrad": focus_tenengrad(gray_u8),
- "brenner": focus_brenner(gray_u8),
- "mean": float(arr.mean()),
- "std": float(arr.std()),
- "p95": float(np.percentile(arr, 95)),
- "pct_saturated": float((arr >= 0.98).mean() * 100.0),
- "pct_dark": float((arr <= 0.02).mean() * 100.0),
- "pixels": int(arr.size),
- }
-
-
-def metric_value(metrics: dict, method: str) -> float:
- return float(metrics.get(method, 0.0) or 0.0)
-
-
-def smooth_from_history(history, window: int) -> float:
- if not history:
- return 0.0
-
- vals = [float(x["score"]) for x in list(history)[-max(1, window):]]
- return float(np.mean(vals))
-
-
-def analyze_trend(history, best_score, direction_name: str, drop_warn_pct=3.0):
- if len(history) < 6:
- return {
- "status": "coletando",
- "instruction": "gire devagar e observe o grafico",
- "delta": 0.0,
- "pct_of_best": 0.0,
- }
-
- recent = [float(x["smooth"]) for x in list(history)[-5:]]
-
- if len(history) >= 12:
- old = [float(x["smooth"]) for x in list(history)[-12:-7]]
- else:
- old = [float(x["smooth"]) for x in list(history)[:5]]
-
- recent_mean = float(np.mean(recent))
- old_mean = float(np.mean(old))
- delta = recent_mean - old_mean
-
- pct_of_best = 0.0 if best_score <= 0 else (recent_mean / best_score) * 100.0
- drop_from_best = 100.0 - pct_of_best
-
- if best_score > 0 and drop_from_best >= drop_warn_pct:
- return {
- "status": "passou_do_pico",
- "instruction": f"volte um pouco: contrario de {direction_name}",
- "delta": delta,
- "pct_of_best": pct_of_best,
- }
-
- eps = max(best_score * 0.002, 1e-6)
-
- if delta > eps:
- return {
- "status": "melhorando",
- "instruction": f"continue {direction_name}",
- "delta": delta,
- "pct_of_best": pct_of_best,
- }
-
- if delta < -eps:
- return {
- "status": "piorando",
- "instruction": f"inverta: contrario de {direction_name}",
- "delta": delta,
- "pct_of_best": pct_of_best,
- }
-
- return {
- "status": "estavel",
- "instruction": "ajuste fino ou trave a lente",
- "delta": delta,
- "pct_of_best": pct_of_best,
- }
-
-
-# ============================================================
-# Desenho
-# ============================================================
-
-def draw_roi(panel, roi_src, src_shape_hw, active=False):
- if roi_src is None:
- return
-
- ph, pw = panel.shape[:2]
- sh, sw = src_shape_hw
-
- x0, y0, x1, y1 = roi_src
-
- px0 = int(x0 * pw / max(1, sw))
- px1 = int(x1 * pw / max(1, sw))
- py0 = int(y0 * ph / max(1, sh))
- py1 = int(y1 * ph / max(1, sh))
-
- color = (0, 255, 255) if active else (0, 180, 255)
-
- cv2.rectangle(panel, (px0, py0), (px1, py1), color, 2)
- cv2.putText(
- panel,
- "FOCUS ROI",
- (px0 + 6, max(20, py0 - 8)),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.55,
- color,
- 2,
- cv2.LINE_AA,
- )
-
-
-def draw_crosshair(panel):
- h, w = panel.shape[:2]
-
- cv2.line(
- panel,
- (w // 2 - 18, h // 2),
- (w // 2 + 18, h // 2),
- (255, 255, 255),
- 1,
- cv2.LINE_AA,
- )
-
- cv2.line(
- panel,
- (w // 2, h // 2 - 18),
- (w // 2, h // 2 + 18),
- (255, 255, 255),
- 1,
- cv2.LINE_AA,
- )
-
-
-def draw_panel_title(panel, title, selected=False):
- color = (0, 255, 255) if selected else (255, 255, 255)
- overlay_hud(
- panel,
- [title],
- x=12,
- y=24,
- font_scale=0.60,
- line_step=24,
- color=color,
- )
-
-
-def draw_score_bar(panel, pct, x, y, w, h, label):
- pct = float(max(0.0, min(100.0, pct)))
-
- cv2.rectangle(panel, (x, y), (x + w, y + h), (80, 80, 80), 1)
-
- fill_w = int((pct / 100.0) * w)
- cv2.rectangle(panel, (x, y), (x + fill_w, y + h), (230, 230, 230), -1)
-
- cv2.rectangle(panel, (x, y), (x + w, y + h), (180, 180, 180), 1)
-
- cv2.putText(
- panel,
- f"{label}: {pct:5.1f}%",
- (x, y - 8),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.50,
- (255, 255, 255),
- 1,
- cv2.LINE_AA,
- )
-
-
-def draw_history_graph(panel, history, x, y, w, h, best_score):
- cv2.rectangle(panel, (x, y), (x + w, y + h), (35, 35, 35), -1)
- cv2.rectangle(panel, (x, y), (x + w, y + h), (120, 120, 120), 1)
-
- if len(history) < 2:
- cv2.putText(
- panel,
- "grafico aguardando historico...",
- (x + 10, y + h // 2),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.50,
- (180, 180, 180),
- 1,
- cv2.LINE_AA,
- )
- return
-
- vals = np.array([float(item["smooth"]) for item in history], dtype=np.float32)
- vals = vals[-max(2, w):]
-
- max_val = max(float(np.max(vals)), float(best_score), 1e-6)
- min_val = min(float(np.min(vals)), max_val * 0.90)
- span = max(max_val - min_val, 1e-6)
-
- pts = []
-
- for i, value in enumerate(vals):
- px = x + int((i / max(1, len(vals) - 1)) * (w - 1))
- py = y + h - 1 - int(((float(value) - min_val) / span) * (h - 1))
- pts.append((px, py))
-
- for p0, p1 in zip(pts[:-1], pts[1:]):
- cv2.line(panel, p0, p1, (255, 255, 255), 2, cv2.LINE_AA)
-
- if best_score > 0:
- by = y + h - 1 - int(((best_score - min_val) / span) * (h - 1))
- by = max(y, min(y + h - 1, by))
-
- cv2.line(panel, (x, by), (x + w, by), (0, 255, 255), 1, cv2.LINE_AA)
-
- cv2.putText(
- panel,
- "best",
- (x + 6, max(y + 16, by - 4)),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.43,
- (0, 255, 255),
- 1,
- cv2.LINE_AA,
- )
-
-
-def make_data_panel(
- shape_hw,
- spec: SensorSpec,
- selected_role,
- method,
- metrics,
- score,
- smooth,
- best,
- trend,
- fps_by_role,
- fps_view,
- direction_name,
- history,
- roi_locked,
- equalize,
-):
- h, w = shape_hw
- panel = np.zeros((h, w, 3), dtype=np.uint8)
-
- best_score = float(best.get("smooth", 0.0) or 0.0)
- score_pct = 0.0 if best_score <= 0 else (smooth / best_score) * 100.0
-
- lines = [
- "FOCUS CALIBRATION - SENSOR AWARE",
- f"ativa={selected_role.upper()} | metodo={method}",
- f"sensor={spec.sensor_name} | socket={spec.socket_name}",
- f"modo={spec.resolution_name} | {spec.configured_width}x{spec.configured_height}",
- f"fonte={spec.source}",
- f"score={score:.1f} | smooth={smooth:.1f}",
- f"best={best_score:.1f} | atual/best={score_pct:.1f}%",
- f"status={trend.get('status', 'coletando')}",
- f"acao={trend.get('instruction', 'gire devagar')}",
- f"sentido={direction_name}",
- f"fps RGB/RE/NIR={fps_by_role.get('rgb', 0):.1f}/{fps_by_role.get('re', 0):.1f}/{fps_by_role.get('nir', 0):.1f}",
- f"fps_view={fps_view:.1f}",
- f"ROI={'travada' if roi_locked else 'editavel'} | equalize={'ON' if equalize else 'OFF'}",
- ]
-
- if metrics and metrics.get("valid"):
- lines.extend([
- "-",
- f"mean={metrics['mean']:.3f} std={metrics['std']:.3f} p95={metrics['p95']:.3f}",
- f"sat={metrics['pct_saturated']:.2f}% dark={metrics['pct_dark']:.2f}%",
- ])
-
- overlay_hud(
- panel,
- lines,
- x=14,
- y=26,
- font_scale=0.48,
- line_step=20,
- )
-
- bar_y = min(h - 165, 315)
- bar_y = max(255, bar_y)
-
- draw_score_bar(
- panel,
- min(100.0, score_pct),
- 18,
- bar_y,
- max(80, w - 36),
- 22,
- "nitidez relativa",
- )
-
- graph_y = bar_y + 46
- graph_h = max(65, h - graph_y - 62)
-
- draw_history_graph(
- panel,
- history,
- 18,
- graph_y,
- max(100, w - 36),
- graph_h,
- best_score,
- )
-
- help_lines = [
- "1 RGB | 2 RE | 3 NIR | M metrica | D sentido",
- "mouse ROI | C centraliza | L trava | E equalize | R reset",
- "S snapshot PNG | SPACE salva JSON | Q sai",
- ]
-
- overlay_hud(
- panel,
- help_lines,
- x=14,
- y=h - 48,
- font_scale=0.40,
- line_step=16,
- )
-
- return panel
-
-
-# ============================================================
-# Estado / persistência
-# ============================================================
-
-def empty_best():
- return {
- "score": 0.0,
- "smooth": 0.0,
- "metrics": None,
- "timestamp": None,
- "roi": None,
- }
-
-
-def json_safe_best(best):
- return {
- "score": float(best.get("score", 0.0) or 0.0),
- "smooth": float(best.get("smooth", 0.0) or 0.0),
- "metrics": best.get("metrics"),
- "timestamp": best.get("timestamp"),
- "roi": best.get("roi"),
- }
-
-
-def build_result_payload(
- args,
- camera_rows,
- specs,
- best_by_role_method,
- snapshots,
- actual_mx,
- usb_speed,
-):
- results = {}
-
- for role in ROLES:
- results[role] = {}
- for method in METHODS:
- results[role][method] = json_safe_best(best_by_role_method[role][method])
-
- return {
- "schema": "oak_ffc_focus_calibration_sensor_aware_v2",
- "saved_at": now_str(),
- "depthai_version": getattr(dai, "__version__", "unknown"),
- "device_mx_id": actual_mx,
- "usb_speed": usb_speed,
- "mode": args.mode,
- "fps_requested": args.fps,
- "role_socket_map": {
- "rgb": args.rgb_socket,
- "re": args.re_socket,
- "nir": args.nir_socket,
- },
- "camera_inventory": [
- {k: v for k, v in row.items() if k != "socket_obj"}
- for row in camera_rows
- ],
- "active_specs": {
- role: asdict(spec)
- for role, spec in specs.items()
- },
- "results_by_role_and_method": results,
- "snapshots": snapshots,
- "notes": args.notes or "",
- }
-
-
-def save_json(path, payload):
- ensure_dir(os.path.dirname(path) or ".")
-
- with open(path, "w", encoding="utf-8") as f:
- json.dump(payload, f, ensure_ascii=False, indent=2)
-
-
-# ============================================================
-# Main
-# ============================================================
-
-def main():
- parser = argparse.ArgumentParser(
- description=(
- "Focus Calibration Tool standalone para OAK-FFC-3P, "
- "sensor-aware para OV9782 e AR0234."
- ),
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
- )
-
- parser.add_argument(
- "--mode",
- default="auto",
- choices=["auto", "rgb", "triple"],
- help="auto abre o que existir; rgb abre só CAM_A/RGB; triple exige RGB+RE+NIR",
- )
-
- parser.add_argument("--fps", type=float, default=20.0)
- parser.add_argument("--mx-id", default=None)
-
- parser.add_argument("--rgb-socket", default="CAM_A")
- parser.add_argument("--re-socket", default="CAM_B")
- parser.add_argument("--nir-socket", default="CAM_C")
-
- parser.add_argument(
- "--panel-width",
- type=int,
- default=640,
- help="Largura visual de cada quadrante. Métrica continua na resolução nativa.",
- )
- parser.add_argument(
- "--panel-height",
- type=int,
- default=400,
- help="Altura visual de cada quadrante.",
- )
-
- parser.add_argument(
- "--method",
- default="laplacian",
- choices=list(METHODS),
- )
- parser.add_argument("--history", type=int, default=260)
- parser.add_argument("--smooth-window", type=int, default=5)
- parser.add_argument("--drop-warn-pct", type=float, default=3.0)
- parser.add_argument("--equalize", action="store_true")
-
- parser.add_argument(
- "--out-json",
- default="calibration/focus_calibration_sensor_aware.json",
- )
- parser.add_argument(
- "--snapshot-dir",
- default="calibration/focus_snapshots",
- )
- parser.add_argument("--notes", default="")
-
- args = parser.parse_args()
-
- # --------------------------------------------------------
- # Descoberta
- # --------------------------------------------------------
-
- camera_rows, actual_mx, usb_speed = discover_cameras(args.mx_id)
-
- print("=" * 74)
- print("FOCUS CALIBRATION TOOL - SENSOR AWARE")
- print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}")
- print(f"MX ID : {actual_mx}")
- print(f"USB : {usb_speed}")
- print("-" * 74)
-
- for row in camera_rows:
- print(
- f"{row['socket_name']:5s} | "
- f"{row['sensor_name']:12s} | "
- f"{row['width']}x{row['height']} | "
- f"types={row['supported_types']} | "
- f"AF_IC={row['has_autofocus_ic']}"
- )
-
- specs = build_specs(camera_rows, args)
-
- print("-" * 74)
-
- for role, spec in specs.items():
- print(
- f"[PIPE] {role.upper():3s} <- {spec.socket_name} "
- f"{spec.sensor_name} | {spec.resolution_name} "
- f"{spec.configured_width}x{spec.configured_height} | {spec.source}"
- )
-
- print("=" * 74)
-
- # --------------------------------------------------------
- # Pipeline
- # --------------------------------------------------------
-
- pipeline = build_pipeline(specs, args.fps)
-
- device_info = dai.DeviceInfo(actual_mx) if actual_mx else None
-
- if device_info is not None:
- device_ctx = dai.Device(pipeline, device_info)
- else:
- device_ctx = dai.Device(pipeline)
-
- # --------------------------------------------------------
- # Estado da UI
- # --------------------------------------------------------
-
- selected_role = "rgb"
- method = args.method
- equalize = bool(args.equalize)
-
- direction_idx = 0
- direction_names = ["rosqueando", "desrosqueando"]
-
- frames: Dict[str, Optional[np.ndarray]] = {role: None for role in ROLES}
- roi_rects = {role: None for role in ROLES}
-
- history_by_role_method = {
- role: {
- m: deque(maxlen=args.history)
- for m in METHODS
- }
- for role in ROLES
- }
-
- best_by_role_method = {
- role: {
- m: empty_best()
- for m in METHODS
- }
- for role in ROLES
- }
-
- snapshots = []
-
- fps_by_role = {role: 0.0 for role in ROLES}
- fps_count = {role: 0 for role in ROLES}
- fps_t0 = {role: time.time() for role in ROLES}
-
- fps_view = 0.0
- view_count = 0
- view_t0 = time.time()
-
- roi_locked = False
- dragging_roi = False
- drag_start_src = None
-
- panel_rects = {
- "rgb": None,
- "re": None,
- "nir": None,
- "data": None,
- }
-
- last_board = None
- last_msg = ""
- last_msg_t = 0.0
-
- window_name = "Focus Calibration Tool - Sensor Aware"
- cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
-
- def set_msg(text):
- nonlocal last_msg, last_msg_t
- last_msg = str(text)
- last_msg_t = time.time()
-
- def inside(rect, px, py):
- if rect is None:
- return False
-
- x0, y0, x1, y1 = rect
- return x0 <= px < x1 and y0 <= py < y1
-
- def display_to_source(role, px, py):
- rect = panel_rects.get(role)
- img = frames.get(role)
-
- if rect is None or img is None:
- return None
-
- x0, y0, x1, y1 = rect
- pw = max(1, x1 - x0)
- ph = max(1, y1 - y0)
-
- sh, sw = img.shape[:2]
-
- lx = max(0, min(pw - 1, int(px - x0)))
- ly = max(0, min(ph - 1, int(py - y0)))
-
- sx = int(lx * sw / pw)
- sy = int(ly * sh / ph)
-
- sx = max(0, min(sw - 1, sx))
- sy = max(0, min(sh - 1, sy))
-
- return sx, sy
-
- def on_mouse(event, x, y, flags, param):
- nonlocal dragging_roi, drag_start_src
-
- if roi_locked:
- return
-
- rect = panel_rects.get(selected_role)
-
- if rect is None or not inside(rect, x, y):
- return
-
- src_pt = display_to_source(selected_role, x, y)
-
- if src_pt is None:
- return
-
- if event == cv2.EVENT_LBUTTONDOWN:
- dragging_roi = True
- drag_start_src = src_pt
-
- sx, sy = src_pt
- roi_rects[selected_role] = (sx, sy, sx + 1, sy + 1)
-
- elif event == cv2.EVENT_MOUSEMOVE and dragging_roi and drag_start_src:
- x0, y0 = drag_start_src
- x1, y1 = src_pt
- roi_rects[selected_role] = (x0, y0, x1, y1)
-
- elif event == cv2.EVENT_LBUTTONUP and dragging_roi and drag_start_src:
- x0, y0 = drag_start_src
- x1, y1 = src_pt
-
- rect_src = sanitize_roi(
- (x0, y0, x1, y1),
- frames[selected_role].shape[:2],
- )
-
- if rect_src is not None:
- roi_rects[selected_role] = rect_src
- set_msg(f"ROI atualizada: {selected_role.upper()}")
-
- dragging_roi = False
- drag_start_src = None
-
- cv2.setMouseCallback(window_name, on_mouse)
-
- ensure_dir(args.snapshot_dir)
-
- # --------------------------------------------------------
- # Execução
- # --------------------------------------------------------
-
- try:
- with device_ctx as device:
- queues = {
- role: device.getOutputQueue(
- name=spec.stream_name,
- maxSize=2,
- blocking=False,
- )
- for role, spec in specs.items()
- }
-
- print("[OK] Pipeline iniciado. Ajuste a lente devagar.")
- print("[OK] Q/ESC sai | 1/2/3 troca câmera | S salva snapshot")
-
- while True:
- # ------------------------------------------------
- # Coleta independente: foco não precisa sync rígido.
- # ------------------------------------------------
- for role, queue in queues.items():
- packet = queue.tryGet()
-
- if packet is None:
- continue
-
- try:
- img = packet.getCvFrame()
- except Exception as exc:
- set_msg(f"Falha getCvFrame {role}: {exc}")
- continue
-
- if img is None:
- continue
-
- if img.ndim == 2:
- img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
-
- frames[role] = img
-
- if roi_rects[role] is None:
- roi_rects[role] = default_roi_for_shape(img.shape[:2], frac=0.42)
-
- fps_count[role] += 1
- dt = time.time() - fps_t0[role]
-
- if dt >= 1.0:
- fps_by_role[role] = fps_count[role] / dt
- fps_count[role] = 0
- fps_t0[role] = time.time()
-
- # ------------------------------------------------
- # Se ativa não existe, cai para a primeira disponível.
- # ------------------------------------------------
- if selected_role not in specs or frames.get(selected_role) is None:
- available = [
- r for r in ROLES
- if r in specs and frames.get(r) is not None
- ]
-
- if available:
- selected_role = available[0]
-
- active_img = frames.get(selected_role)
-
- # ------------------------------------------------
- # Métrica
- # ------------------------------------------------
- metrics = None
- score = 0.0
- smooth = 0.0
- trend = {
- "status": "aguardando",
- "instruction": "aguardando frame",
- "pct_of_best": 0.0,
- }
-
- history = history_by_role_method[selected_role][method]
- best = best_by_role_method[selected_role][method]
-
- if active_img is not None:
- if roi_rects[selected_role] is None:
- roi_rects[selected_role] = default_roi_for_shape(
- active_img.shape[:2],
- frac=0.42,
- )
-
- metrics = compute_focus_metrics(
- active_img,
- roi_rects[selected_role],
- equalize=equalize,
- )
-
- if metrics.get("valid"):
- score = metric_value(metrics, method)
-
- history.append({
- "t": time.time(),
- "score": score,
- "smooth": score,
- })
-
- smooth = smooth_from_history(
- history,
- args.smooth_window,
- )
-
- history[-1]["smooth"] = smooth
-
- if smooth > float(best.get("smooth", 0.0) or 0.0):
- best.update({
- "score": float(score),
- "smooth": float(smooth),
- "metrics": metrics,
- "timestamp": now_str(),
- "roi": list(map(int, roi_rects[selected_role])),
- })
-
- trend = analyze_trend(
- history,
- float(best.get("smooth", 0.0) or 0.0),
- direction_names[direction_idx],
- drop_warn_pct=args.drop_warn_pct,
- )
-
- # ------------------------------------------------
- # Painéis
- # ------------------------------------------------
- ph = int(args.panel_height)
- pw = int(args.panel_width)
- target_hw = (ph, pw)
-
- panels = {}
-
- for role in ROLES:
- img = frames.get(role)
- spec = specs.get(role)
-
- if img is None:
- if spec is None:
- panels[role] = build_empty_panel(
- target_hw,
- role.upper(),
- "camera nao ativa neste modo",
- )
- else:
- panels[role] = build_empty_panel(
- target_hw,
- role.upper(),
- f"aguardando {spec.sensor_name}",
- )
- continue
-
- view = img.copy()
-
- if role in ("re", "nir"):
- view = colorize_mono_for_view(view, role)
-
- panel = resize_panel(view, target_hw)
-
- roi = roi_rects.get(role)
- if roi is not None:
- draw_roi(
- panel,
- roi,
- img.shape[:2],
- active=(role == selected_role),
- )
-
- draw_crosshair(panel)
-
- spec = specs[role]
- title = (
- f"{role.upper()} | {spec.socket_name} | "
- f"{spec.sensor_name} | {img.shape[1]}x{img.shape[0]}"
- )
-
- draw_panel_title(
- panel,
- title,
- selected=(role == selected_role),
- )
-
- panels[role] = panel
-
- active_spec = specs.get(selected_role)
-
- if active_spec is None:
- # Fallback apenas defensivo.
- active_spec = next(iter(specs.values()))
-
- data_panel = make_data_panel(
- target_hw,
- active_spec,
- selected_role,
- method,
- metrics or {},
- score,
- smooth,
- best,
- trend,
- fps_by_role,
- fps_view,
- direction_names[direction_idx],
- history,
- roi_locked,
- equalize,
- )
-
- # Layout:
- # RGB | RE
- # NIR | DATA
- panel_rects["rgb"] = (0, 0, pw, ph)
- panel_rects["re"] = (pw, 0, pw * 2, ph)
- panel_rects["nir"] = (0, ph, pw, ph * 2)
- panel_rects["data"] = (pw, ph, pw * 2, ph * 2)
-
- top = np.hstack([panels["rgb"], panels["re"]])
- bottom = np.hstack([panels["nir"], data_panel])
- board = np.vstack([top, bottom])
-
- if last_msg and (time.time() - last_msg_t) < 2.5:
- cv2.putText(
- board,
- last_msg,
- (16, board.shape[0] - 14),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.58,
- (0, 255, 0),
- 2,
- cv2.LINE_AA,
- )
-
- last_board = board.copy()
-
- cv2.imshow(window_name, board)
-
- # FPS da UI
- view_count += 1
- dt_view = time.time() - view_t0
-
- if dt_view >= 1.0:
- fps_view = view_count / dt_view
- view_count = 0
- view_t0 = time.time()
-
- # ------------------------------------------------
- # Teclas
- # ------------------------------------------------
- k = cv2.waitKey(1) & 0xFF
-
- if k in (ord("q"), ord("Q"), 27):
- break
-
- elif k == ord("1"):
- if "rgb" in specs:
- selected_role = "rgb"
- set_msg("Selecionada: RGB")
-
- elif k == ord("2"):
- if "re" in specs:
- selected_role = "re"
- set_msg("Selecionada: RE")
- else:
- set_msg("RE nao ativa neste modo")
-
- elif k == ord("3"):
- if "nir" in specs:
- selected_role = "nir"
- set_msg("Selecionada: NIR")
- else:
- set_msg("NIR nao ativa neste modo")
-
- elif k in (ord("m"), ord("M")):
- method = METHODS[(METHODS.index(method) + 1) % len(METHODS)]
- set_msg(f"Metrica -> {method}")
-
- elif k in (ord("d"), ord("D")):
- direction_idx = 1 - direction_idx
- set_msg(f"Sentido -> {direction_names[direction_idx]}")
-
- elif k in (ord("e"), ord("E")):
- equalize = not equalize
- set_msg(f"Equalize -> {'ON' if equalize else 'OFF'}")
-
- elif k in (ord("l"), ord("L")):
- roi_locked = not roi_locked
- set_msg(f"ROI -> {'travada' if roi_locked else 'editavel'}")
-
- elif k in (ord("c"), ord("C")):
- img = frames.get(selected_role)
-
- if img is not None:
- roi_rects[selected_role] = default_roi_for_shape(
- img.shape[:2],
- frac=0.42,
- )
- set_msg(f"ROI centralizada: {selected_role.upper()}")
-
- elif k in (ord("r"), ord("R")):
- history_by_role_method[selected_role][method].clear()
- best_by_role_method[selected_role][method] = empty_best()
-
- set_msg(
- f"Reset: {selected_role.upper()} / {method}"
- )
-
- elif k in (ord("s"), ord("S")):
- stamp = now_file_str()
-
- png_path = os.path.join(
- args.snapshot_dir,
- f"focus_{selected_role}_{method}_{stamp}.png",
- )
-
- if last_board is not None:
- cv2.imwrite(png_path, last_board)
-
- snap = {
- "timestamp": now_str(),
- "role": selected_role,
- "method": method,
- "sensor": asdict(specs[selected_role]),
- "roi": (
- list(map(int, roi_rects[selected_role]))
- if roi_rects[selected_role] is not None
- else None
- ),
- "current_score": float(score),
- "current_smooth": float(smooth),
- "best": json_safe_best(
- best_by_role_method[selected_role][method]
- ),
- "direction_name": direction_names[direction_idx],
- "equalize": bool(equalize),
- "png_path": png_path,
- }
-
- snapshots.append(snap)
- set_msg(f"Snapshot -> {png_path}")
-
- elif k == 32:
- payload = build_result_payload(
- args,
- camera_rows,
- specs,
- best_by_role_method,
- snapshots,
- actual_mx,
- usb_speed,
- )
-
- save_json(args.out_json, payload)
- set_msg(f"JSON salvo -> {args.out_json}")
-
- finally:
- cv2.destroyAllWindows()
-
- # Salva automaticamente no fechamento também.
- try:
- payload = build_result_payload(
- args,
- camera_rows,
- specs,
- best_by_role_method,
- snapshots,
- actual_mx,
- usb_speed,
- )
- save_json(args.out_json, payload)
- print(f"[OK] Resultado final salvo em: {args.out_json}")
- except Exception as exc:
- print(f"[WARN] Não foi possível salvar resultado final: {exc}")
-
- print("Fim da calibração de foco.")
-
-
-if __name__ == "__main__":
- main()
diff --git a/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py b/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py
deleted file mode 100644
index 25e88dc64..000000000
--- a/Python/OAK/datasets/oak-fcc-3/utils/radiometric_config_tool.py
+++ /dev/null
@@ -1,1617 +0,0 @@
-import os
-import json
-import time
-import argparse
-from datetime import datetime
-
-import cv2
-import numpy as np
-
-from core.oak_fcc3_client import OakFcc3Client as MultiSpectralClient
-
-
-# ============================================================
-# Helpers gerais
-# ============================================================
-
-def now_str() -> str:
- return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
-
-def ensure_dir(path: str):
- if path:
- os.makedirs(path, exist_ok=True)
-
-
-def clamp(v, lo, hi):
- return max(lo, min(hi, v))
-
-
-def overlay_hud(
- img_bgr,
- lines,
- x=12,
- y=24,
- font_scale=0.58,
- line_step=22,
- color=(255, 255, 255),
- shadow=(0, 0, 0),
-):
- yy = int(y)
- for s in lines:
- cv2.putText(img_bgr, str(s), (int(x), yy), cv2.FONT_HERSHEY_SIMPLEX,
- font_scale, shadow, 3, cv2.LINE_AA)
- cv2.putText(img_bgr, str(s), (int(x), yy), cv2.FONT_HERSHEY_SIMPLEX,
- font_scale, color, 1, cv2.LINE_AA)
- yy += int(line_step)
-
-
-def to_bgr_u8_from_rgb01(rgb01: np.ndarray) -> np.ndarray:
- rgb_u8 = np.clip(rgb01 * 255.0, 0, 255).astype(np.uint8)
- return cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR)
-
-
-def gray_to_bgr_u8(gray01: np.ndarray) -> np.ndarray:
- g = np.clip(gray01 * 255.0, 0, 255).astype(np.uint8)
- return cv2.cvtColor(g, cv2.COLOR_GRAY2BGR)
-
-
-def resize_if_needed(img: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray:
- if img is None:
- return None
- th, tw = target_hw
- if img.shape[:2] == (th, tw):
- return img
- return cv2.resize(img, (tw, th), interpolation=cv2.INTER_LINEAR)
-
-
-def compute_stats(img01: np.ndarray, roi_px=None) -> dict:
- if img01 is None:
- 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,
- }
-
- if img01.ndim == 3:
- arr = (
- 0.299 * img01[:, :, 0] +
- 0.587 * img01[:, :, 1] +
- 0.114 * img01[:, :, 2]
- ).astype(np.float32)
- else:
- arr = img01.astype(np.float32)
-
- if roi_px is not None:
- x0, y0, x1, y1 = roi_px
- x0, x1 = sorted((int(x0), int(x1)))
- y0, y1 = sorted((int(y0), int(y1)))
- x0 = clamp(x0, 0, arr.shape[1] - 1)
- x1 = clamp(x1, x0 + 1, arr.shape[1])
- y0 = clamp(y0, 0, arr.shape[0] - 1)
- y1 = clamp(y1, y0 + 1, arr.shape[0])
- arr = arr[y0:y1, x0:x1]
-
- flat = arr.reshape(-1).astype(np.float32)
- if flat.size == 0:
- 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,
- }
-
- return {
- "valid": True,
- "pixels": int(flat.size),
- "mean": float(flat.mean()),
- "std": float(flat.std()),
- "p05": float(np.percentile(flat, 5)),
- "p50": float(np.percentile(flat, 50)),
- "p95": float(np.percentile(flat, 95)),
- "sat_pct": float((flat >= 0.98).mean() * 100.0),
- "dark_pct": float((flat <= 0.02).mean() * 100.0),
- }
-
-
-def pct_to_px(roi_pct: dict, w: int, h: int):
- x0 = int(float(roi_pct.get("x0", 0.0)) * w)
- y0 = int(float(roi_pct.get("y0", 0.0)) * h)
- x1 = int(float(roi_pct.get("x1", 1.0)) * w)
- y1 = int(float(roi_pct.get("y1", 1.0)) * h)
-
- x0 = clamp(x0, 0, w - 1)
- x1 = clamp(x1, x0 + 1, w)
- y0 = clamp(y0, 0, h - 1)
- y1 = clamp(y1, y0 + 1, h)
-
- return x0, y0, x1, y1
-
-
-def px_to_pct(rect, w: int, h: int):
- x0, y0, x1, y1 = rect
- x0, x1 = sorted((int(x0), int(x1)))
- y0, y1 = sorted((int(y0), int(y1)))
-
- x0 = clamp(x0, 0, w - 1)
- x1 = clamp(x1, x0 + 1, w)
- y0 = clamp(y0, 0, h - 1)
- y1 = clamp(y1, y0 + 1, h)
-
- return {
- "x0": round(x0 / float(w), 6),
- "y0": round(y0 / float(h), 6),
- "x1": round(x1 / float(w), 6),
- "y1": round(y1 / float(h), 6),
- }
-
-
-def get_decoded_by_role(decoded: dict, role: str):
- role = str(role).lower()
- for cam_id, item in (decoded or {}).items():
- if str(item.get("role", "")).lower() == role:
- return cam_id, item
- return None, None
-
-
-def get_image_by_role(decoded: dict, role: str):
- cam_id, item = get_decoded_by_role(decoded, role)
- if item is None:
- return cam_id, None
- return cam_id, item.get("image")
-
-
-def get_visual_preview_by_role(visual_previews: dict, meta: dict, role: str):
- """
- Busca uma imagem visual BGR dentro do retorno de cam.build_visual_preview_from_raw(),
- usando camera_info para descobrir o role rgb/re/nir.
-
- Retorna: cam_id, img_bgr
- """
- if not visual_previews:
- return None, None
-
- camera_info = (meta or {}).get("camera_info", {}) or {}
- role = str(role).lower()
-
- for cam_id, img in visual_previews.items():
- cam_role = str(camera_info.get(cam_id, {}).get("role", "")).lower()
- if cam_role == role:
- return cam_id, img
-
- return None, None
-
-
-def validate_module_ready(status: dict, raw_policy: str):
- if not status.get("ok", True):
- raise RuntimeError(f"Status inválido retornado pelo modulo: {status}")
-
- active_roles = status.get("active_roles", {}) or {}
- active_count = int(status.get("camera_count_active", 0))
-
- if raw_policy == "require_triple":
- missing = [role for role in ("rgb", "nir", "re") if role not in active_roles]
- if missing:
- raise RuntimeError(
- f"RAW_BRUTO com require_triple exige rgb/nir/re ativas. "
- f"Faltando: {missing}. Ativas: {active_roles}"
- )
- elif active_count < 1:
- raise RuntimeError("RAW_BRUTO requer ao menos uma câmera ativa.")
-
-
-# ============================================================
-# 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():
- 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}
- 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": 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_guard_roi_pct": guard_base,
- })
- 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,
- "roi_pct": {},
- "roi_pct_by_role": {"rgb": {}, "re": {}, "nir": {}},
- "roi_list_by_role": {"rgb": [], "re": [], "nir": []},
- }
-
-
-def default_profile_patches():
- 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"))
- if name not in ("global_scene_mode", "three_reference_patches_mode"):
- return "global_scene_mode"
- return name
-
-
-def set_active_profile_name(data: dict, profile_name: str):
- if profile_name not in ("global_scene_mode", "three_reference_patches_mode"):
- profile_name = "global_scene_mode"
- data["active_profile"] = profile_name
-
-
-def get_active_radiometric_config(data: dict) -> dict:
- profile_name = get_active_profile_name(data)
- profile = data.get(profile_name, {}) or {}
- cfg = profile.get("radiometric_config", {}) or {}
- return json.loads(json.dumps(cfg))
-
-
-def update_root_radiometric_config(data: dict):
- data["radiometric_config"] = get_active_radiometric_config(data)
-
-
-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 = {}
-
- module_contract = is_module_params_contract(data)
- root_cfg = data.get("radiometric_config") if isinstance(data.get("radiometric_config"), dict) else None
-
- 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),
- ):
- default_profile = default_fn()
- data.setdefault(profile_name, default_profile)
- data[profile_name].setdefault("radiometric_config", {})
-
- default_cfg = default_profile["radiometric_config"]
- cfg = data[profile_name]["radiometric_config"]
-
- for k, v in default_cfg.items():
- 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 ".")
- 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()
-
- 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(out, f, ensure_ascii=False, indent=2)
-
-
-
-ROLES = ["rgb", "re", "nir"]
-
-
-def normalize_role(role: str) -> str:
- role = str(role or "rgb").lower()
- return role if role in ROLES else "rgb"
-
-
-def default_roi():
- return {"x0": 0.08, "y0": 0.08, "x1": 0.92, "y1": 0.92}
-
-
-def clone_roi(roi: dict) -> dict:
- roi = roi or {}
- return {
- "x0": float(roi.get("x0", 0.08)),
- "y0": float(roi.get("y0", 0.08)),
- "x1": float(roi.get("x1", 0.92)),
- "y1": float(roi.get("y1", 0.92)),
- }
-
-
-def make_roi_by_role(base_roi=None):
- base = clone_roi(base_roi or default_roi())
- return {role: dict(base) for role in ROLES}
-
-
-def ensure_global_roi_by_role(data: dict):
- data.setdefault("global_scene_mode", default_profile_global())
- cfg = data["global_scene_mode"].setdefault(
- "radiometric_config",
- default_profile_global()["radiometric_config"],
- )
-
- legacy = cfg.get("global_roi_pct", default_roi())
- by_role = cfg.setdefault("global_roi_pct_by_role", make_roi_by_role(legacy))
-
- for role in ROLES:
- if role not in by_role or not by_role[role]:
- by_role[role] = clone_roi(legacy)
-
- return by_role
-
-
-def get_global_roi_for_role(data: dict, role: str):
- role = normalize_role(role)
- by_role = ensure_global_roi_by_role(data)
- return by_role.get(role, clone_roi(default_roi()))
-
-
-def set_global_roi_for_role(data: dict, role: str, roi_pct: dict):
- role = normalize_role(role)
- by_role = ensure_global_roi_by_role(data)
- by_role[role] = roi_pct
-
- # Compatibilidade: mantém uma ROI antiga preenchida.
- # Uso: média/legado/visual antigo. O controller novo deverá usar by_role.
- data["global_scene_mode"]["radiometric_config"]["global_roi_pct"] = by_role.get("rgb", roi_pct)
-
-
-def get_patches(data: dict):
- return (
- data.get("three_reference_patches_mode", {})
- .get("radiometric_config", {})
- .get("reference_patches", [])
- )
-
-
-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:
- 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 {}
-
- 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 já 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 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",
- default_profile_patches()["radiometric_config"],
- )
-
- patches = cfg.setdefault(
- "reference_patches",
- default_profile_patches()["radiometric_config"]["reference_patches"],
- )
-
- patch_type = str(patch_type).lower()
- 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)
-
- for p in patches:
- if str(p.get("type", "")).lower() == patch_type:
- ensure_patch_roi_lists_by_role(p)
- return p
-
- patch = make_default_patch(patch_type, target, weight)
- patches.append(patch)
- ensure_patch_roi_lists_by_role(patch)
- return patch
-
-
-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())
- cfg = data[profile].setdefault("radiometric_config", {})
- cfg["spectral_control_mode"] = "shared" if shared else "independent"
- cfg["apply_same_spectral_to_both"] = bool(shared)
-
- update_root_radiometric_config(data)
-
-
-# ============================================================
-# UI
-# ============================================================
-
-PATCH_COLORS = {
- "global": (0, 255, 255),
- "black": (80, 80, 80),
- "gray": (180, 180, 180),
- "white": (255, 255, 255),
-}
-
-PATCH_ORDER = ["black", "gray", "white"]
-
-
-def draw_roi_on_panel(panel, roi_pct, label, color, thickness=2):
- if roi_pct is None:
- return
- h, w = panel.shape[:2]
- x0, y0, x1, y1 = pct_to_px(roi_pct, w, h)
-
- cv2.rectangle(panel, (x0, y0), (x1, y1), color, thickness)
- cv2.putText(panel, label, (x0 + 5, max(20, y0 - 6)), cv2.FONT_HERSHEY_SIMPLEX,
- 0.55, (0, 0, 0), 3, cv2.LINE_AA)
- cv2.putText(panel, label, (x0 + 5, max(20, y0 - 6)), cv2.FONT_HERSHEY_SIMPLEX,
- 0.55, color, 1, cv2.LINE_AA)
-
-
-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)
-
- is_edit_panel = panel_role == edit_role
-
- if mode == "global":
- roi = get_global_roi_for_role(data, panel_role)
- label = f"GLOBAL/{panel_role.upper()}"
- thickness = 3 if is_edit_panel else 2
- draw_roi_on_panel(panel, roi, label, PATCH_COLORS["global"], thickness)
-
- else:
- for p in get_patches(data):
- typ = str(p.get("type", "")).lower()
- color = PATCH_COLORS.get(typ, (0, 255, 255))
- entries = get_patch_roi_entries_for_role(data, typ, panel_role, enabled_only=False)
-
- 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)
-
-
-def build_board(
- decoded,
- data,
- mode,
- selected_target,
- edit_role,
- selected_roi_index,
- drag_rect_local,
- drag_role,
- panel_rects,
- preview_scale=1.0,
- visual_previews=None,
- meta=None,
- beauty_preview=True,
-):
- rgb_id, rgb01 = get_image_by_role(decoded, "rgb")
- re_id, re01 = get_image_by_role(decoded, "re")
- nir_id, nir01 = get_image_by_role(decoded, "nir")
-
- # ------------------------------------------------------------
- # Tamanho base SEMPRE vem do decoded, porque ROI/stats usam dado real.
- # O preview visual é só para desenhar bonito.
- # ------------------------------------------------------------
- if rgb01 is not None:
- base_h, base_w = rgb01.shape[:2]
- elif re01 is not None:
- base_h, base_w = re01.shape[:2]
- elif nir01 is not None:
- base_h, base_w = nir01.shape[:2]
- else:
- base_h, base_w = 800, 1280
-
- # ------------------------------------------------------------
- # Preview bonito, igual ao capture.
- # ------------------------------------------------------------
- rgb_vis_id, rgb_vis = get_visual_preview_by_role(visual_previews, meta, "rgb")
- re_vis_id, re_vis = get_visual_preview_by_role(visual_previews, meta, "re")
- nir_vis_id, nir_vis = get_visual_preview_by_role(visual_previews, meta, "nir")
-
- if beauty_preview and rgb_vis is not None:
- rgb_panel = rgb_vis.copy()
- if rgb_panel.shape[:2] != (base_h, base_w):
- rgb_panel = cv2.resize(rgb_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
- rgb_id = rgb_vis_id
- else:
- if rgb01 is not None:
- rgb_panel = to_bgr_u8_from_rgb01(rgb01)
- else:
- rgb_panel = np.zeros((base_h, base_w, 3), dtype=np.uint8)
- overlay_hud(rgb_panel, ["RGB", "sem frame"])
-
- if beauty_preview and re_vis is not None:
- re_panel = re_vis.copy()
- if re_panel.shape[:2] != (base_h, base_w):
- re_panel = cv2.resize(re_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
- re_id = re_vis_id
- else:
- re01_show = resize_if_needed(re01, (base_h, base_w)) if re01 is not None else None
- re_panel = gray_to_bgr_u8(re01_show) if re01_show is not None else np.zeros_like(rgb_panel)
-
- if beauty_preview and nir_vis is not None:
- nir_panel = nir_vis.copy()
- if nir_panel.shape[:2] != (base_h, base_w):
- nir_panel = cv2.resize(nir_panel, (base_w, base_h), interpolation=cv2.INTER_LINEAR)
- nir_id = nir_vis_id
- else:
- 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, 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
- color = PATCH_COLORS["global"] if mode == "global" else PATCH_COLORS.get(selected_target, (0, 255, 255))
-
- if drag_role == "rgb":
- cv2.rectangle(rgb_panel, (x0, y0), (x1, y1), color, 1)
- elif drag_role == "re":
- cv2.rectangle(re_panel, (x0, y0), (x1, y1), color, 1)
- elif drag_role == "nir":
- cv2.rectangle(nir_panel, (x0, y0), (x1, y1), color, 1)
-
- overlay_hud(rgb_panel, [f"RGB ({rgb_id})"], y=24)
- overlay_hud(re_panel, [f"RE ({re_id})"], y=24)
- overlay_hud(nir_panel, [f"NIR ({nir_id})"], y=24)
-
- ph = max(rgb_panel.shape[0], re_panel.shape[0], nir_panel.shape[0])
- pw = max(rgb_panel.shape[1], re_panel.shape[1], nir_panel.shape[1])
-
- def fit_panel(img):
- if img.shape[:2] != (ph, pw):
- return cv2.resize(img, (pw, ph), interpolation=cv2.INTER_NEAREST)
- return img
-
- rgb_panel = fit_panel(rgb_panel)
- re_panel = fit_panel(re_panel)
- nir_panel = fit_panel(nir_panel)
-
- data_panel = np.zeros((ph, pw, 3), dtype=np.uint8)
-
- panel_rects["rgb"] = (0, 0, pw, ph)
- panel_rects["re"] = (pw, 0, pw * 2, ph)
- panel_rects["nir"] = (0, ph, pw, ph * 2)
- panel_rects["data"] = (pw, ph, pw * 2, ph * 2)
-
- top = np.hstack([rgb_panel, re_panel])
- bottom = np.hstack([nir_panel, data_panel])
- board = np.vstack([top, bottom])
-
- x0, y0, x1, y1 = panel_rects["data"]
- 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(
- board,
- (int(board.shape[1] * preview_scale), int(board.shape[0] * preview_scale)),
- interpolation=cv2.INTER_NEAREST,
- )
-
- return board
-
-
-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={mode.upper()} | camera={edit_role.upper()} | active={active_profile}",
- f"spectral={active_cfg.get('spectral_control_mode')} | strategy={active_cfg.get('control_strategy')}",
- f"roi_contract={active_cfg.get('patch_roi_contract', 'legacy_single_roi')}",
- "",
- "Arraste no painel da camera editada para definir/atualizar ROI.",
- "PATCHES agora suportam N ROIs por cor e por camera.",
- "",
- ]
-
- if mode == "global":
- lines.append("GLOBAL ROI por camera:")
- for role in ROLES:
- roi_pct = get_global_roi_for_role(data, role)
- marker = "*" if role == edit_role else " "
- lines.append(f"{marker} {role.upper()}: roi={roi_pct}")
-
- lines.append("")
- lines.append("Stats GLOBAL:")
- 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(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:
- 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()}: {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(
- 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 robustas {selected_target.upper()}:")
- lines.extend(stats_lines_for_mode(data, decoded, mode="patches", patch_type=selected_target))
-
- lines.extend([
- "",
- "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 = []
-
- for role in ROLES:
- _, img = get_image_by_role(decoded, role)
- if img is 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)
- if not roi_pct:
- lines.append(f"{role.upper()}: sem ROI")
- continue
- 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
- x0, y0, x1, y1 = rect
- return x0 <= x < x1 and y0 <= y < y1
-
-
-def local_from_rect(rect, x, y):
- x0, y0, _, _ = rect
- return int(x - x0), int(y - y0)
-
-
-# ============================================================
-# Main
-# ============================================================
-
-def main():
- parser = argparse.ArgumentParser(
- description="Ferramenta visual para parametrizar o radiometric_config global ou por 3 patches.",
- formatter_class=argparse.ArgumentDefaultsHelpFormatter,
- )
-
- parser.add_argument("--fps", type=int, default=20)
- parser.add_argument("--width", type=int, default=1280)
- parser.add_argument("--height", type=int, default=800)
- parser.add_argument("--bayer", default="RGGB", choices=["GBRG", "GRBG", "RGGB", "BGGR"])
- parser.add_argument("--capture_mode", default="AUTO", choices=["AUTO", "SINGLE", "DOUBLE", "TRIPLE"])
- parser.add_argument("--raw_policy", default="allow_single", choices=["allow_single", "require_triple"])
- parser.add_argument("--module_calibration_json", default="calibration/module_params.json")
- parser.add_argument("--out_json", default="calibration/radiometric_config.json")
- parser.add_argument("--load_json", default="")
- parser.add_argument("--preview_scale", type=float, default=0.75)
-
- args = parser.parse_args()
-
- config_path = args.load_json or args.out_json
- data = load_or_default_config(config_path)
-
- mode = "global"
- selected_target = "gray"
- selected_roi_index = 0
- edit_role = "rgb"
- drag_role = None
- beauty_preview = True
- visual_previews_last = {}
- raw_meta_last = {}
-
- panel_rects = {"rgb": None, "re": None, "nir": None, "data": None}
- dragging = False
- drag_start = None
- drag_rect_local = None
-
- last_msg = ""
- last_msg_t = 0.0
- last_frame_id = -1
- decoded_last = {}
-
- 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, selected_roi_index
-
- # Coordenadas vêm depois do preview_scale. Reescala para board real.
- if args.preview_scale != 1.0:
- x = int(x / args.preview_scale)
- y = int(y / args.preview_scale)
-
- edit_rect = panel_rects.get(edit_role)
- if not rect_inside(edit_rect, x, y):
- return
-
- lx, ly = local_from_rect(edit_rect, x, y)
-
- if event == cv2.EVENT_LBUTTONDOWN:
- dragging = True
- drag_role = edit_role
- drag_start = (lx, ly)
- drag_rect_local = (lx, ly, lx + 1, ly + 1)
-
- elif event == cv2.EVENT_MOUSEMOVE and dragging:
- sx, sy = drag_start
- drag_rect_local = (sx, sy, lx, ly)
-
- elif event == cv2.EVENT_LBUTTONUP and dragging:
- dragging = False
- sx, sy = drag_start
- rect = (sx, sy, lx, ly)
- drag_rect_local = None
-
- # Descobre tamanho local do painel da camera editada.
- edit_rect = panel_rects.get(drag_role or edit_role)
- if edit_rect is None:
- return
-
- _, _, x1, y1 = edit_rect
- x0r, y0r, _, _ = edit_rect
- w = x1 - x0r
- h = y1 - y0r
-
- roi_pct = px_to_pct(rect, w, h)
-
- role_to_save = normalize_role(drag_role or edit_role)
-
- if mode == "global":
- set_global_roi_for_role(data, role_to_save, roi_pct)
- last_msg = f"GLOBAL ROI {role_to_save.upper()} atualizada: {roi_pct}"
- else:
- 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()
-
- cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
- cv2.setMouseCallback(window_name, on_mouse)
-
- try:
- with MultiSpectralClient(
- width=args.width,
- height=args.height,
- bayer=args.bayer,
- fps=args.fps,
- frame_type="RAW_BRUTO",
- output_dtype="uint8",
- capture_mode=args.capture_mode,
- raw_policy=args.raw_policy,
- module_calibration_json=args.module_calibration_json
- ) as cam:
-
- validate_module_ready(cam.get_status(), args.raw_policy)
-
- while True:
- raw_frame, raw_meta, decoded = cam.get_next_decoded(timeout=2.0)
- visual_previews = {}
-
- try:
- if isinstance(raw_frame, dict):
- visual_previews = cam.build_visual_preview_from_raw(raw_frame, raw_meta)
- except Exception as e:
- visual_previews = {}
- print(f"[WARN] Falha ao gerar beauty preview: {e}")
-
- if raw_meta is not None and raw_meta.get("frame_id") != last_frame_id:
- last_frame_id = raw_meta.get("frame_id")
- decoded_last = decoded
- visual_previews_last = visual_previews
- raw_meta_last = raw_meta
-
- if decoded_last:
- board = build_board(
- decoded=decoded_last,
- data=data,
- 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,
- preview_scale=args.preview_scale,
- visual_previews=visual_previews_last,
- meta=raw_meta_last,
- beauty_preview=beauty_preview,
- )
-
- if last_msg and (time.time() - last_msg_t) < 2.5:
- cv2.putText(board, last_msg, (18, board.shape[0] - 20),
- cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 0), 2, cv2.LINE_AA)
-
- cv2.imshow(window_name, board)
- else:
- blank = np.zeros((720, 1280, 3), dtype=np.uint8)
- overlay_hud(blank, ["Aguardando frames..."], x=40, y=80, font_scale=1.0)
- cv2.imshow(window_name, blank)
-
- k = cv2.waitKey(1) & 0xFF
-
- if k in (ord("q"), ord("Q"), 27):
- break
-
- elif k in (ord("m"), ord("M")):
- mode = "patches" if mode == "global" else "global"
-
- if mode == "global":
- set_active_profile_name(data, "global_scene_mode")
- else:
- set_active_profile_name(data, "three_reference_patches_mode")
-
- 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"
- last_msg_t = time.time()
-
- 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"
- last_msg_t = time.time()
-
- 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"
- last_msg_t = time.time()
-
- elif k in (ord("s"), ord("S")):
- cfg = data.get("global_scene_mode", {}).get("radiometric_config", {})
- curr = str(cfg.get("spectral_control_mode", "shared")).lower()
- set_shared_mode(data, shared=(curr != "shared"))
- new_mode = (
- data.get("global_scene_mode", {})
- .get("radiometric_config", {})
- .get("spectral_control_mode", "shared")
- )
- last_msg = f"spectral_control_mode -> {new_mode}"
- last_msg_t = time.time()
-
- elif k in (ord("r"), ord("R")):
- # 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()
-
- elif k in (ord("p"), ord("P"), 32):
- save_config(args.out_json, data)
- last_msg = f"Salvo em: {args.out_json}"
- last_msg_t = time.time()
- print(f"[OK] radiometric config salvo em: {args.out_json}")
-
- 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}"
- last_msg_t = time.time()
-
- finally:
- cv2.destroyAllWindows()
- print("Fim da parametrizacao radiometrica.")
-
-
-if __name__ == "__main__":
- main()