agrobot_base/Python/gal5000/preview_auto_exposure.py

561 lines
17 KiB
Python
Raw Normal View History

2026-01-28 18:05:51 +00:00
import os
import time
import math
import ctypes as C
from ctypes import wintypes as W
import numpy as np
import cv2
# =========================
# CONFIG
# =========================
SDK_DIR = os.path.join(os.path.dirname(__file__), "dlls")
DLL_NAME = "VT_SDK64.dll"
TIMEOUT_MS = 2000
WINDOW_NAME = "GAL5000 4CH Preview (A=AE, Q=quit)"
# Camera scan/open
DEVICE_UDEF = 0
DEVICE_INDEX = 0
DATA_RAW = 0
# RAW geometry (já conhecido da GAL5000)
RAW_W = 2592
RAW_H = 2056
# PARAM IDs (apenas os que sabemos que existem)
BUF_SIZE = 256
PARAM_ID_SENSOR_EXPOSURETIMERAW = 0x00003010
PARAM_ID_SENSOR_GAINANALOGRAW = 0x00003020
PARAM_ID_SENSOR_GAINDIGITRAW = 0x0000302A
PARAM_ID_SFNC_SENSORWIDTH = 0x00001101
PARAM_ID_SFNC_SENSORHEIGHT = 0x00001102
PARAM_ID_SFNC_WIDTHMAX = 0x00001106
PARAM_ID_SFNC_HEIGHTMAX = 0x00001107
PARAM_ID_SFNC_WIDTH = 0x00001111
PARAM_ID_SFNC_HEIGHT = 0x00001112
PARAM_ID_SFNC_OFFSETX = 0x00001113
PARAM_ID_SFNC_OFFSETY = 0x00001114
PARAM_ID_SFNC_EXPOSURETIME = 0x0000121A # pode ou não refletir algo útil
# PARAM_VALUETYPE
VALUE_INT = 0
VALUE_FLOAT = 1
VALUE_STR = 2
# =========================
# LIMITES / HOTKEYS
# =========================
# Exposição em unidades RAW (linhas)
EXP_MIN = 1
EXP_MAX = 20000 # ajusta depois se ver que a câmera aceita mais/menos
EXP_STEP = 200 # passo “normal” (+/-)
EXP_STEP_FAST = 1000 # passo rápido ([ ])
# Ganho analógico
GAIN_A_MIN = 0
GAIN_A_MAX = 255
GAIN_A_STEP = 2
# Ganho digital
GAIN_D_MIN = 0
GAIN_D_MAX = 8 # chute conservador; hoje está em 2
GAIN_D_STEP = 1
# ROI para análise (chão)
ROI_Y0_FRAC = 0.55
ROI_Y1_FRAC = 0.95
ROI_X0_FRAC = 0.15
ROI_X1_FRAC = 0.85
# Alvo de brilho / saturação
TARGET_P95 = 140.0 # alvo de brilho (0..255)
DEADBAND = 6.0 # zona morta em torno do alvo
SAT_LIMIT = 0.02 # fração máxima de pixels saturados (2%)
# Controle log / suavização
K_LOG = 0.12 # ganho do controlador em log
MAX_STEP = 0.10 # limite do passo (em espaço log) por iteração
EMA_ALPHA = 0.20 # suavização do p95
def clamp(v, lo, hi):
return lo if v < lo else hi if v > hi else v
# =========================
# STRUCTS
# =========================
class VT_FRAMEINFO(C.Structure):
_fields_ = [
("lFrameID", W.DWORD),
("lBufSize", W.DWORD),
("lWidth", W.DWORD),
("lHeight", W.DWORD),
("lPixBits", C.c_ubyte),
("_pad0", C.c_ubyte * 3),
("pBufPtr", C.POINTER(C.c_ubyte)),
("lFrameStatus", W.DWORD),
("lPixType", W.DWORD),
("lTimeStamp", W.DWORD),
("_reserve", W.DWORD * 8),
]
class VT_DEVPARAM(C.Structure):
_fields_ = [
("bUseName", W.BOOL),
("lParamByID", W.DWORD),
("lParamByName", C.c_char * BUF_SIZE),
]
def devparam_by_id(pid: int) -> VT_DEVPARAM:
p = VT_DEVPARAM()
p.bUseName = False
p.lParamByID = pid
p.lParamByName = b""
return p
# =========================
# DLL LOAD + prototypes
# =========================
os.add_dll_directory(SDK_DIR)
dll = C.WinDLL(os.path.join(SDK_DIR, DLL_NAME))
print("DLL carregada OK:", dll)
dll.VT_DeviceScan.argtypes = [C.POINTER(C.c_ubyte), C.c_int]
dll.VT_DeviceScan.restype = C.c_int
dll.VT_DeviceOpen.argtypes = [C.c_void_p, C.POINTER(W.HANDLE), C.c_int, C.c_int]
dll.VT_DeviceOpen.restype = C.c_int
dll.VT_SingleFrameCapture.argtypes = [W.HANDLE, C.POINTER(VT_FRAMEINFO), C.c_int, C.c_int, W.BOOL]
dll.VT_SingleFrameCapture.restype = C.c_int
dll.VT_DeviceClose.argtypes = [C.POINTER(W.HANDLE)]
dll.VT_DeviceClose.restype = C.c_int
dll.VT_ParamGetValue.argtypes = [W.HANDLE, VT_DEVPARAM, C.c_void_p, C.c_int]
dll.VT_ParamGetValue.restype = C.c_int
dll.VT_ParamSetValue.argtypes = [W.HANDLE, VT_DEVPARAM, C.c_void_p, C.c_int]
dll.VT_ParamSetValue.restype = C.c_int
def ck(ret: int, name: str):
if ret != 0:
raise RuntimeError(f"{name} falhou, ret={ret}")
def param_get_int(h: W.HANDLE, pid: int) -> int:
p = devparam_by_id(pid)
v = C.c_int(0)
ret = dll.VT_ParamGetValue(h, p, C.byref(v), VALUE_INT)
ck(ret, f"VT_ParamGetValue({hex(pid)})")
return int(v.value)
def param_set_int(h: W.HANDLE, pid: int, value: int):
p = devparam_by_id(pid)
v = C.c_int(int(value))
ret = dll.VT_ParamSetValue(h, p, C.byref(v), VALUE_INT)
ck(ret, f"VT_ParamSetValue({hex(pid)})")
def param_get_float(h: W.HANDLE, pid: int) -> float:
p = devparam_by_id(pid)
v = C.c_float(0.0)
ret = dll.VT_ParamGetValue(h, p, C.byref(v), VALUE_FLOAT)
ck(ret, f"VT_ParamGetValue({hex(pid)})")
return float(v.value)
# =========================
# CAPTURA / VISUALIZAÇÃO
# =========================
def capture_raw8(h: W.HANDLE) -> np.ndarray:
fi = VT_FRAMEINFO()
ret = dll.VT_SingleFrameCapture(h, C.byref(fi), DATA_RAW, TIMEOUT_MS, True)
ck(ret, "VT_SingleFrameCapture")
w, hh = int(fi.lWidth), int(fi.lHeight)
if w != RAW_W or hh != RAW_H:
print(f"[WARN] Res mudou: {w}x{hh} (esperado {RAW_W}x{RAW_H})")
buf = C.string_at(fi.pBufPtr, fi.lBufSize)
arr = np.frombuffer(buf, dtype=np.uint8)
needed = w * hh
if arr.size < needed:
arr = np.pad(arr, (0, needed - arr.size), mode="constant", constant_values=0)
arr = arr[:needed].reshape(hh, w)
return arr
def make_montage_4ch(raw: np.ndarray) -> np.ndarray:
# Bayer+NIR pattern:
# R G
# IR B
R = raw[0::2, 0::2]
G = raw[0::2, 1::2]
IR = raw[1::2, 0::2]
B = raw[1::2, 1::2]
def norm8(x):
lo = np.percentile(x, 2)
hi = np.percentile(x, 98)
if hi <= lo + 1:
return x
y = (x.astype(np.float32) - lo) * (255.0 / (hi - lo))
return np.clip(y, 0, 255).astype(np.uint8)
Rn, Gn, IRn, Bn = map(norm8, [R, G, IR, B])
top = np.hstack([Rn, Gn])
bot = np.hstack([IRn, Bn])
mont = np.vstack([top, bot])
mont_bgr = cv2.cvtColor(mont, cv2.COLOR_GRAY2BGR)
h2, w2 = Rn.shape
cv2.putText(mont_bgr, "R", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,255,255), 2, cv2.LINE_AA)
cv2.putText(mont_bgr, "G", (w2 + 10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,255,255), 2, cv2.LINE_AA)
cv2.putText(mont_bgr, "IR", (10, h2 + 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,255,255), 2, cv2.LINE_AA)
cv2.putText(mont_bgr, "B", (w2 + 10, h2 + 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,255,255), 2, cv2.LINE_AA)
return mont_bgr
def make_rgb_preview(raw: np.ndarray, upscale=2) -> np.ndarray:
R = raw[0::2, 0::2]
G = raw[0::2, 1::2]
B = raw[1::2, 1::2]
def norm8(x):
lo = np.percentile(x, 2)
hi = np.percentile(x, 98)
if hi <= lo + 1:
return x
y = (x.astype(np.float32) - lo) * (255.0 / (hi - lo))
return np.clip(y, 0, 255).astype(np.uint8)
Rn, Gn, Bn = map(norm8, [R, G, B])
rgb = np.dstack([Bn, Gn, Rn]) # OpenCV = BGR
if upscale and upscale != 1:
rgb = cv2.resize(rgb, (rgb.shape[1]*upscale, rgb.shape[0]*upscale),
interpolation=cv2.INTER_NEAREST)
return rgb
def draw_text(img, text, pos, scale=0.8):
x, y = pos
cv2.putText(img, text, (x+2, y+2),
cv2.FONT_HERSHEY_SIMPLEX, scale,
(0, 0, 0), 3, cv2.LINE_AA)
cv2.putText(img, text, (x, y),
cv2.FONT_HERSHEY_SIMPLEX, scale,
(255, 255, 255), 2, cv2.LINE_AA)
def overlay_hud(img, ae_enabled, exp_raw, gain_a, gain_d, fps, dbg):
p95 = dbg.get("p95", None)
sat = dbg.get("sat", None)
lines = [
f"AE: {'ON' if ae_enabled else 'OFF'}",
f"ExposureRaw: {exp_raw}",
f"Gain A: {gain_a} | Gain D: {gain_d}",
f"FPS: {fps:.1f}",
]
if p95 is not None and sat is not None:
lines.append(f"p95: {p95:.1f} | sat: {sat*100:.2f}%")
lines.append("Keys: A=AE +/- / [ ] exp Z/X gainA C/V gainD Q=quit")
y = 30
for s in lines:
draw_text(img, s, (12, y), scale=0.80)
y += 26
# =========================
# AE CONTROLLER (software)
# =========================
def measure_raw_g_metrics(raw: np.ndarray):
"""
Mede p90/p95/saturação no canal G cru (8-bit),
usando apenas uma ROI voltada ao chão.
"""
H, W = raw.shape[:2]
# canal G cru (Bayer layout R/G/IR/B):
G = raw[0::2, 1::2] # ~ H/2 x W/2
h2, w2 = G.shape
y0 = int(h2 * ROI_Y0_FRAC)
y1 = int(h2 * ROI_Y1_FRAC)
x0 = int(w2 * ROI_X0_FRAC)
x1 = int(w2 * ROI_X1_FRAC)
roi = G[y0:y1, x0:x1]
p90 = float(np.percentile(roi, 90))
p95 = float(np.percentile(roi, 95))
sat = float(np.mean(roi >= 250))
return p90, p95, sat
class AEController:
def __init__(self,
exp_min=EXP_MIN,
exp_max=EXP_MAX,
target_p95=TARGET_P95,
deadband=DEADBAND,
k=K_LOG,
max_step=MAX_STEP,
ema_alpha=EMA_ALPHA,
sat_limit=SAT_LIMIT,
use_gain=True):
self.exp_min = exp_min
self.exp_max = exp_max
self.target = target_p95
self.deadband = deadband
self.k = k
self.max_step = max_step
self.ema_alpha = ema_alpha
self.sat_limit = sat_limit
self.use_gain = use_gain
self.p95_ema = None
def step(self, raw: np.ndarray, exp_raw: int,
gain_a: int, gain_d: int):
"""
Retorna (new_exp, new_gain_a, new_gain_d, dbg)
"""
p90, p95, sat = measure_raw_g_metrics(raw)
# EMA do p95
if self.p95_ema is None:
self.p95_ema = p95
else:
self.p95_ema = (1.0 - self.ema_alpha) * self.p95_ema + self.ema_alpha * p95
e = self.target - self.p95_ema
# deadband: se está perto do alvo e não saturando, não mexe
if abs(e) <= self.deadband and sat <= self.sat_limit:
dbg = {
"p90": p90,
"p95": p95,
"p95_ema": self.p95_ema,
"sat": sat,
"step": 0.0,
"hold": True
}
return exp_raw, gain_a, gain_d, dbg
# cálculo do step em log
if sat > self.sat_limit:
# saturou: garante um passo negativo mínimo
step = -min(self.max_step, 0.12)
else:
ratio = (self.target + 1e-6) / (self.p95_ema + 1e-6)
step = self.k * math.log(ratio)
step = clamp(step, -self.max_step, +self.max_step)
new_exp = int(round(exp_raw * math.exp(step)))
new_exp = clamp(new_exp, self.exp_min, self.exp_max)
new_gain_a = gain_a
new_gain_d = gain_d
if self.use_gain:
# se exposição chegou no teto e ainda está escuro, sobe ganho analógico
if new_exp >= self.exp_max and self.p95_ema < (self.target - self.deadband):
new_gain_a = clamp(gain_a + GAIN_A_STEP, GAIN_A_MIN, GAIN_A_MAX)
# se exposição chegou no chão e está muito claro/saturando, baixa ganho analógico
if new_exp <= self.exp_min and (self.p95_ema > (self.target + self.deadband) or sat > self.sat_limit):
new_gain_a = clamp(gain_a - GAIN_A_STEP, GAIN_A_MIN, GAIN_A_MAX)
dbg = {
"p90": p90,
"p95": p95,
"p95_ema": self.p95_ema,
"sat": sat,
"step": step,
"hold": False
}
return new_exp, new_gain_a, new_gain_d, dbg
# =========================
# MAIN
# =========================
def main():
# scan
n = C.c_ubyte(0)
ret = dll.VT_DeviceScan(C.byref(n), DEVICE_UDEF)
ck(ret, "VT_DeviceScan")
if n.value == 0:
raise RuntimeError("Nenhuma câmera encontrada.")
# open
idx = C.c_ubyte(0)
h = W.HANDLE()
ret = dll.VT_DeviceOpen(C.byref(idx), C.byref(h), DEVICE_INDEX, DEVICE_UDEF)
ck(ret, "VT_DeviceOpen")
print("DeviceOpen OK, handle=", h.value)
# Info básica do sensor / ROI (opcional, mas útil pra log)
try:
sensor_w = param_get_int(h, PARAM_ID_SFNC_SENSORWIDTH)
sensor_h = param_get_int(h, PARAM_ID_SFNC_SENSORHEIGHT)
width_max = param_get_int(h, PARAM_ID_SFNC_WIDTHMAX)
height_max= param_get_int(h, PARAM_ID_SFNC_HEIGHTMAX)
roi_w = param_get_int(h, PARAM_ID_SFNC_WIDTH)
roi_h = param_get_int(h, PARAM_ID_SFNC_HEIGHT)
roi_x = param_get_int(h, PARAM_ID_SFNC_OFFSETX)
roi_y = param_get_int(h, PARAM_ID_SFNC_OFFSETY)
print(f"[CAM] sensor={sensor_w}x{sensor_h} roi={roi_w}x{roi_h}+{roi_x},{roi_y} max={width_max}x{height_max}")
except Exception as e:
print("[CAM] Não foi possível ler info SFNC:", e)
# ler exp/gains atuais
try:
exp_raw = param_get_int(h, PARAM_ID_SENSOR_EXPOSURETIMERAW)
except Exception:
exp_raw = 1500
try:
gain_a = param_get_int(h, PARAM_ID_SENSOR_GAINANALOGRAW)
except Exception:
gain_a = 0
try:
gain_d = param_get_int(h, PARAM_ID_SENSOR_GAINDIGITRAW)
except Exception:
gain_d = 0
print(f"[INIT] exp_raw={exp_raw} gainA={gain_a} gainD={gain_d}")
cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)
cv2.namedWindow("RGB", cv2.WINDOW_NORMAL)
show_rgb = True
t0 = time.time()
frames = 0
fps = 0.0
ae = AEController()
ae_enabled = True
dbg_last = {}
def set_exposure(new_exp: int) -> int:
new_exp = clamp(int(new_exp), EXP_MIN, EXP_MAX)
param_set_int(h, PARAM_ID_SENSOR_EXPOSURETIMERAW, new_exp)
return new_exp
def set_gain_a(new_gain: int) -> int:
new_gain = clamp(int(new_gain), GAIN_A_MIN, GAIN_A_MAX)
param_set_int(h, PARAM_ID_SENSOR_GAINANALOGRAW, new_gain)
return new_gain
def set_gain_d(new_gain: int) -> int:
new_gain = clamp(int(new_gain), GAIN_D_MIN, GAIN_D_MAX)
param_set_int(h, PARAM_ID_SENSOR_GAINDIGITRAW, new_gain)
return new_gain
try:
while True:
raw = capture_raw8(h)
# Auto-exposure em software
if ae_enabled:
new_exp, new_gain_a, new_gain_d, dbg = ae.step(raw, exp_raw, gain_a, gain_d)
if new_exp != exp_raw:
exp_raw = set_exposure(new_exp)
if new_gain_a != gain_a and False:
gain_a = set_gain_a(new_gain_a)
if new_gain_d != gain_d:
gain_d = set_gain_d(new_gain_d)
dbg_last = dbg
else:
dbg_last = {}
montage = make_montage_4ch(raw)
# FPS calculado
frames += 1
dt = time.time() - t0
if dt >= 1.0:
fps = frames / dt
frames = 0
t0 = time.time()
overlay_hud(montage, ae_enabled, exp_raw, gain_a, gain_d, fps, dbg_last)
cv2.imshow(WINDOW_NAME, montage)
if show_rgb:
rgb = make_rgb_preview(raw, upscale=2)
cv2.imshow("RGB", rgb)
k = cv2.waitKey(1) & 0xFF
if k in (ord('q'), ord('Q'), 27):
break
elif k in (ord('a'), ord('A')):
ae_enabled = not ae_enabled
print("AE (software) =", ae_enabled)
elif k in (ord('m'), ord('M')):
show_rgb = not show_rgb
if not show_rgb:
cv2.destroyWindow("RGB")
else:
cv2.namedWindow("RGB", cv2.WINDOW_NORMAL)
# Controles manuais só quando AE está desligado
elif not ae_enabled:
if k in (ord('+'), ord('=')):
exp_raw = set_exposure(exp_raw + EXP_STEP)
print(f"[MANUAL] ExposureRaw -> {exp_raw}")
elif k in (ord('-'), ord('_')):
exp_raw = set_exposure(exp_raw - EXP_STEP)
print(f"[MANUAL] ExposureRaw -> {exp_raw}")
elif k == ord(']'):
exp_raw = set_exposure(exp_raw + EXP_STEP_FAST)
print(f"[MANUAL] ExposureRaw (fast) -> {exp_raw}")
elif k == ord('['):
exp_raw = set_exposure(exp_raw - EXP_STEP_FAST)
print(f"[MANUAL] ExposureRaw (fast) -> {exp_raw}")
elif k in (ord('z'), ord('Z')):
gain_a = set_gain_a(gain_a - GAIN_A_STEP)
print(f"[MANUAL] GainA -> {gain_a}")
elif k in (ord('x'), ord('X')):
gain_a = set_gain_a(gain_a + GAIN_A_STEP)
print(f"[MANUAL] GainA -> {gain_a}")
elif k in (ord('c'), ord('C')):
gain_d = set_gain_d(gain_d - GAIN_D_STEP)
print(f"[MANUAL] GainD -> {gain_d}")
elif k in (ord('v'), ord('V')):
gain_d = set_gain_d(gain_d + GAIN_D_STEP)
print(f"[MANUAL] GainD -> {gain_d}")
else:
pass
finally:
try:
ret = dll.VT_DeviceClose(C.byref(h))
if ret != 0:
print("VT_DeviceClose retornou:", ret)
except Exception as e:
print("Erro ao fechar:", e)
cv2.destroyAllWindows()
print("Fim.")
if __name__ == "__main__":
main()