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()