import cv2 import tkinter as tk from tkinter import Canvas from PIL import Image, ImageTk import threading import numpy as np import warnings import os from config import config_manager # 抑制OpenCV的警告信息(兼容不同版本) import sys import io import time as _time os.environ['OPENCV_LOG_LEVEL'] = 'SILENT' os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '0' try: if hasattr(cv2, 'setLogLevel'): if hasattr(cv2, 'LOG_LEVEL_SILENT'): cv2.setLogLevel(cv2.LOG_LEVEL_SILENT) elif hasattr(cv2, 'LOG_LEVEL_ERROR'): cv2.setLogLevel(cv2.LOG_LEVEL_ERROR) elif hasattr(cv2, 'utils'): cv2.utils.setLogLevel(0) except Exception: pass # 简单日志限流 _last_log_times = {} def log_throttle(key: str, interval_sec: float, text: str): now = _time.time() last = _last_log_times.get(key, 0) if now - last >= interval_sec: _last_log_times[key] = now try: print(text) except Exception: pass class PreviewWindow: """采集卡预览窗口""" def __init__(self): self.config = config_manager.config self.caps = {} self.frames = {} self.large_windows = {} # idx -> Toplevel self.running = True def init_cameras(self): """初始化所有相机""" print("🔧 开始初始化采集卡...") loaded_count = 0 # 根据配置选择加载哪些组 display = self.config.get('display', {}) use_all = bool(display.get('preview_use_all_groups', True)) if use_all: target_groups = self.config['groups'] print(f"📷 预览全部配置组,共 {len(target_groups)} 组") else: target_groups = [g for g in self.config['groups'] if g.get('active', False)] print(f"📷 仅预览活动配置组,共 {len(target_groups)} 组") if not target_groups: print("⚠️ 没有活动的配置组,将不显示任何采集卡。可在配置中启用‘预览全部配置组’或设置活动组") # 重定向stderr来抑制OpenCV的错误输出 old_stderr = sys.stderr suppressed_output = io.StringIO() try: sys.stderr = suppressed_output for i, group in enumerate(target_groups): try: cam_idx = group['camera_index'] print(f" 尝试打开采集卡 {cam_idx} ({group['name']})...") cap = None # 尝试多种后端打开 backends_to_try = [ (int(cam_idx), cv2.CAP_DSHOW), (int(cam_idx), cv2.CAP_ANY), (int(cam_idx), None), ] for idx, backend in backends_to_try: try: with warnings.catch_warnings(): warnings.filterwarnings('ignore') if backend is not None: cap = cv2.VideoCapture(idx, backend) else: cap = cv2.VideoCapture(idx) if cap.isOpened(): # 测试读取一帧 ret, test_frame = cap.read() if ret and test_frame is not None: break else: cap.release() cap = None except Exception: if cap: try: cap.release() except: pass cap = None continue if cap and cap.isOpened(): try: cap.set(cv2.CAP_PROP_FRAME_WIDTH, group['camera_width']) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, group['camera_height']) except Exception as e: print(f" ⚠️ 设置分辨率失败: {e}") self.caps[i] = { 'cap': cap, 'group': group, 'name': group['name'] } loaded_count += 1 print(f" ✅ 采集卡 {cam_idx} 初始化成功") else: print(f" ❌ 采集卡 {cam_idx} 无法打开") except Exception as e: print(f" ❌ 采集卡 {group.get('camera_index', '?')} 初始化失败: {e}") import traceback traceback.print_exc() finally: # 恢复stderr sys.stderr = old_stderr if loaded_count == 0: print("⚠️ 警告:没有成功加载任何采集卡!") print("请检查:") print("1. 采集卡是否正确连接") print("2. 采集卡索引是否正确") print("3. 是否有活动的配置组") else: print(f"✅ 成功加载 {loaded_count} 个采集卡") def grab_once(self): """抓取每個采集卡當前單幀(裁剪後,存入 self.frames),抓取结果增强debug输出""" for idx, data in self.caps.items(): try: cap = data['cap'] frm_name = data.get('name', str(idx)) if cap is None: print(f"[抓取] 采集卡{idx}({frm_name}) cap为None") self.frames[idx] = None continue ret, frame = cap.read() if not ret or frame is None: print(f"[抓取] 采集卡{idx}({frm_name}) 没抓到帧 ret={ret} frame=None") self.frames[idx] = None continue height, width = frame.shape[:2] crop_top = 30 crop_bottom = min(crop_top + 720, height) crop_width = min(1280, width) if crop_bottom <= crop_top or crop_width <= 0: print(f"[抓取] 采集卡{idx}({frm_name}) 尺寸异常: H={height} W={width} 裁剪失败") self.frames[idx] = None continue frame = frame[crop_top:crop_bottom, 0:crop_width] # 额外检查:像素全黑? if np.mean(frame) < 3: print(f"[抓取] 采集卡{idx}({frm_name}) 帧抓到但内容接近全黑 shape={frame.shape}") else: print(f"[抓取] 采集卡{idx}({frm_name}) 成功 shape={frame.shape} 类型={frame.dtype}") self.frames[idx] = frame except Exception as e: print(f"[抓取] 采集卡{idx}({data.get('name', str(idx))}) 抓取异常: {e}") self.frames[idx] = None def capture_frames(self): """捕获帧""" while self.running: for idx, data in self.caps.items(): try: ret, frame = data['cap'].read() if ret and frame is not None: # 裁剪到配置的区域 height, width = frame.shape[:2] crop_top = 30 crop_bottom = min(crop_top + 720, height) crop_width = min(1280, width) # 确保裁剪范围有效 if crop_bottom > crop_top and crop_width > 0: frame = frame[crop_top:crop_bottom, 0:crop_width] # 转换颜色空间 frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) self.frames[idx] = frame_rgb else: print(f"⚠️ 采集卡 {idx} 裁剪参数无效") else: # 读取失败,清除旧帧 if idx in self.frames: self.frames[idx] = None except Exception as e: print(f"捕获帧 {idx} 错误: {e}") import traceback traceback.print_exc() import time time.sleep(0.01) # 避免CPU占用过高 def create_grid_window(self, master=None): """创建网格窗口,可挂载在主Tk下作为Toplevel弹窗""" display = self.config.get('display', {}) preview_width = display.get('preview_width', 1000) preview_height = display.get('preview_height', 700) columns = display.get('preview_columns', 2) rows = display.get('preview_rows', 2) if master is not None: root = tk.Toplevel(master) else: root = tk.Tk() root.title("采集卡预览 - 点击放大") root.geometry(f"{preview_width}x{preview_height}") root.update_idletasks() canvas = Canvas(root, bg='black', width=preview_width, height=preview_height) canvas.pack(fill=tk.BOTH, expand=True) # 存储图像对象 self.photo_objects = {} # 用于控制调试输出(只打印前几次) self.debug_count = 0 def update_frames_once(): """在主线程中每5秒刷新画面(直接显示高频采集线程生成的帧,去除grab_once)""" if not self.running: return try: if not self.caps: canvas.delete("all") canvas.create_text( root.winfo_width() // 2, root.winfo_height() // 2, text="未找到可用的采集卡\n\n请检查:\n1. 采集卡是否正确连接\n2. 配置组是否有活动的采集卡\n3. 采集卡索引是否正确", fill='yellow', font=('Arial', 14), justify=tk.CENTER ) root.after(5000, update_frames_once) return # 计算每个预览窗口的位置和大小 # 直接使用配置值作为画布尺寸(macOS上窗口尺寸可能在显示前返回默认值) canvas_width = preview_width canvas_height = preview_height # 尝试获取实际的窗口尺寸,如果有效则使用(大于配置值说明可能被手动调整了) try: root.update_idletasks() actual_width = root.winfo_width() actual_height = root.winfo_height() # 只有在获取到合理的尺寸时才使用(大于100像素) if actual_width > 100 and actual_height > 100: canvas_width = actual_width canvas_height = actual_height except: pass # 如果获取失败,使用配置值 cell_width = max(10, canvas_width // columns) cell_height = max(10, canvas_height // rows) # 先收集所有需要显示的图像 images_to_draw = [] texts_to_draw = [] frame_idx = 0 for idx in self.caps.keys(): frame = self.frames.get(idx) has_frame = frame is not None print(f'[DEBUG] update UI idx={idx}, has_frame={has_frame}, frame_info={getattr(frame, "shape", None)}') name = self.caps[idx]['name'] row = frame_idx // columns col = frame_idx % columns x = col * cell_width y = row * cell_height center_x = x + cell_width // 2 center_y = y + cell_height // 2 # 如果没拿到frame,临时主动再抓一次,并DEBUG if not has_frame: print(f'[DEBUG] UI未获得frame,尝试临时抓取采集卡{idx}...') try: cap_test = self.caps[idx]['cap'] ret, fr2 = cap_test.read() print(f'[DEBUG] 临时抓取ret={ret}, shape={getattr(fr2, "shape", None)}') if ret and fr2 is not None: frame = fr2 has_frame = True else: frame = None has_frame = False except Exception as e: print(f'[DEBUG] 临时抓取异常:{e}') frame = None has_frame = False if has_frame: try: h, w = frame.shape[:2] if w <= 0 or h <= 0: detail = f"采集卡{idx}:{name}\n抓到帧但图像尺寸异常 ({w}x{h})" texts_to_draw.append((center_x, center_y, detail, 'red')) frame_idx += 1 continue scale = min(cell_width / w, cell_height / h, 1.0) * 0.85 new_w = max(1, int(w * scale)) new_h = max(1, int(h * scale)) if new_w <= 0 or new_h <= 0: detail = f"采集卡{idx}:{name}\n缩放计算异常 ({w}x{h})" texts_to_draw.append((center_x, center_y, detail, 'red')) frame_idx += 1 continue display_frame = frame if len(display_frame.shape) == 3 and display_frame.shape[2] == 3: display_frame = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) resized_frame = cv2.resize(display_frame, (new_w, new_h)) pil_image = Image.fromarray(resized_frame) photo = ImageTk.PhotoImage(image=pil_image) if not hasattr(self, 'photo_refs'): self.photo_refs = {} self.photo_refs[idx] = photo canvas.create_image(center_x, center_y, image=photo, anchor='center') canvas.update() # 保证实时刷新 print(f'[DEBUG] UI idx={idx}, 绘制photo id={id(photo)}, photo_refs={len(self.photo_refs)}') images_to_draw.append((photo, center_x, center_y)) texts_to_draw.append((center_x, y + 15, name, 'white')) frame_idx += 1 except Exception as e: errstr = str(e) detail = f"采集卡{idx}:{name}\n帧处理异常:{errstr}" print(f'[DEBUG] idx={idx}, 帧处理异常:{errstr}') texts_to_draw.append((center_x, center_y, detail, 'red')) frame_idx += 1 else: detail = f"采集卡{idx}:{name}\n抓取失败/暂无画面" print(f'[DEBUG] idx={idx} 依然无可用帧,最终显示错误提示') texts_to_draw.append((center_x, center_y, detail, 'red')) frame_idx += 1 # 清空画布 canvas.delete("all") # 先绘制所有图像(底层) if images_to_draw and self.debug_count <= 3: log_throttle("draw_prepare", 2.0, f"✅ 准备绘制 {len(images_to_draw)} 个图像到画布 ({canvas_width}x{canvas_height})") for photo, x, y in images_to_draw: try: # PhotoImage强引用,防止被GC if not hasattr(self, 'photo_refs'): self.photo_refs = {} self.photo_refs[idx] = photo canvas.create_image(x, y, image=photo, anchor='center') canvas.update() # 保证实时刷新 print(f'[DEBUG] idx={idx}, photo id={id(photo)}, photo refs={len(self.photo_refs)}') except Exception as e: if "pyimage" not in str(e).lower(): log_throttle("draw_image_error", 2.0, f"绘制图像错误: {e}") # 再绘制所有文本(上层) for x, y, text, color in texts_to_draw: try: if color == 'white': canvas.create_text(x, y, text=text, fill=color, font=('Arial', 10, 'bold')) else: canvas.create_text(x, y, text=text, fill=color, font=('Arial', 10)) except Exception as e: log_throttle("draw_text_error", 2.0, f"绘制文本错误: {e}") # 绘制分割线,区分不同的采集卡窗口 try: # 绘制垂直分割线(列之间的分割线) for col in range(1, columns): x = col * cell_width canvas.create_line( x, 0, x, canvas_height, fill='white', width=2, dash=(5, 5) # 虚线效果,让分割线更明显 ) # 绘制水平分割线(行之间的分割线) for row in range(1, rows): y = row * cell_height canvas.create_line( 0, y, canvas_width, y, fill='white', width=2, dash=(5, 5) # 虚线效果 ) except Exception as e: log_throttle("draw_grid_error", 2.0, f"绘制分割线错误: {e}") except Exception as e: log_throttle("update_frame_error", 1.0, f"更新帧错误: {e}") import traceback traceback.print_exc() # 每5秒更新一次 root.after(5000, update_frames_once) def on_canvas_click(event): """点击画布事件""" window_width = root.winfo_width() window_height = root.winfo_height() cell_width = window_width // columns cell_height = window_height // rows col = int(event.x // cell_width) row = int(event.y // cell_height) index = row * columns + col # 找到对应的配置 if index < len(self.caps): idx = list(self.caps.keys())[index] self.show_large_window(idx) canvas.bind('', on_canvas_click) # 等待窗口完全初始化后再开始更新 def start_updates(): """延迟启动更新,确保窗口已完全显示""" # 强制更新窗口尺寸 root.update_idletasks() root.update() # 等待窗口完全绘制 import time time.sleep(0.2) # 给窗口更多时间初始化 root.update_idletasks() root.update() update_frames_once() # 使用after在主线程中循环刷新(延迟启动,给足够时间让窗口初始化) root.after(200, start_updates) def on_closing(): """关闭窗口""" self.running = False for data in self.caps.values(): data['cap'].release() root.destroy() root.protocol("WM_DELETE_WINDOW", on_closing) # 仅在独立窗口(无master)时进入事件循环 if master is None: root.mainloop() def show_large_window(self, idx): """显示指定采集卡的大窗口(可多实例)""" # 如果已存在该索引窗口,先销毁再创建,确保刷新 if idx in self.large_windows and self.large_windows[idx].winfo_exists(): try: self.large_windows[idx].destroy() except Exception: pass win = tk.Toplevel() win.title(f"放大视图 - {self.caps[idx]['name']}") win.geometry("1280x720") self.large_windows[idx] = win canvas = Canvas(win, bg='black') canvas.pack(fill=tk.BOTH, expand=True) photo_obj = {} def update_large_once(): if not self.running or not win.winfo_exists(): return try: window_width = win.winfo_width() if win.winfo_width() > 1 else 1280 window_height = win.winfo_height() if win.winfo_height() > 1 else 720 if idx in self.frames and self.frames[idx] is not None: frame = self.frames[idx] h, w = frame.shape[:2] scale = min(window_width / w, window_height / h) new_w = int(w * scale) new_h = int(h * scale) resized_frame = cv2.resize(frame, (new_w, new_h)) if len(resized_frame.shape) == 3 and resized_frame.shape[2] == 3: resized_frame = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2RGB) pil_image = Image.fromarray(resized_frame) photo = ImageTk.PhotoImage(image=pil_image) photo_obj['img'] = photo canvas.delete("all") canvas.create_image(window_width // 2, window_height // 2, image=photo, anchor='center') else: canvas.delete("all") # 显示更详细的调试信息 debug_text = f"等待画面...\n采集卡: {idx}\n帧状态: {idx in self.frames}\n" if idx in self.frames: debug_text += f"帧数据: {self.frames[idx] is not None}" else: debug_text += "帧数据: 未初始化" canvas.create_text( window_width // 2, window_height // 2, text=debug_text, fill='gray', font=('Arial', 12), justify='center' ) except Exception as e: if "pyimage" not in str(e).lower(): log_throttle("large_update_error", 1.0, f"更新大窗口错误: {e}") # 放大視窗維持原本高頻刷新 win.after(33, update_large_once) win.after(33, update_large_once) def on_close(): try: if idx in self.large_windows: del self.large_windows[idx] finally: win.destroy() win.protocol("WM_DELETE_WINDOW", on_close) def run(self, master=None): """运行预览,支持主Tk弹窗模式""" self.init_cameras() if not self.caps: print("⚠️ 没有可用的采集卡,将显示错误提示窗口") display = self.config.get('display', {}) multi_window = bool(display.get('preview_multi_window', False)) if self.caps: capture_thread = threading.Thread(target=self.capture_frames, daemon=True) capture_thread.start() import time time.sleep(0.5) if multi_window: root = tk.Toplevel(master) if master else tk.Tk() root.title("采集卡预览 - 多窗口模式") root.geometry("300x80") tk.Label(root, text="已打开多窗口预览,可单独拖动/调整大小").pack(pady=20) print("等待采集卡初始化...") max_wait = 50 wait_count = 0 while wait_count < max_wait and not any(frame is not None for frame in self.frames.values()): time.sleep(0.1) wait_count += 1 if wait_count >= max_wait: print("⚠️ 采集卡初始化超时,可能无法显示画面") for idx in list(self.caps.keys()): print(f"打开采集卡 {idx} 的预览窗口...") self.show_large_window(idx) def on_root_close(): self.running = False for data in self.caps.values(): data['cap'].release() try: root.destroy() except Exception: pass root.protocol("WM_DELETE_WINDOW", on_root_close) # 仅在独立窗口(无master)时进入事件循环 if master is None: root.mainloop() else: self.create_grid_window(master) if __name__ == "__main__": preview = PreviewWindow() preview.run()