# -*- coding: utf-8 -*- """ 红绿灯检测模块 - 独立工作流版本 基于YOLO模型实时检测红绿灯状态,并通过语音反馈 可以通过语音命令"检测红绿灯"、"停止检测"来控制 """ import os import time import threading import cv2 import numpy as np from ultralytics import YOLO import bridge_io from audio_player import play_voice_text # 使用统一的语音播放接口 import logging # Day 20: TensorRT 模型加载工具 from model_utils import get_best_model_path logger = logging.getLogger(__name__) # ========= 配置参数 ========= # Day 20: 优先使用 TensorRT 引擎 YOLO_MODEL_PATH = get_best_model_path(os.path.join(os.path.dirname(__file__), "model", "trafficlight.pt")) # ========= 显示参数 ========= CONF_THRESHOLD = 0.25 # 置信度阈值 FONT_SIZE = 20 STROKE_WIDTH = 3 # ========= 语音播报参数 ========= TTS_INTERVAL_SEC = 2.0 # 语音播报间隔(避免频繁播报) ENABLE_TTS = False # 【禁用】红绿灯检测模块不播报,由 workflow_crossstreet.py 统一处理 # ========= 线程控制 ========= _detection_thread = None _stop_event = None _detection_running = False # ========= 单帧处理模式(新增)========= _model = None # 全局模型实例 _last_tts_ts = 0.0 _last_detected_light = None _detection_history = [] # ========= 前端配色(BGR) ========= FRONTEND_COLORS = { "text": (230, 237, 243), # 白色文字 "red": (0, 0, 255), # 红色 "yellow": (0, 255, 255), # 黄色 "green": (0, 255, 0), # 绿色 "muted": (159, 176, 195), # 灰色 } # 红绿灯状态到颜色的映射 LIGHT_COLORS = { "stop": FRONTEND_COLORS["red"], "countdown_go": FRONTEND_COLORS["yellow"], "go": FRONTEND_COLORS["green"], } # 【修正】红绿灯状态到中文的映射 # 只包含真正的红绿灯类别,排除斑马线(crossing)和空白 LIGHT_NAMES = { "stop": "红灯", # 机动车红灯 "go": "绿灯", # 机动车绿灯 "countdown_go": "黄灯", # 绿灯倒计时(用黄灯提示) "countdown_stop": "红灯", # 红灯倒计时 } # 红绿灯状态到语音文件的映射 LIGHT_VOICE_MAP = { "stop": "红灯", # → voice/红灯.WAV "go": "绿灯", # → voice/绿灯.WAV "countdown_go": "黄灯", # → voice/黄灯.WAV(绿灯倒计时用黄灯提示) "countdown_stop": "红灯", # → voice/红灯.WAV } # 需要过滤的类别(不检测、不显示) FILTERED_CLASSES = { "crossing", # 斑马线(不需要) "blank", # 空白 "countdown_blank" # 倒计时空白 } # UI文本管理 _UI_LINE = 0 _UI_H = 0 _UI_TR_LINE = 0 _UI_TOP_MARGIN = 12 _UI_RIGHT_MARGIN = 12 UNIFIED_FONT_PX = 12 def ui_reset_overlay(img_h: int): """每帧调用一次,重置叠加行计数""" global _UI_LINE, _UI_H, _UI_TR_LINE _UI_LINE = 0 _UI_TR_LINE = 0 _UI_H = int(img_h) def _ui_next_y_top(font_size: int) -> int: """返回右上角下一行的y坐标""" global _UI_TR_LINE line_gap = max(4, int(font_size * 0.25)) y_top = _UI_TOP_MARGIN + (_UI_TR_LINE * (font_size + line_gap)) _UI_TR_LINE += 1 return y_top # ======== 中文文本绘制 ======== _PIL_OK = False _FONT_PATH = None def _init_font(): global _PIL_OK, _FONT_PATH try: from PIL import ImageFont _PIL_OK = True except Exception: _PIL_OK = False return candidates = [ # Linux 中文字体路径 (Ubuntu/Debian) "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", ] for p in candidates: if os.path.exists(p): _FONT_PATH = p return _PIL_OK = False _init_font() def draw_text_cn(img_bgr, text, xy, font_size=20, color=(255,255,255), ui_hint=True): """统一的中文文本绘制""" color = (255, 255, 255) font_size = int(UNIFIED_FONT_PX) H, W = img_bgr.shape[:2] y_top = _ui_next_y_top(font_size) if ui_hint else xy[1] tw = th = 0 font_obj = None if _PIL_OK and _FONT_PATH: try: from PIL import Image, ImageDraw, ImageFont font_obj = ImageFont.truetype(_FONT_PATH, font_size) bbox = ImageDraw.Draw(Image.new('RGB', (1,1))).textbbox((0,0), text, font=font_obj) tw = max(1, bbox[2] - bbox[0]) th = max(1, bbox[3] - bbox[1]) except Exception: pass if _PIL_OK and _FONT_PATH and font_obj is not None: try: from PIL import Image, ImageDraw img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(img_rgb) draw = ImageDraw.Draw(pil_img) if ui_hint: x = max(8, W - _UI_RIGHT_MARGIN - tw) y = y_top else: x = xy[0] y = xy[1] draw.text((x, y), text, fill=color, font=font_obj) img_bgr[:] = cv2.cvtColor(np.asarray(pil_img), cv2.COLOR_RGB2BGR) return except Exception: pass # OpenCV 回退 if tw <= 0 or th <= 0: scale = font_size/24.0 (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, scale, 2) if ui_hint: x = max(8, W - _UI_RIGHT_MARGIN - int(tw)) y_baseline = int(y_top + th) else: x = xy[0] y_baseline = xy[1] + int(th) cv2.putText(img_bgr, text, (x, y_baseline), cv2.FONT_HERSHEY_SIMPLEX, font_size/24.0, color, 2, cv2.LINE_AA) def main(headless: bool = True, stop_event=None): """ 红绿灯检测主函数 参数: headless: 是否无头模式(不显示OpenCV窗口) stop_event: threading.Event,用于停止检测 """ print("[TRAFFIC] 加载 YOLO 红绿灯检测模型...") try: model = YOLO(YOLO_MODEL_PATH) print(f"[TRAFFIC] 模型加载成功: {YOLO_MODEL_PATH}") except Exception as e: print(f"[TRAFFIC] 模型加载失败: {e}") return # 获取类别名称 class_names = model.names if hasattr(model, 'names') else {} print(f"[TRAFFIC] 模型类别: {class_names}") # 状态跟踪 last_tts_ts = 0.0 last_detected_light = None fps_hist = [] # 【优化】状态稳定性判断 - 使用多数表决而非连续帧 detection_history = [] # 保存最近N帧的检测结果 HISTORY_SIZE = 5 # 保存最近5帧 MAJORITY_THRESHOLD = 3 # 5帧中至少3帧相同才认为稳定 # 【新增】帧统计 frame_count = 0 frame_received_count = 0 frame_none_count = 0 last_frame_log_time = time.time() print("[TRAFFIC] 等待 ESP32 画面...") try: while True: # 检查停止事件 if stop_event and stop_event.is_set(): print("[TRAFFIC] 停止事件触发,退出检测") break # 【优化】从bridge_io获取原始BGR帧 - 增加超时时间 frame = bridge_io.wait_raw_bgr(timeout_sec=2.0) # 从0.5秒增加到2秒 frame_count += 1 if frame is None: frame_none_count += 1 # 每3秒打印一次帧统计 current_time = time.time() if current_time - last_frame_log_time > 3.0: print(f"[TRAFFIC] 帧统计: 总={frame_count}, 收到={frame_received_count}, " f"丢失={frame_none_count}, 丢失率={frame_none_count/frame_count*100:.1f}%") last_frame_log_time = current_time if headless: cv2.waitKey(1) continue frame_received_count += 1 # 重置UI叠加 H, W = frame.shape[:2] ui_reset_overlay(H) vis = frame.copy() t_now = time.time() # 【优化】YOLO推理 - 添加计时 inference_start = time.time() results = model(frame, conf=CONF_THRESHOLD, verbose=False) inference_time = (time.time() - inference_start) * 1000 # 监控推理时间 if inference_time > 100: print(f"[TRAFFIC] WARNING: 推理耗时 {inference_time:.0f}ms") # 处理检测结果 detected_light = None max_conf = 0.0 if results and len(results) > 0: r = results[0] if r.boxes is not None and len(r.boxes) > 0: # 【过滤】遍历所有检测框,找到置信度最高的红绿灯(排除斑马线) for box in r.boxes: cls_id = int(box.cls[0]) conf = float(box.conf[0]) class_name = class_names.get(cls_id, f"class_{cls_id}") class_name_lower = class_name.lower() # 跳过不需要的类别 if class_name_lower in FILTERED_CLASSES: continue if conf > max_conf: max_conf = conf detected_light = class_name_lower # 【过滤】绘制检测框(只绘制红绿灯) for box in r.boxes: cls_id = int(box.cls[0]) conf = float(box.conf[0]) class_name = class_names.get(cls_id, f"class_{cls_id}") class_name_lower = class_name.lower() # 跳过不需要的类别 if class_name_lower in FILTERED_CLASSES: continue # 获取边界框坐标 x1, y1, x2, y2 = map(int, box.xyxy[0]) # 确定颜色 color = LIGHT_COLORS.get(class_name_lower, FRONTEND_COLORS["text"]) # 绘制边界框 cv2.rectangle(vis, (x1, y1), (x2, y2), color, STROKE_WIDTH) # 绘制中文标签(使用PIL) label = f"{LIGHT_NAMES.get(class_name.lower(), class_name)}: {conf:.2f}" if _PIL_OK and _FONT_PATH: try: from PIL import Image, ImageDraw, ImageFont # 使用较大的字体绘制标签 font_obj = ImageFont.truetype(_FONT_PATH, 20) # 转换为PIL图像 img_rgb = cv2.cvtColor(vis, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(img_rgb) draw = ImageDraw.Draw(pil_img) # 计算文本尺寸 bbox = draw.textbbox((0, 0), label, font=font_obj) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] # 标签位置 label_y = max(y1 - text_h - 8, text_h) # 绘制背景矩形 bg_x1 = x1 bg_y1 = label_y - text_h - 4 bg_x2 = x1 + text_w + 8 bg_y2 = label_y + 4 cv2.rectangle(vis, (bg_x1, bg_y1), (bg_x2, bg_y2), color, -1) # 重新转换(因为矩形是用OpenCV画的) img_rgb = cv2.cvtColor(vis, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(img_rgb) draw = ImageDraw.Draw(pil_img) # 【删除】绘制文字 # draw.text((x1 + 4, label_y - text_h), label, fill=(0, 0, 0), font=font_obj) # 转换回OpenCV格式 vis[:] = cv2.cvtColor(np.asarray(pil_img), cv2.COLOR_RGB2BGR) except Exception as e: # 【删除】PIL失败时的文本标签 pass else: # 【删除】文本标签 pass # 【优化】状态稳定性判断:使用多数表决而非连续帧 detection_history.append(detected_light) if len(detection_history) > HISTORY_SIZE: detection_history.pop(0) # 判断状态是否稳定(多数表决) stable_light = None if len(detection_history) >= MAJORITY_THRESHOLD: # 统计最近N帧中每个状态出现的次数 valid_detections = [d for d in detection_history if d and d in LIGHT_NAMES] if len(valid_detections) >= MAJORITY_THRESHOLD: # 找出现次数最多的状态 from collections import Counter counter = Counter(valid_detections) most_common = counter.most_common(1) if most_common and most_common[0][1] >= MAJORITY_THRESHOLD: stable_light = most_common[0][0] # 打印调试信息 if frame_received_count % 30 == 0: print(f"[TRAFFIC] 检测历史: {detection_history[-5:]}, 稳定状态: {stable_light}") # 【禁用语音播报】只检测不播报,由调用者(workflow_crossstreet.py)统一处理语音 # 只更新状态跟踪 if stable_light: # 状态改变时记录(但不播报) if stable_light != last_detected_light: last_detected_light = stable_light print(f"[TRAFFIC] 检测到稳定状态改变: {LIGHT_NAMES[stable_light]}(不播报)") last_tts_ts = t_now # 超过间隔时间,更新时间戳(但不播报) elif (t_now - last_tts_ts) > TTS_INTERVAL_SEC: print(f"[TRAFFIC] 稳定状态持续: {LIGHT_NAMES[stable_light]}(不播报)") last_tts_ts = t_now # 【删除】显示当前检测状态 # if detected_light and detected_light in LIGHT_NAMES: # status_text = f"检测: {LIGHT_NAMES[detected_light]} ({max_conf:.2f})" # color = LIGHT_COLORS[detected_light] # else: # status_text = "检测: 无" # color = FRONTEND_COLORS["muted"] # draw_text_cn(vis, status_text, (10, 40), font_size=18, color=color) # 【删除】显示稳定状态 # if stable_light: # stable_text = f"稳定状态: {LIGHT_NAMES[stable_light]}" # stable_color = LIGHT_COLORS[stable_light] # else: # stable_text = f"稳定状态: 等待中 ({len(detection_history)}/{HISTORY_SIZE})" # stable_color = FRONTEND_COLORS["muted"] # draw_text_cn(vis, stable_text, (10, 60), font_size=18, color=stable_color) # 【删除】FPS计算和显示 # fps_hist.append(t_now) # if len(fps_hist) > 30: # fps_hist.pop(0) # fps = 0.0 if len(fps_hist) < 2 else (len(fps_hist)-1)/(fps_hist[-1]-fps_hist[0]) # draw_text_cn(vis, f"FPS: {fps:.1f}", (10, 20), font_size=16, color=FRONTEND_COLORS["text"]) # 发送可视化结果到前端 bridge_io.send_vis_bgr(vis) # 非headless模式下显示窗口 if not headless: cv2.imshow("Traffic Light Detection", vis) key = cv2.waitKey(1) & 0xFF if key in (27, ord('q')): break else: cv2.waitKey(1) except Exception as e: print(f"[TRAFFIC] 检测过程出错: {e}") finally: if not headless: cv2.destroyAllWindows() print("[TRAFFIC] 红绿灯检测已停止") def start_detection(): """启动红绿灯检测(在后台线程中运行)""" global _detection_thread, _stop_event, _detection_running if _detection_running: print("[TRAFFIC] 红绿灯检测已在运行中") return False _stop_event = threading.Event() _detection_thread = threading.Thread( target=main, args=(True, _stop_event), # headless=True, stop_event daemon=True, name="TrafficLightDetection" ) _detection_thread.start() _detection_running = True print("[TRAFFIC] 红绿灯检测已启动(后台线程)") return True def stop_detection(): """停止红绿灯检测""" global _detection_thread, _stop_event, _detection_running if not _detection_running: print("[TRAFFIC] 红绿灯检测未运行") return False print("[TRAFFIC] 正在停止红绿灯检测...") if _stop_event: _stop_event.set() if _detection_thread: _detection_thread.join(timeout=2.0) _detection_thread = None _stop_event = None _detection_running = False print("[TRAFFIC] 红绿灯检测已停止") return True def is_detection_running(): """检查红绿灯检测是否正在运行""" return _detection_running def init_model(): """初始化YOLO模型(单帧处理模式)""" global _model if _model is not None: print("[TRAFFIC] 模型已加载") return True try: print("[TRAFFIC] 加载 YOLO 红绿灯检测模型...") _model = YOLO(YOLO_MODEL_PATH) print(f"[TRAFFIC] 模型加载成功: {YOLO_MODEL_PATH}") class_names = _model.names if hasattr(_model, 'names') else {} print(f"[TRAFFIC] 模型类别: {class_names}") return True except Exception as e: print(f"[TRAFFIC] 模型加载失败: {e}") _model = None return False def process_single_frame(image: np.ndarray, ui_broadcast_callback=None) -> dict: """ 处理单帧图像(主线程模式,避免掉帧) 参数: image: 输入图像 ui_broadcast_callback: 前端广播回调函数(用于显示红绿灯状态) 返回:{'vis_image': 可视化图像, 'detected_light': 检测到的灯, 'stable_light': 稳定状态} """ global _model, _last_tts_ts, _last_detected_light, _detection_history if _model is None: if not init_model(): return {'vis_image': image, 'detected_light': None, 'stable_light': None} vis = image.copy() t_now = time.time() # YOLO推理 results = _model(image, conf=CONF_THRESHOLD, verbose=False) # 处理检测结果 detected_light = None max_conf = 0.0 class_names = _model.names if hasattr(_model, 'names') else {} if results and len(results) > 0: r = results[0] if r.boxes is not None and len(r.boxes) > 0: # 遍历所有检测框,找到置信度最高的红绿灯(过滤掉crossing等) for box in r.boxes: cls_id = int(box.cls[0]) conf = float(box.conf[0]) class_name = class_names.get(cls_id, f"class_{cls_id}") class_name_lower = class_name.lower() # 【过滤】跳过不需要的类别(斑马线、空白等) if class_name_lower in FILTERED_CLASSES: continue if conf > max_conf: max_conf = conf detected_light = class_name_lower # 绘制检测框(只绘制红绿灯,不绘制斑马线) for box in r.boxes: cls_id = int(box.cls[0]) conf = float(box.conf[0]) class_name = class_names.get(cls_id, f"class_{cls_id}") class_name_lower = class_name.lower() # 【过滤】跳过不需要的类别 if class_name_lower in FILTERED_CLASSES: continue # 获取边界框坐标 x1, y1, x2, y2 = map(int, box.xyxy[0]) # 确定颜色 color = LIGHT_COLORS.get(class_name_lower, FRONTEND_COLORS["text"]) # 绘制边界框 cv2.rectangle(vis, (x1, y1), (x2, y2), color, STROKE_WIDTH) # 【放宽】状态稳定性判断(多数表决) - 降低要求 _detection_history.append(detected_light) if len(_detection_history) > 5: _detection_history.pop(0) stable_light = None if len(_detection_history) >= 2: # 从3帧降低到2帧 from collections import Counter valid_detections = [d for d in _detection_history if d and d in LIGHT_NAMES] if len(valid_detections) >= 2: # 从3帧降低到2帧 counter = Counter(valid_detections) most_common = counter.most_common(1) if most_common and most_common[0][1] >= 2: # 从3次降低到2次 stable_light = most_common[0][0] # 【调试】打印检测结果(已禁用) # print(f"[TRAFFIC-DEBUG] detected={detected_light}, stable={stable_light}, history={_detection_history}") # 【禁用语音播报】只检测不播报,由 workflow_crossstreet.py 统一处理语音 # 只更新状态跟踪,不调用 play_voice_text if stable_light: # 更新状态跟踪(用于检测状态变化) if stable_light != _last_detected_light: _last_detected_light = stable_light print(f"[TRAFFIC] 检测到稳定状态改变: {LIGHT_NAMES[stable_light]}(不播报)") _last_tts_ts = t_now elif (t_now - _last_tts_ts) > TTS_INTERVAL_SEC: # 超过间隔时间,更新时间戳(但不播报) print(f"[TRAFFIC] 稳定状态持续: {LIGHT_NAMES[stable_light]}(不播报)") _last_tts_ts = t_now # 【删除】状态文本显示 # if detected_light and detected_light in LIGHT_NAMES: # status_text = f"{LIGHT_NAMES[detected_light]} ({max_conf:.2f})" # else: # status_text = "无检测" # # if stable_light: # stable_text = f"稳定: {LIGHT_NAMES[stable_light]}" # else: # stable_text = f"等待稳定 ({len(_detection_history)}/5)" # # # 添加简单的文本显示 # cv2.putText(vis, status_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 2) # cv2.putText(vis, stable_text, (10, 70), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 2) return { 'vis_image': vis, 'detected_light': detected_light, 'stable_light': stable_light } def reset_detection_state(): """重置检测状态""" global _last_tts_ts, _last_detected_light, _detection_history _last_tts_ts = 0.0 _last_detected_light = None _detection_history = [] print("[TRAFFIC] 检测状态已重置") if __name__ == "__main__": main(headless=False)