1674 lines
47 KiB
Python
1674 lines
47 KiB
Python
#!/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()
|