import os import json import time import argparse from datetime import datetime from collections import deque 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 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 if shadow: cv2.putText(img_bgr, str(s), (x, yy), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), 3, cv2.LINE_AA) cv2.putText(img_bgr, str(s), (x, yy), cv2.FONT_HERSHEY_SIMPLEX, font_scale, color, 1, cv2.LINE_AA) yy += 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 gray_to_color_bgr(gray01: np.ndarray, color_name: str) -> np.ndarray: g = np.clip(gray01 * 255.0, 0, 255).astype(np.uint8) z = np.zeros_like(g, dtype=np.uint8) color_name = str(color_name).upper() if color_name == "RE": rgb = np.stack([g, z, z], axis=2) elif color_name == "NIR": rgb = np.stack([z, g, g], axis=2) else: rgb = np.stack([g, g, g], axis=2) return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) def resize_if_needed(img: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray: if img is None: return None target_h, target_w = target_hw if img.shape[:2] == (target_h, target_w): return img return cv2.resize(img, (target_w, target_h), interpolation=cv2.INTER_LINEAR) def build_empty_panel(shape_hw: tuple[int, int], title: str) -> np.ndarray: h, w = shape_hw img = np.zeros((h, w, 3), dtype=np.uint8) overlay_hud(img, [title, "sem frame disponivel"], x=18, y=44, font_scale=0.8, line_step=32) return img def get_decoded_by_role(decoded: dict, role: str): role = str(role).lower() for cam_id, item in decoded.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_preview_panel_by_role(previews: dict, meta: dict, role: str): camera_info = (meta or {}).get("camera_info", {}) or {} for cam_id, preview in (previews or {}).items(): info = camera_info.get(cam_id, {}) or {} if str(info.get("role", "")).lower() == role: return preview return None def validate_module_ready(status: dict, frame_type: str, raw_policy: str): if not status.get("ok", True): raise RuntimeError(f"Status inválido retornado pelo módulo: {status}") active_roles = status.get("active_roles", {}) or {} active_count = int(status.get("camera_count_active", 0)) if frame_type == "RAW_BRUTO": 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.") return raise RuntimeError(f"frame_type desconhecido para validação: {frame_type}") def normalize_gray01(img01: np.ndarray) -> np.ndarray: """ Converte RGB/mono float 0..1 para mono float 0..1. Para foco, o objetivo é medir borda, então usamos luminância no RGB. """ if img01 is None: return None arr = img01.astype(np.float32) if arr.ndim == 3: # img01 vem em RGB, não BGR. r = arr[:, :, 0] g = arr[:, :, 1] b = arr[:, :, 2] gray = 0.299 * r + 0.587 * g + 0.114 * b else: gray = arr gray = np.nan_to_num(gray, nan=0.0, posinf=1.0, neginf=0.0) return np.clip(gray, 0.0, 1.0) def crop_rect(img: np.ndarray, rect): if img is None: return None h, w = img.shape[:2] 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(0, min(w, x1)) y0 = max(0, min(h - 1, y0)) y1 = max(0, min(h, y1)) if x1 <= x0 or y1 <= y0: return None return img[y0:y1, x0:x1] def default_roi_for_shape(shape_hw, frac=0.42): h, w = shape_hw rw = int(w * frac) rh = int(h * frac) x0 = (w - rw) // 2 y0 = (h - rh) // 2 return (x0, y0, x0 + rw, y0 + rh) # ============================================================ # Métricas de foco # ============================================================ def preprocess_focus_gray(gray01: np.ndarray, equalize=False, blur_ksize=0) -> np.ndarray: g = np.clip(gray01 * 255.0, 0, 255).astype(np.uint8) if equalize: g = cv2.equalizeHist(g) if blur_ksize and blur_ksize >= 3: if blur_ksize % 2 == 0: blur_ksize += 1 g = cv2.GaussianBlur(g, (blur_ksize, blur_ksize), 0) 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) mag2 = sx * sx + sy * sy return float(np.mean(mag2)) 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(img01: np.ndarray, roi_rect, equalize=False) -> dict: gray01 = normalize_gray01(img01) 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_score(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) -> dict: 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:]] old = [float(x["smooth"]) for x in list(history)[-12:-7]] if len(history) >= 12 else [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 no sentido contrario de {direction_name}", "delta": delta, "pct_of_best": pct_of_best, } # Faixa morta para evitar feedback nervoso. 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 o sentido: contrario de {direction_name}", "delta": delta, "pct_of_best": pct_of_best, } return { "status": "estavel", "instruction": "ajuste bem fino ou trave a lente", "delta": delta, "pct_of_best": pct_of_best, } # ============================================================ # Desenho # ============================================================ def draw_roi(panel: np.ndarray, rect, active=False): if rect is None: return x0, y0, x1, y1 = map(int, rect) color = (0, 255, 255) if active else (0, 180, 255) cv2.rectangle(panel, (x0, y0), (x1, y1), color, 2) cv2.putText(panel, "FOCUS ROI", (x0 + 6, max(20, y0 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA) def draw_crosshair(panel: np.ndarray): 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_score_bar(panel: np.ndarray, pct: float, x: int, y: int, w: int, h: int, label: str): 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.55, (255, 255, 255), 1, cv2.LINE_AA) def draw_history_graph(panel: np.ndarray, history, x: int, y: int, w: int, h: int, best_score: float): 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.55, (180, 180, 180), 1, cv2.LINE_AA) return vals = np.array([float(item["smooth"]) for item in history], dtype=np.float32) vals = vals[-w:] # no máximo um ponto por pixel horizontal 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, 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 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.45, (0, 255, 255), 1, cv2.LINE_AA) def make_data_panel( shape_hw, selected_role, method, metrics, score, smooth, best_score, best_pct, trend, fps_stream, fps_view, direction_name, history, roi_rect, roi_locked, equalize, ): h, w = shape_hw panel = np.zeros((h, w, 3), dtype=np.uint8) score_pct = 0.0 if best_score <= 0 else (smooth / best_score) * 100.0 score_pct = max(0.0, min(120.0, score_pct)) status = trend.get("status", "coletando") instruction = trend.get("instruction", "gire devagar") lines = [ "FOCUS CALIBRATION TOOL", f"camera ativa: {selected_role.upper()} | metodo={method}", f"score={score:.1f} | smooth={smooth:.1f}", f"best={best_score:.1f} | atual/best={score_pct:.1f}%", f"status={status}", f"acao: {instruction}", f"sentido atual: {direction_name}", f"fps_stream={fps_stream:.1f} | 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}% pixels={metrics['pixels']}", ]) overlay_hud(panel, lines, x=14, y=28, font_scale=0.58, line_step=23) bar_y = min(h - 170, 310) draw_score_bar(panel, min(100.0, score_pct), 18, bar_y, max(80, w - 36), 24, "nitidez relativa") graph_y = bar_y + 52 graph_h = max(90, h - graph_y - 78) 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 troca metrica | D troca sentido", "mouse arrasta ROI | C centraliza ROI | L trava ROI | E equalize", "R reset score | S snapshot JSON | SPACE salva resultado | Q sai", ] overlay_hud(panel, help_lines, x=14, y=h - 56, font_scale=0.48, line_step=18) return panel def fit_panel(img, target_hw): th, tw = target_hw if img.shape[:2] == (th, tw): return img return cv2.resize(img, (tw, th), interpolation=cv2.INTER_NEAREST) 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.65, line_step=24, color=color) # ============================================================ # Persistência # ============================================================ def build_result_payload(args, results_by_role, snapshots): return { "schema": "multispec_focus_calibration_v1", "saved_at": now_str(), "frame_type": "RAW_BRUTO", "capture_mode_requested": args.capture_mode, "raw_policy": args.raw_policy, "sensor_width": args.width, "sensor_height": args.height, "bayer_pattern": args.bayer, "focus_method_default": args.method, "notes": args.notes or "", "results_by_role": results_by_role, "snapshots": snapshots, } def save_json(path, payload): ensure_dir(os.path.dirname(path) or ".") payload = dict(payload) payload["saved_at"] = now_str() 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="Ferramenta de auxílio para foco manual das câmeras RGB/RE/NIR do módulo multiespectral.", 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="allow_single", choices=["allow_single", "require_triple"]) parser.add_argument("--preview_scale", type=float, default=1.0) parser.add_argument("--module_calibration_json", default="calibration/module_params.json") parser.add_argument("--out_json", default="calibration/focus_calibration.json") parser.add_argument("--method", default="laplacian", choices=["laplacian", "tenengrad", "brenner"]) 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", help="Equaliza histograma da ROI antes de medir foco") parser.add_argument("--only_camera", default=None, choices=["CAM_A", "CAM_B", "CAM_C"]) parser.add_argument("--notes", default="") args = parser.parse_args() selected_role = "rgb" method = args.method equalize = bool(args.equalize) direction_idx = 0 direction_names = ["rosqueando", "desrosqueando"] decoded_last = {} previews_last = {} meta_last = None last_frame_id = -1 fps_view = 0.0 fps_stream = 0.0 t_view_fps = time.time() t_stream_fps = time.time() view_frames = 0 stream_frames_accum = 0 last_stream_frame_id = None panel_rects = {"rgb": None, "re": None, "nir": None, "data": None} roi_rects = {"rgb": None, "re": None, "nir": None} roi_locked = False dragging_roi = False drag_start = None history_by_role = {role: deque(maxlen=args.history) for role in ("rgb", "re", "nir")} best_by_role = { role: {"score": 0.0, "smooth": 0.0, "metrics": None, "timestamp": None, "roi": None, "method": method} for role in ("rgb", "re", "nir") } snapshots = [] last_msg = "" last_msg_t = 0.0 window_name = "Focus Calibration Tool - Multispec" cv2.namedWindow(window_name, cv2.WINDOW_NORMAL) 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 local_from_rect(rect, px, py): x0, y0, _, _ = rect return int(px - x0), int(py - y0) def on_mouse(event, x, y, flags, param): nonlocal dragging_roi, drag_start, last_msg, last_msg_t if roi_locked: return active_rect = panel_rects.get(selected_role) if active_rect is None or not inside(active_rect, x, y): return lx, ly = local_from_rect(active_rect, x, y) if event == cv2.EVENT_LBUTTONDOWN: dragging_roi = True drag_start = (lx, ly) roi_rects[selected_role] = (lx, ly, lx + 1, ly + 1) elif event == cv2.EVENT_MOUSEMOVE and dragging_roi and drag_start is not None: x0, y0 = drag_start roi_rects[selected_role] = (x0, y0, lx, ly) elif event == cv2.EVENT_LBUTTONUP and dragging_roi and drag_start is not None: x0, y0 = drag_start roi_rects[selected_role] = (x0, y0, lx, ly) dragging_roi = False drag_start = None last_msg = f"ROI atualizada para {selected_role.upper()}" last_msg_t = time.time() 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, only_camera=args.only_camera, ) as cam: validate_module_ready(cam.get_status(), "RAW_BRUTO", args.raw_policy) while True: t0 = time.time() frame, meta, decoded = cam.get_next_decoded(timeout=2.0) if frame is not None and meta is not None: try: previews_last = cam.build_visual_preview_from_raw(frame, meta) except Exception: previews_last = {} if meta is not None and frame is not None and meta.get("frame_id") != last_frame_id: last_frame_id = meta["frame_id"] if not isinstance(frame, dict): raise RuntimeError("Este calibrador espera RAW_BRUTO multi-payload como dict de câmeras.") decoded_last = decoded meta_last = meta curr_frame_id = meta.get("frame_id") if curr_frame_id is not None and last_stream_frame_id != curr_frame_id: stream_frames_accum += 1 last_stream_frame_id = curr_frame_id dt_stream = time.time() - t_stream_fps if dt_stream >= 1.0: fps_stream = stream_frames_accum / dt_stream stream_frames_accum = 0 t_stream_fps = time.time() view_frames += 1 dt_view = time.time() - t_view_fps if dt_view >= 1.0: fps_view = view_frames / dt_view view_frames = 0 t_view_fps = time.time() if decoded_last: rgb_id, rgb01 = get_image_by_role(decoded_last, "rgb") re_id, re01 = get_image_by_role(decoded_last, "re") nir_id, nir01 = get_image_by_role(decoded_last, "nir") # Base de escala visual. 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 = args.height, args.width for role in ("rgb", "re", "nir"): if roi_rects[role] is None: roi_rects[role] = default_roi_for_shape((base_h, base_w), frac=0.42) re01 = resize_if_needed(re01, (base_h, base_w)) nir01 = resize_if_needed(nir01, (base_h, base_w)) rgb_panel = get_preview_panel_by_role(previews_last, meta_last, "rgb") re_panel = get_preview_panel_by_role(previews_last, meta_last, "re") nir_panel = get_preview_panel_by_role(previews_last, meta_last, "nir") if rgb_panel is None: rgb_panel = to_bgr_u8_from_rgb01(rgb01) if rgb01 is not None else build_empty_panel((base_h, base_w), "RGB") if re_panel is None: re_panel = gray_to_color_bgr(re01, "RE") if re01 is not None else build_empty_panel((base_h, base_w), "RE") if nir_panel is None: nir_panel = gray_to_color_bgr(nir01, "NIR") if nir01 is not None else build_empty_panel((base_h, base_w), "NIR") # Garante que painéis e ROIs estão na mesma resolução visual. ph = max(rgb_panel.shape[0], re_panel.shape[0], nir_panel.shape[0], base_h) pw = max(rgb_panel.shape[1], re_panel.shape[1], nir_panel.shape[1], base_w) rgb_panel = fit_panel(rgb_panel, (ph, pw)) re_panel = fit_panel(re_panel, (ph, pw)) nir_panel = fit_panel(nir_panel, (ph, pw)) # Se a resolução visual mudou em relação ao decoded, escalamos a ROI para desenhar corretamente. sx = pw / float(base_w) sy = ph / float(base_h) def scaled_roi(role): r = roi_rects[role] return (int(r[0] * sx), int(r[1] * sy), int(r[2] * sx), int(r[3] * sy)) active_img_map = {"rgb": rgb01, "re": re01, "nir": nir01} active_img = active_img_map.get(selected_role) active_roi = roi_rects[selected_role] metrics = compute_focus_metrics(active_img, active_roi, equalize=equalize) score = metric_value(metrics, method) if metrics.get("valid") else 0.0 hist = history_by_role[selected_role] smooth_tmp = score hist.append({ "t": time.time(), "score": score, "smooth": smooth_tmp, "method": method, }) smooth = smooth_score(hist, args.smooth_window) hist[-1]["smooth"] = smooth best = best_by_role[selected_role] if smooth > best["smooth"]: best.update({ "score": score, "smooth": smooth, "metrics": metrics, "timestamp": now_str(), "roi": list(map(int, active_roi)), "method": method, }) trend = analyze_trend( hist, best["smooth"], direction_names[direction_idx], drop_warn_pct=args.drop_warn_pct, ) # Painéis com ROI draw_roi(rgb_panel, scaled_roi("rgb"), active=(selected_role == "rgb")) draw_roi(re_panel, scaled_roi("re"), active=(selected_role == "re")) draw_roi(nir_panel, scaled_roi("nir"), active=(selected_role == "nir")) draw_crosshair(rgb_panel) draw_crosshair(re_panel) draw_crosshair(nir_panel) draw_panel_title(rgb_panel, f"RGB ({rgb_id}) | 1 seleciona", selected_role == "rgb") draw_panel_title(re_panel, f"RE ({re_id}) | 2 seleciona", selected_role == "re") draw_panel_title(nir_panel, f"NIR ({nir_id}) | 3 seleciona", selected_role == "nir") data_panel = make_data_panel( (ph, pw), selected_role, method, metrics, score, smooth, best["smooth"], trend.get("pct_of_best", 0.0), trend, fps_stream, fps_view, direction_names[direction_idx], hist, active_roi, roi_locked, equalize, ) 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]) if last_msg and (time.time() - last_msg_t) < 2.5: cv2.putText(board, last_msg, (16, board.shape[0] - 76), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 0), 2, cv2.LINE_AA) if args.preview_scale != 1.0: board = cv2.resize( board, (int(board.shape[1] * args.preview_scale), int(board.shape[0] * args.preview_scale)), interpolation=cv2.INTER_NEAREST, ) cv2.imshow(window_name, board) else: blank = np.zeros((720, 1280, 3), dtype=np.uint8) overlay_hud(blank, ["Aguardando frames do modulo..."], x=40, y=80, font_scale=1.0, line_step=34) cv2.imshow(window_name, blank) k = cv2.waitKey(1) & 0xFF if k in (ord("q"), ord("Q"), 27): break elif k == ord("1"): selected_role = "rgb" last_msg = "Selecionada: RGB" last_msg_t = time.time() elif k == ord("2"): selected_role = "re" last_msg = "Selecionada: RE" last_msg_t = time.time() elif k == ord("3"): selected_role = "nir" last_msg = "Selecionada: NIR" last_msg_t = time.time() elif k in (ord("m"), ord("M")): methods = ["laplacian", "tenengrad", "brenner"] method = methods[(methods.index(method) + 1) % len(methods)] last_msg = f"Metrica -> {method}" last_msg_t = time.time() elif k in (ord("d"), ord("D")): direction_idx = 1 - direction_idx last_msg = f"Sentido informado -> {direction_names[direction_idx]}" last_msg_t = time.time() elif k in (ord("e"), ord("E")): equalize = not equalize last_msg = f"Equalize -> {'ON' if equalize else 'OFF'}" last_msg_t = time.time() elif k in (ord("l"), ord("L")): roi_locked = not roi_locked last_msg = f"ROI -> {'travada' if roi_locked else 'editavel'}" last_msg_t = time.time() elif k in (ord("c"), ord("C")): # Centraliza ROI da câmera ativa usando a resolução do último frame ativo. active_img = {"rgb": get_image_by_role(decoded_last, "rgb")[1], "re": get_image_by_role(decoded_last, "re")[1], "nir": get_image_by_role(decoded_last, "nir")[1]}.get(selected_role) if active_img is not None: roi_rects[selected_role] = default_roi_for_shape(active_img.shape[:2], frac=0.42) last_msg = f"ROI centralizada em {selected_role.upper()}" else: last_msg = "Sem imagem ativa para centralizar ROI" last_msg_t = time.time() elif k in (ord("r"), ord("R")): history_by_role[selected_role].clear() best_by_role[selected_role] = { "score": 0.0, "smooth": 0.0, "metrics": None, "timestamp": None, "roi": list(map(int, roi_rects[selected_role])) if roi_rects[selected_role] else None, "method": method, } last_msg = f"Score resetado: {selected_role.upper()}" last_msg_t = time.time() elif k in (ord("s"), ord("S")): best = best_by_role[selected_role] snap = { "timestamp": now_str(), "role": selected_role, "method": method, "roi": list(map(int, roi_rects[selected_role])) if roi_rects[selected_role] else None, "current_best": json.loads(json.dumps(best)), "direction_name": direction_names[direction_idx], "equalize": equalize, } snapshots.append(snap) last_msg = f"Snapshot salvo em memoria: {selected_role.upper()}" last_msg_t = time.time() elif k == 32: payload = build_result_payload(args, best_by_role, snapshots) save_json(args.out_json, payload) last_msg = f"Resultado salvo em: {args.out_json}" last_msg_t = time.time() dt_loop = time.time() - t0 if dt_loop < 0.001: time.sleep(0.001) finally: cv2.destroyAllWindows() print("Fim da calibração de foco.") if __name__ == "__main__": main()