多台控制

This commit is contained in:
ray
2025-10-30 23:39:27 +08:00
parent 069613fe09
commit 7623e22e5c
9 changed files with 350 additions and 190 deletions

View File

@@ -11,6 +11,7 @@ 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'
@@ -26,13 +27,25 @@ try:
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_window = None
self.large_windows = {} # idx -> Toplevel
self.running = True
def init_cameras(self):
@@ -40,11 +53,17 @@ class PreviewWindow:
print("🔧 开始初始化采集卡...")
loaded_count = 0
# 如果没有活动配置,加载所有配置
active_groups = [g for g in self.config['groups'] if g.get('active', True)]
if not active_groups:
print("⚠️ 没有活动的配置组,将尝试加载所有配置组")
active_groups = self.config['groups']
# 根据配置选择加载哪些组
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
@@ -53,7 +72,7 @@ class PreviewWindow:
try:
sys.stderr = suppressed_output
for i, group in enumerate(active_groups):
for i, group in enumerate(target_groups):
try:
cam_idx = group['camera_index']
print(f" 尝试打开采集卡 {cam_idx} ({group['name']})...")
@@ -125,6 +144,40 @@ class PreviewWindow:
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:
@@ -159,19 +212,21 @@ class PreviewWindow:
import time
time.sleep(0.01) # 避免CPU占用过高
def create_grid_window(self):
"""创建网格窗口"""
# 获取显示配置
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)
root = tk.Tk()
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() # 立即更新窗口尺寸
root.update_idletasks()
canvas = Canvas(root, bg='black', width=preview_width, height=preview_height)
canvas.pack(fill=tk.BOTH, expand=True)
@@ -183,11 +238,10 @@ class PreviewWindow:
self.debug_count = 0
def update_frames_once():
"""在主线程中更新一帧使用after循环"""
"""在主线程中每5秒刷新画面直接显示高频采集线程生成的帧去除grab_once"""
if not self.running:
return
try:
# 如果没有加载任何采集卡,显示提示
if not self.caps:
canvas.delete("all")
canvas.create_text(
@@ -198,7 +252,7 @@ class PreviewWindow:
font=('Arial', 14),
justify=tk.CENTER
)
root.after(33, update_frames_once)
root.after(5000, update_frames_once)
return
# 计算每个预览窗口的位置和大小
@@ -228,78 +282,74 @@ class PreviewWindow:
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
name = self.caps[idx]['name']
# 检查帧数据
has_frame = idx in self.frames and self.frames[idx] is not None
if has_frame:
# 调整图像大小
# 如果没拿到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:
frame = self.frames[idx]
h, w = frame.shape[:2]
# 确保尺寸有效
if w <= 0 or h <= 0:
texts_to_draw.append((center_x, center_y, f"{name}\n尺寸无效", 'red'))
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:
texts_to_draw.append((center_x, center_y, f"{name}\n缩放失败", 'red'))
detail = f"采集卡{idx}:{name}\n缩放计算异常 ({w}x{h})"
texts_to_draw.append((center_x, center_y, detail, 'red'))
frame_idx += 1
continue
# 调试输出(仅前几次,避免刷屏)
if frame_idx == 0 and len(images_to_draw) == 0:
self.debug_count += 1
if self.debug_count <= 3: # 只打印前3次
print(f"🔍 预览调试 #{self.debug_count}:")
print(f" 配置尺寸: {preview_width}x{preview_height}")
print(f" 实际画布: {canvas_width}x{canvas_height}")
print(f" 单元格大小: {cell_width}x{cell_height}")
print(f" 原始帧: {w}x{h}")
print(f" 缩放后: {new_w}x{new_h}")
resized_frame = cv2.resize(frame, (new_w, new_h))
# 转换为PIL图像
# 确保颜色通道正确BGR -> RGB
if len(resized_frame.shape) == 3 and resized_frame.shape[2] == 3:
resized_frame = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2RGB)
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)
# 保持引用避免被GC
self.photo_objects[idx] = photo
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:
# 忽略pyimage相关错误避免刷屏
if "pyimage" not in str(e).lower():
print(f"处理帧 {idx} 错误: {e}")
import traceback
traceback.print_exc()
# 如果处理失败,显示等待提示
texts_to_draw.append((center_x, center_y, f"{name}\n处理失败", 'red'))
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:
# 显示等待提示
texts_to_draw.append((center_x, center_y, f"{name}\n等待画面...", 'gray'))
detail = f"采集卡{idx}:{name}\n抓取失败/暂无画面"
print(f'[DEBUG] idx={idx} 依然无可用帧,最终显示错误提示')
texts_to_draw.append((center_x, center_y, detail, 'red'))
frame_idx += 1
# 清空画布
@@ -307,14 +357,20 @@ class PreviewWindow:
# 先绘制所有图像(底层)
if images_to_draw and self.debug_count <= 3:
print(f"✅ 准备绘制 {len(images_to_draw)} 个图像到画布 ({canvas_width}x{canvas_height})")
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():
print(f"绘制图像错误: {e}")
log_throttle("draw_image_error", 2.0, f"绘制图像错误: {e}")
# 再绘制所有文本(上层)
for x, y, text, color in texts_to_draw:
@@ -324,7 +380,7 @@ class PreviewWindow:
else:
canvas.create_text(x, y, text=text, fill=color, font=('Arial', 10))
except Exception as e:
print(f"绘制文本错误: {e}")
log_throttle("draw_text_error", 2.0, f"绘制文本错误: {e}")
# 绘制分割线,区分不同的采集卡窗口
try:
@@ -350,14 +406,14 @@ class PreviewWindow:
dash=(5, 5) # 虚线效果
)
except Exception as e:
print(f"绘制分割线错误: {e}")
log_throttle("draw_grid_error", 2.0, f"绘制分割线错误: {e}")
except Exception as e:
print(f"更新帧错误: {e}")
log_throttle("update_frame_error", 1.0, f"更新帧错误: {e}")
import traceback
traceback.print_exc()
# 约30fps
root.after(33, update_frames_once)
# 每5秒更新一次
root.after(5000, update_frames_once)
def on_canvas_click(event):
"""点击画布事件"""
@@ -401,86 +457,128 @@ class PreviewWindow:
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_closing)
root.mainloop()
# 仅在独立窗口无master时进入事件循环
if master is None:
root.mainloop()
def show_large_window(self, idx):
"""显示大窗口"""
if self.large_window is not None and self.large_window.winfo_exists():
self.large_window.destroy()
"""显示指定采集卡的大窗口(可多实例)"""
# 如果已存在该索引窗口,先销毁再创建,确保刷新
if idx in self.large_windows and self.large_windows[idx].winfo_exists():
try:
self.large_windows[idx].destroy()
except Exception:
pass
self.large_window = tk.Toplevel()
self.large_window.title(f"放大视图 - {self.caps[idx]['name']}")
self.large_window.geometry("1280x720")
win = tk.Toplevel()
win.title(f"放大视图 - {self.caps[idx]['name']}")
win.geometry("1280x720")
self.large_windows[idx] = win
canvas = Canvas(self.large_window, bg='black')
canvas = Canvas(win, bg='black')
canvas.pack(fill=tk.BOTH, expand=True)
photo_obj = {}
def update_large_once():
if not self.running or not self.large_window.winfo_exists():
if not self.running or not win.winfo_exists():
return
try:
# 获取窗口大小
window_width = self.large_window.winfo_width() if self.large_window.winfo_width() > 1 else 1280
window_height = self.large_window.winfo_height() if self.large_window.winfo_height() > 1 else 720
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))
# 确保颜色通道正确BGR -> RGB
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)
# 保存引用到字典确保不被GC
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="等待画面...",
fill='gray',
font=('Arial', 16)
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(): # 忽略pyimage错误避免刷屏
print(f"更新大窗口错误: {e}")
self.large_window.after(33, update_large_once)
if "pyimage" not in str(e).lower():
log_throttle("large_update_error", 1.0, f"更新大窗口错误: {e}")
# 放大視窗維持原本高頻刷新
win.after(33, update_large_once)
self.large_window.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):
"""运行预览"""
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) # 等待几帧
self.create_grid_window()
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()