├── pic └── 使用示例.jpg ├── yolov8n.pt ├── requirements.txt ├── WSL+win ├── start_server.sh ├── api_server.py ├── windows_client.py └── yolo.py ├── README.md └── simple_yolo_mirror.py /pic/使用示例.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danghb/moyu_yolov8/HEAD/pic/使用示例.jpg -------------------------------------------------------------------------------- /yolov8n.pt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danghb/moyu_yolov8/HEAD/yolov8n.pt -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | opencv-python>=4.8.0 2 | ultralytics>=8.0.0 3 | Pillow>=9.0.0 4 | pystray>=0.19.0 5 | numpy>=1.21.0 6 | torch>=2.0.0 7 | torchvision>=0.15.0 -------------------------------------------------------------------------------- /WSL+win/start_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "--- 正在啟動 YOLOv8 API 伺服器 ---" 3 | echo "--- 作者: Gemini ---" 4 | 5 | # 進入我們為 Python 3.10 建立的專案目錄 6 | cd ~/headless_service_py310 7 | 8 | # 啟動虛擬環境 9 | source venv/bin/activate 10 | 11 | # 執行 Python API 伺服器 12 | # 注意: 我們在這裡直接執行 python api_server.py 13 | # uvicorn 會在程式碼內部被呼叫 14 | python ~/api_server.py 15 | -------------------------------------------------------------------------------- /WSL+win/api_server.py: -------------------------------------------------------------------------------- 1 | # api_server.py - 運行在 WSL (Ubuntu) 上 2 | import uvicorn 3 | from fastapi import FastAPI, File, UploadFile 4 | from ultralytics import YOLO 5 | import numpy as np 6 | import cv2 7 | from typing import List 8 | 9 | print("正在啟動 FastAPI 伺服器...") 10 | 11 | # 建立 FastAPI 應用 12 | app = FastAPI(title="YOLOv8 Detection API", version="1.0") 13 | 14 | # --- 全域載入模型 (效能最佳化) --- 15 | # 模型只需要在伺服器啟動時載入一次 16 | print("正在載入 YOLOv8 模型到 GPU...") 17 | try: 18 | model = YOLO("yolov8n.pt") 19 | # 預熱模型,讓第一次請求速度更快 20 | model.predict(np.zeros((640, 480, 3)), device='cuda', verbose=False) 21 | print("模型載入並預熱成功!") 22 | except Exception as e: 23 | print(f"模型載入失敗: {e}") 24 | model = None 25 | 26 | # --- API 端點 (Endpoint) --- 27 | @app.post("/detect/") 28 | async def detect_image(image: UploadFile = File(...)): 29 | """ 30 | 接收上傳的圖片檔案,執行 YOLOv8 偵測,並回傳 JSON 結果。 31 | """ 32 | if not model: 33 | return {"error": "模型未成功載入,無法進行偵測。"} 34 | 35 | # 1. 讀取並解碼圖片 36 | contents = await image.read() 37 | nparr = np.frombuffer(contents, np.uint8) 38 | frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) 39 | 40 | if frame is None: 41 | return {"error": "無法解碼圖片。"} 42 | 43 | # 2. 執行 YOLO 偵測 44 | results = model.predict(frame, conf=0.5, classes=[0], device='cuda', verbose=False) 45 | 46 | # 3. 處理並格式化結果 47 | boxes_data = [] 48 | person_count = 0 49 | for box in results[0].boxes: 50 | person_count += 1 51 | x1, y1, x2, y2 = map(int, box.xyxy[0]) 52 | confidence = float(box.conf[0]) 53 | boxes_data.append({ 54 | "x1": x1, 55 | "y1": y1, 56 | "x2": x2, 57 | "y2": y2, 58 | "confidence": round(confidence, 2) 59 | }) 60 | 61 | # 4. 回傳 JSON 62 | return { 63 | "person_count": person_count, 64 | "boxes": boxes_data 65 | } 66 | 67 | # --- 啟動伺服器 --- 68 | if __name__ == "__main__": 69 | # 監聽 0.0.0.0 可以讓 Windows 端的請求訪問到 70 | uvicorn.run("api_server:app", host="0.0.0.0", port=8888, reload=False) -------------------------------------------------------------------------------- /WSL+win/windows_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import cv2 3 | import time 4 | import socket 5 | import json 6 | import threading 7 | import queue 8 | import base64 9 | 10 | # --- YOLO API 配置 --- 11 | WSL_IP = "172.26.8.96" 12 | API_URL = f"http://{WSL_IP}:8888/detect/" 13 | 14 | # --- TCP 配置 --- 15 | HOST = "0.0.0.0" 16 | PORT = 9999 17 | 18 | # --- 共享队列 --- 19 | result_queue = queue.Queue(maxsize=1) # 存最新结果 20 | 21 | def capture_thread(): 22 | """采集 + YOLO 检测线程""" 23 | cap = cv2.VideoCapture(0) 24 | if not cap.isOpened(): 25 | print("無法打開攝影機!") 26 | return 27 | 28 | while True: 29 | ret, frame = cap.read() 30 | if not ret: 31 | break 32 | 33 | # 压缩成 JPEG 34 | _, img_encoded = cv2.imencode('.jpg', frame) 35 | files = {'image': ('image.jpg', img_encoded.tobytes(), 'image/jpeg')} 36 | 37 | data = {"person_count": 0, "boxes": [], "frame": None} 38 | try: 39 | response = requests.post(API_URL, files=files, timeout=5) 40 | if response.status_code == 200: 41 | data = response.json() 42 | except requests.exceptions.RequestException as e: 43 | print(f"YOLO 連線錯誤: {e}") 44 | 45 | # 保存原始帧,用 Base64 编码 46 | _, jpeg_frame = cv2.imencode('.jpg', frame) 47 | frame_b64 = base64.b64encode(jpeg_frame.tobytes()).decode('utf-8') 48 | data["frame"] = frame_b64 49 | 50 | # 放进队列(覆盖旧数据) 51 | if result_queue.full(): 52 | result_queue.get_nowait() 53 | result_queue.put_nowait(data) 54 | 55 | time.sleep(1/5) # 控制频率 56 | 57 | cap.release() 58 | 59 | def client_handler(conn, addr): 60 | """处理单个客户端连接""" 61 | print(f"客戶端已連線: {addr}") 62 | try: 63 | while True: 64 | try: 65 | data = result_queue.get(timeout=1) 66 | except queue.Empty: 67 | continue 68 | 69 | msg = json.dumps(data).encode("utf-8") 70 | conn.sendall(len(msg).to_bytes(4, "big") + msg) 71 | 72 | except Exception as e: 73 | print(f"客戶端斷開 {addr}: {e}") 74 | finally: 75 | conn.close() 76 | 77 | def tcp_server_thread(): 78 | """TCP 服务器线程,支持多个客户端""" 79 | server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 80 | server.bind((HOST, PORT)) 81 | server.listen(5) 82 | print(f"TCP 服務啟動,監聽 {PORT}") 83 | 84 | while True: 85 | conn, addr = server.accept() 86 | t = threading.Thread(target=client_handler, args=(conn, addr), daemon=True) 87 | t.start() 88 | 89 | def main(): 90 | threading.Thread(target=capture_thread, daemon=True).start() 91 | tcp_server_thread() 92 | 93 | if __name__ == "__main__": 94 | main() 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YOLO摸鱼后视镜项目 2 | 3 | 为了解决摸鱼时总担心后面有没有人的问题,找哈基米(gemini)开发了一个基于python的摸鱼后视镜项目,实时告诉你摄像头里看到几个人。 4 | 5 | 还能用桌面小浮窗看到具体人在哪里。让你做到摸鱼心里有底。2333333 6 | 7 | ![image](https://github.com/danghb/moyu_yolov8/blob/master/pic/%E4%BD%BF%E7%94%A8%E7%A4%BA%E4%BE%8B.jpg) 8 | 9 | ## 🚀 快速开始(推荐新手) 10 | 11 | ### 一键启动简化版本 12 | ```powershell 13 | # 安装依赖 我在python 3.13.3上可以运行,其他版本没试过 14 | pip install -r requirements.txt 15 | 16 | # 直接运行简化版 17 | python single_yolo_mirror.py 18 | ``` 19 | 20 | **简化版特点:** 21 | - ✅ 单文件运行,无需复杂配置 22 | - ✅ CPU模式,兼容性好 23 | - ✅ 即装即用,适合快速体验 24 | - ⚠️ 性能较低,检测速度慢 25 | 26 | **性能提升:** 如果追求更高性能,可以自行研究WSL环境配置或使用AI助手修改代码启用GPU加速,详见下方WSL+CUDA版本说明。 27 | 28 | --- 29 | 30 | ## 📁 文件结构 31 | 32 | ``` 33 | yolo后视镜/ 34 | ├── WSL+win/ # GPU 加速版本(需要自行解决环境问题) 35 | │ ├── api_server.py # WSL 端 FastAPI 服务器 36 | │ ├── windows_client.py # Windows 端摄像头采集和转发服务 37 | │ ├── yolo.py # 客户端程序(托盘 + 显示) 38 | │ └── start_server.sh # WSL 端启动脚本 39 | ├── simple_yolo_mirror.py # 简化版单文件程序(CPU模式,新手推荐) 40 | ├── requirements.txt # Python 依赖包列表 41 | ├── yolov8n.pt # YOLOv8 预训练模型文件 42 | └── README.md # 项目说明文档 43 | ``` 44 | 45 | ## 🎮 程序说明 46 | 47 | ### 系统托盘功能 48 | 49 | - **托盘图标** - 显示当前检测到的人数 50 | - **右键菜单**: 51 | - 显示浮窗 - 显示可拖拽的人数浮窗 52 | - 隐藏浮窗 - 隐藏人数浮窗 53 | - 显示视频窗口 - 显示实时视频预览(带检测框) 54 | - 退出 - 关闭程序 55 | 56 | ### 浮窗操作 57 | 58 | - **拖拽移动** - 点击并拖拽浮窗到任意位置 59 | - **半透明显示** - 不影响其他应用的使用 60 | - **实时更新** - 显示最新的人数统计 61 | 62 | ### 视频预览窗口 63 | 64 | - **实时画面** - 显示摄像头采集的实时画面 65 | - **检测框显示** - 红色矩形框标出检测到的人员 66 | - **拖拽移动** - 可拖拽到任意位置 67 | - **右键关闭** - 右键点击关闭窗口 68 | 69 | ## 🏗️ 系统架构 70 | 71 | ``` 72 | ┌─────────────────┐ HTTP API ┌─────────────────┐ TCP Socket ┌─────────────────┐ 73 | │ WSL/Ubuntu │ ◄──────────── │ Windows 端 │ ◄──────────────► │ 客户端 │ 74 | │ │ │ │ │ │ 75 | │ • YOLOv8 模型 │ │ • 摄像头采集 │ │ • 系统托盘 │ 76 | │ • FastAPI 服务 │ │ • 图像处理 │ │ • 浮窗显示 │ 77 | │ • GPU 加速 │ │ • TCP 服务器 │ │ • 视频预览 │ 78 | └─────────────────┘ └─────────────────┘ └─────────────────┘ 79 | ``` 80 | ## ⚙️ 配置说明 81 | 82 | ### 1. WSL + CUDA 环境配置 83 | 84 | **注意:** `WSL+win` 文件夹中的代码是为了使用 GPU 加速而设计的。WSL 环境下安装 CUDA 更加便捷,能够充分利用 GPU 性能进行 YOLO 推理加速。 85 | 86 | - **推荐用户:** 熟悉 CUDA 环境配置的用户 87 | - **安装建议:** 可以结合 AI 助手(如 ChatGPT、Claude 等)来协助安装和配置 WSL + CUDA 环境 88 | - **性能优势:** GPU 加速可显著提升检测速度和响应性能 89 | 90 | ### 2. 网络配置 91 | 92 | 在 `WSL+win/windows_client.py` 中修改 WSL IP 地址: 93 | 94 | ```python 95 | WSL_IP = "172.26.8.96" # 替换为你的 WSL IP 地址 96 | ``` 97 | 98 | 在 `WSL+win/yolo.py` 中修改服务器 IP 地址: 99 | 100 | ```python 101 | SERVER_IP = "192.168.233.1" # 替换为你的 Windows IP 地址 102 | ``` 103 | 104 | 105 | ## 🚀 启动步骤 106 | 107 | ### 1. 启动 WSL 端 YOLO API 服务 108 | 109 | ```bash 110 | cd WSL+win 111 | chmod +x start_server.sh 112 | ./start_server.sh 113 | ``` 114 | 115 | ### 2. 启动 Windows 端服务 116 | 117 | ```powershell 118 | # 在 WSL+win 目录中执行 119 | cd WSL+win 120 | python windows_client.py 121 | ``` 122 | 123 | ### 3. 启动客户端 124 | 125 | ```powershell 126 | # 在 WSL+win 目录中执行 127 | cd WSL+win 128 | python yolo.py 129 | ``` 130 | 131 | 132 | -------------------------------------------------------------------------------- /WSL+win/yolo.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import threading 3 | import json 4 | import time 5 | import base64 6 | import pystray 7 | from pystray import MenuItem as item 8 | from PIL import Image, ImageDraw, ImageFont, ImageTk 9 | import tkinter as tk 10 | from io import BytesIO 11 | 12 | SERVER_IP = "192.168.233.1" # 服务器 IP 13 | PORT = 9999 14 | 15 | # -------------------- 浮窗显示人数 -------------------- 16 | class FloatingWindow: 17 | def __init__(self): 18 | self.root = tk.Tk() 19 | self.root.overrideredirect(True) 20 | self.root.attributes("-topmost", True) 21 | self.root.attributes("-alpha", 0.5) 22 | self.root.geometry("150x50+1700+950") 23 | self.label = tk.Label(self.root, text="0", font=("Arial", 24), bg="black", fg="white") 24 | self.label.pack(fill="both", expand=True) 25 | self.label.bind("", self.start_move) 26 | self.label.bind("", self.do_move) 27 | self.root.withdraw() 28 | 29 | def start_move(self, event): 30 | self.start_x = event.x 31 | self.start_y = event.y 32 | 33 | def do_move(self, event): 34 | x = self.root.winfo_x() + event.x - self.start_x 35 | y = self.root.winfo_y() + event.y - self.start_y 36 | self.root.geometry(f"+{x}+{y}") 37 | 38 | def update(self, count): 39 | self.label.config(text=str(count)) 40 | 41 | def show(self): 42 | self.root.deiconify() 43 | 44 | def hide(self): 45 | self.root.withdraw() 46 | 47 | # -------------------- 视频窗口 -------------------- 48 | class VideoWindow: 49 | def __init__(self, on_hide_callback=None): 50 | self.root = tk.Toplevel() 51 | self.root.overrideredirect(True) 52 | self.root.attributes("-topmost", True) 53 | self.width = 107 54 | self.height = 80 55 | self.root.geometry(f"{self.width}x{self.height}+1600+900") 56 | self.root.attributes("-alpha", 0.2) 57 | self.root.protocol("WM_DELETE_WINDOW", self.hide) 58 | self.label = tk.Label(self.root) 59 | self.label.pack(fill="both", expand=True) 60 | self.root.withdraw() 61 | self.running = False 62 | self.boxes = [] 63 | self.frame_size = (self.width, self.height) 64 | self.on_hide_callback = on_hide_callback 65 | 66 | # 拖动支持 67 | self.label.bind("", self.start_move) 68 | self.label.bind("", self.do_move) 69 | 70 | # 右键关闭窗口 71 | self.label.bind("", lambda e: self.hide()) 72 | 73 | def start_move(self, event): 74 | self.start_x = event.x 75 | self.start_y = event.y 76 | 77 | def do_move(self, event): 78 | x = self.root.winfo_x() + event.x - self.start_x 79 | y = self.root.winfo_y() + event.y - self.start_y 80 | self.root.geometry(f"+{x}+{y}") 81 | 82 | def show(self): 83 | self.root.deiconify() 84 | self.running = True 85 | 86 | def hide(self): 87 | self.running = False 88 | self.root.withdraw() 89 | if self.on_hide_callback: 90 | self.on_hide_callback() # 同步勾选状态 91 | 92 | def update_boxes(self, boxes): 93 | self.boxes = boxes or [] 94 | 95 | def update_frame_from_server(self, frame_b64): 96 | if not self.running or not frame_b64: 97 | return 98 | try: 99 | frame_bytes = base64.b64decode(frame_b64) 100 | img = Image.open(BytesIO(frame_bytes)) 101 | W_server, H_server = img.size 102 | W_client, H_client = self.frame_size 103 | img = img.resize(self.frame_size) 104 | draw = ImageDraw.Draw(img) 105 | 106 | for box in self.boxes: 107 | if isinstance(box, (list, tuple)) and len(box) >= 4: 108 | x1, y1, x2, y2 = box[:4] 109 | elif isinstance(box, dict): 110 | x1 = box.get("x1", 0) 111 | y1 = box.get("y1", 0) 112 | x2 = box.get("x2", 0) 113 | y2 = box.get("y2", 0) 114 | else: 115 | continue 116 | # 按比例缩放 117 | x1 = int(x1 * W_client / W_server) 118 | y1 = int(y1 * H_client / H_server) 119 | x2 = int(x2 * W_client / W_server) 120 | y2 = int(y2 * H_client / H_server) 121 | draw.rectangle([x1, y1, x2, y2], outline="red", width=2) 122 | 123 | img_tk = ImageTk.PhotoImage(img) 124 | self.label.imgtk = img_tk 125 | self.label.config(image=img_tk) 126 | except Exception as e: 127 | print("更新视频错误:", e) 128 | 129 | # -------------------- 托盘客户端 -------------------- 130 | class TrayClient: 131 | def __init__(self, server_ip, port, floating_window): 132 | self.server_ip = server_ip 133 | self.port = port 134 | self.sock = None 135 | self.running = True 136 | self.person_count = 0 137 | self.floating = floating_window 138 | 139 | self.show_video = False 140 | self.video_window = VideoWindow(on_hide_callback=self.on_video_hidden) 141 | 142 | self.icon = pystray.Icon("YOLO", self.create_icon(0), "人數: 0") 143 | self.icon.menu = pystray.Menu( 144 | item("顯示浮窗", self.show_floating), 145 | item("隱藏浮窗", self.hide_floating), 146 | item("顯示視頻窗口", self.toggle_video, checked=lambda item: self.show_video), 147 | item("退出", self.exit_app) 148 | ) 149 | 150 | # 启动线程 151 | self.tcp_thread = threading.Thread(target=self.client_thread, daemon=True) 152 | self.tcp_thread.start() 153 | self.video_thread = threading.Thread(target=self.video_loop, daemon=True) 154 | self.video_thread.start() 155 | threading.Thread(target=self.icon.run, daemon=True).start() 156 | 157 | def create_icon(self, number): 158 | size = 64 159 | img = Image.new("RGBA", (size, size), (0,0,0,0)) 160 | draw = ImageDraw.Draw(img) 161 | text = str(number) 162 | font_size = 64 163 | try: 164 | font = ImageFont.truetype("arial.ttf", font_size) 165 | except: 166 | font = ImageFont.load_default() 167 | bbox = draw.textbbox((0,0), text, font=font) 168 | w, h = bbox[2]-bbox[0], bbox[3]-bbox[1] 169 | draw.text(((size-w)//2, (size-h)//2 - bbox[1]), text, font=font, fill=(255,0,0,255)) 170 | return img 171 | 172 | def show_floating(self, icon=None, item=None): 173 | self.floating.root.after(0, self.floating.show) 174 | 175 | def hide_floating(self, icon=None, item=None): 176 | self.floating.root.after(0, self.floating.hide) 177 | 178 | def toggle_video(self, icon=None, item=None): 179 | self.show_video = not self.show_video 180 | if self.show_video: 181 | self.video_window.show() 182 | else: 183 | self.video_window.hide() 184 | self.icon.update_menu() # 刷新菜单勾选 185 | 186 | def on_video_hidden(self): 187 | self.show_video = False 188 | self.icon.update_menu() # 用户手动关闭窗口同步勾选 189 | 190 | def exit_app(self, icon=None, item=None): 191 | self.running = False 192 | try: 193 | if self.sock: 194 | self.sock.shutdown(socket.SHUT_RDWR) 195 | self.sock.close() 196 | except: 197 | pass 198 | self.video_window.hide() 199 | self.floating.root.after(0, self.floating.root.destroy) 200 | self.icon.stop() 201 | 202 | def client_thread(self): 203 | buffer = b'' 204 | while self.running: 205 | try: 206 | if not self.sock: 207 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 208 | self.sock.settimeout(5) 209 | self.sock.connect((self.server_ip, self.port)) 210 | print("已连接服务器") 211 | 212 | data = self.sock.recv(65536) 213 | if not self.running: 214 | break 215 | if not data: 216 | self.sock.close() 217 | self.sock = None 218 | time.sleep(3) 219 | continue 220 | buffer += data 221 | 222 | while len(buffer) >= 4: 223 | msg_len = int.from_bytes(buffer[:4], "big") 224 | if len(buffer) < 4 + msg_len: 225 | break 226 | msg_data = buffer[4:4+msg_len] 227 | buffer = buffer[4+msg_len:] 228 | try: 229 | json_data = json.loads(msg_data.decode("utf-8")) 230 | self.person_count = int(json_data.get("person_count", 0)) 231 | boxes = json_data.get("boxes", []) 232 | frame_b64 = json_data.get("frame", None) 233 | self.video_window.update_boxes(boxes) 234 | if frame_b64: 235 | self.video_window.update_frame_from_server(frame_b64) 236 | except Exception as e: 237 | print("解析数据错误:", e) 238 | continue 239 | 240 | self.floating.root.after(0, self.floating.update, self.person_count) 241 | self.icon.icon = self.create_icon(self.person_count) 242 | self.icon.title = f"人數: {self.person_count}" 243 | 244 | except (socket.timeout, ConnectionRefusedError): 245 | print("无法连接服务器,3秒后重试...") 246 | if self.sock: 247 | try: 248 | self.sock.shutdown(socket.SHUT_RDWR) 249 | self.sock.close() 250 | except: 251 | pass 252 | self.sock = None 253 | time.sleep(3) 254 | except Exception as e: 255 | if self.running: 256 | print("TCP线程错误:", e) 257 | time.sleep(3) 258 | 259 | def video_loop(self): 260 | while self.running: 261 | if self.show_video: 262 | try: 263 | self.video_window.root.update_idletasks() 264 | self.video_window.root.update() 265 | except: 266 | pass 267 | time.sleep(0.03) 268 | 269 | # -------------------- 主程序 -------------------- 270 | if __name__ == "__main__": 271 | floating_window = FloatingWindow() 272 | client = TrayClient(SERVER_IP, PORT, floating_window) 273 | floating_window.root.mainloop() 274 | -------------------------------------------------------------------------------- /simple_yolo_mirror.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | 简化版YOLO后视镜 - 摸鱼神器 5 | 将所有功能整合到一个文件中,无需GPU加速 6 | 功能:实时人员检测、系统托盘显示、可拖拽浮窗、视频预览 7 | """ 8 | 9 | import cv2 10 | import numpy as np 11 | import threading 12 | import time 13 | import tkinter as tk 14 | from tkinter import messagebox 15 | import pystray 16 | from pystray import MenuItem as item 17 | from PIL import Image, ImageDraw, ImageFont, ImageTk 18 | from ultralytics import YOLO 19 | import queue 20 | import sys 21 | import os 22 | from io import BytesIO 23 | import base64 24 | 25 | class SimpleYOLOMirror: 26 | def __init__(self): 27 | self.running = True 28 | self.person_count = 0 29 | self.model = None 30 | self.cap = None 31 | self.current_frame = None 32 | self.current_boxes = [] 33 | self.quitting = False # 添加退出标志,避免重复调用quit_app 34 | 35 | # 创建队列用于线程间通信 36 | self.result_queue = queue.Queue(maxsize=1) 37 | 38 | # 初始化组件 39 | self.init_model() 40 | self.init_camera() 41 | self.init_floating_window() 42 | self.init_video_window() 43 | self.init_tray() 44 | 45 | def init_model(self): 46 | """初始化YOLO模型""" 47 | try: 48 | print("正在加载YOLOv8模型...") 49 | self.model = YOLO("yolov8n.pt") # 使用nano版本,速度更快 50 | print("模型加载成功!") 51 | except Exception as e: 52 | print(f"模型加载失败: {e}") 53 | messagebox.showerror("错误", f"YOLO模型加载失败: {e}") 54 | sys.exit(1) 55 | 56 | def init_camera(self): 57 | """初始化摄像头""" 58 | try: 59 | self.cap = cv2.VideoCapture(0) 60 | if not self.cap.isOpened(): 61 | raise Exception("无法打开摄像头") 62 | print("摄像头初始化成功!") 63 | except Exception as e: 64 | print(f"摄像头初始化失败: {e}") 65 | messagebox.showerror("错误", f"摄像头初始化失败: {e}") 66 | sys.exit(1) 67 | 68 | def init_floating_window(self): 69 | """初始化浮窗""" 70 | self.floating_window = FloatingWindow() 71 | 72 | def init_video_window(self): 73 | """初始化视频窗口""" 74 | self.show_video = False 75 | self.video_window = VideoWindow(on_hide_callback=self.on_video_hidden) 76 | 77 | def init_tray(self): 78 | """初始化系统托盘""" 79 | self.icon = pystray.Icon( 80 | "YOLO", 81 | self.create_icon(0), 82 | "人數: 0" 83 | ) 84 | self.icon.menu = pystray.Menu( 85 | item("顯示浮窗", self.show_floating), 86 | item("隱藏浮窗", self.hide_floating), 87 | item("顯示視頻窗口", self.toggle_video, checked=lambda item: self.show_video), 88 | item("退出", self.quit_app) 89 | ) 90 | 91 | def create_icon(self, number): 92 | """创建托盘图标""" 93 | size = 64 94 | img = Image.new("RGBA", (size, size), (0,0,0,0)) 95 | draw = ImageDraw.Draw(img) 96 | text = str(number) 97 | font_size = 64 98 | try: 99 | font = ImageFont.truetype("arial.ttf", font_size) 100 | except: 101 | font = ImageFont.load_default() 102 | bbox = draw.textbbox((0,0), text, font=font) 103 | w, h = bbox[2]-bbox[0], bbox[3]-bbox[1] 104 | draw.text(((size-w)//2, (size-h)//2 - bbox[1]), text, font=font, fill=(255,0,0,255)) 105 | return img 106 | 107 | def show_floating(self, icon=None, item=None): 108 | """显示浮窗""" 109 | self.floating_window.root.after(0, self.floating_window.show) 110 | 111 | def hide_floating(self, icon=None, item=None): 112 | """隐藏浮窗""" 113 | self.floating_window.root.after(0, self.floating_window.hide) 114 | 115 | def toggle_video(self, icon=None, item=None): 116 | """切换视频窗口显示""" 117 | self.show_video = not self.show_video 118 | if self.show_video: 119 | self.video_window.show() 120 | else: 121 | self.video_window.hide() 122 | self.icon.update_menu() # 刷新菜单勾选 123 | 124 | def on_video_hidden(self): 125 | """视频窗口被隐藏时的回调""" 126 | self.show_video = False 127 | self.icon.update_menu() # 用户手动关闭窗口同步勾选 128 | 129 | def quit_app(self, icon=None, item=None): 130 | """退出应用""" 131 | if self.quitting: # 避免重复调用 132 | return 133 | 134 | self.quitting = True 135 | print("正在退出...") 136 | self.running = False 137 | 138 | try: 139 | # 等待线程结束 140 | time.sleep(0.5) 141 | 142 | # 释放摄像头资源 143 | if self.cap: 144 | self.cap.release() 145 | print("摄像头资源已释放") 146 | 147 | # 安全关闭视频窗口 148 | if hasattr(self, 'video_window') and self.video_window: 149 | try: 150 | self.video_window.hide() 151 | except: 152 | pass 153 | 154 | # 安全关闭浮窗 155 | if hasattr(self, 'floating_window') and self.floating_window: 156 | try: 157 | if self.floating_window.root.winfo_exists(): 158 | self.floating_window.root.quit() 159 | self.floating_window.root.destroy() 160 | except: 161 | pass 162 | 163 | except Exception as e: 164 | print(f"退出过程中出现错误: {e}") 165 | finally: 166 | # 最后停止托盘 167 | try: 168 | if hasattr(self, 'icon'): 169 | self.icon.stop() 170 | except: 171 | pass 172 | 173 | def detect_persons(self, frame): 174 | """检测画面中的人员""" 175 | try: 176 | # 使用YOLO进行检测,只检测人员(class=0) 177 | results = self.model.predict( 178 | frame, 179 | conf=0.5, # 置信度阈值 180 | classes=[0], # 只检测人员 181 | device='cpu', # 使用CPU 182 | verbose=False 183 | ) 184 | 185 | person_count = 0 186 | boxes = [] 187 | if results and len(results) > 0 and results[0].boxes is not None: 188 | person_count = len(results[0].boxes) 189 | # 提取检测框坐标 190 | for box in results[0].boxes: 191 | x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() 192 | boxes.append([float(x1), float(y1), float(x2), float(y2)]) 193 | 194 | return person_count, boxes 195 | 196 | except Exception as e: 197 | print(f"检测错误: {e}") 198 | return 0, [] 199 | 200 | def camera_thread(self): 201 | """摄像头采集和检测线程""" 202 | print("摄像头线程启动") 203 | 204 | while self.running: 205 | try: 206 | ret, frame = self.cap.read() 207 | if not ret: 208 | print("无法读取摄像头画面") 209 | time.sleep(1) 210 | continue 211 | 212 | # 保存当前帧 213 | self.current_frame = frame.copy() 214 | 215 | # 检测人员数量和位置 216 | person_count, boxes = self.detect_persons(frame) 217 | self.current_boxes = boxes 218 | 219 | # 更新结果队列 220 | if not self.result_queue.full(): 221 | try: 222 | self.result_queue.put_nowait((person_count, boxes)) 223 | except queue.Full: 224 | pass 225 | 226 | # 控制检测频率,避免CPU占用过高 227 | time.sleep(0.5) # 每0.5秒检测一次 228 | 229 | except Exception as e: 230 | print(f"摄像头线程错误: {e}") 231 | time.sleep(1) 232 | 233 | print("摄像头线程结束") 234 | 235 | def update_display(self): 236 | """更新显示线程""" 237 | print("显示更新线程启动") 238 | 239 | while self.running: 240 | try: 241 | # 从队列获取最新结果 242 | try: 243 | person_count, boxes = self.result_queue.get(timeout=1) 244 | self.person_count = person_count 245 | 246 | # 更新托盘图标 247 | if not self.quitting: 248 | self.icon.icon = self.create_icon(person_count) 249 | self.icon.title = f"人數: {person_count}" 250 | 251 | # 更新浮窗 252 | if not self.quitting and hasattr(self, 'floating_window'): 253 | try: 254 | if self.floating_window.root.winfo_exists(): 255 | self.floating_window.root.after(0, self.floating_window.update, person_count) 256 | except: 257 | pass 258 | 259 | # 更新视频窗口 260 | if self.show_video and self.current_frame is not None and not self.quitting: 261 | try: 262 | self.video_window.update_boxes(boxes) 263 | self.video_window.update_frame(self.current_frame) 264 | except: 265 | pass 266 | 267 | except queue.Empty: 268 | continue 269 | 270 | except Exception as e: 271 | if not self.quitting: 272 | print(f"显示更新错误: {e}") 273 | time.sleep(1) 274 | 275 | print("显示更新线程结束") 276 | 277 | def run(self): 278 | """启动应用""" 279 | print("启动摸鱼后视镜...") 280 | 281 | # 启动摄像头线程 282 | camera_thread = threading.Thread(target=self.camera_thread, daemon=True) 283 | camera_thread.start() 284 | 285 | # 启动显示更新线程 286 | update_thread = threading.Thread(target=self.update_display, daemon=True) 287 | update_thread.start() 288 | 289 | # 启动视频更新线程 290 | video_thread = threading.Thread(target=self.video_loop, daemon=True) 291 | video_thread.start() 292 | 293 | # 启动托盘线程 294 | threading.Thread(target=self.icon.run, daemon=True).start() 295 | 296 | # 启动浮窗主循环(阻塞主线程) 297 | try: 298 | self.floating_window.root.protocol("WM_DELETE_WINDOW", self.quit_app) 299 | self.floating_window.root.mainloop() 300 | except KeyboardInterrupt: 301 | print("收到中断信号") 302 | except Exception as e: 303 | print(f"主循环错误: {e}") 304 | finally: 305 | if not self.quitting: 306 | self.quit_app() 307 | 308 | def video_loop(self): 309 | """视频窗口更新循环""" 310 | while self.running: 311 | if self.show_video and not self.quitting: 312 | try: 313 | if self.video_window.root.winfo_exists(): 314 | self.video_window.root.update_idletasks() 315 | self.video_window.root.update() 316 | except: 317 | if not self.quitting: 318 | break 319 | time.sleep(0.03) 320 | 321 | # -------------------- 浮窗显示人数 -------------------- 322 | class FloatingWindow: 323 | def __init__(self): 324 | self.root = tk.Tk() 325 | self.root.overrideredirect(True) 326 | self.root.attributes("-topmost", True) 327 | self.root.attributes("-alpha", 0.5) 328 | self.root.geometry("150x50+1700+950") 329 | self.label = tk.Label(self.root, text="0", font=("Arial", 24), bg="black", fg="white") 330 | self.label.pack(fill="both", expand=True) 331 | self.label.bind("", self.start_move) 332 | self.label.bind("", self.do_move) 333 | self.root.withdraw() 334 | 335 | def start_move(self, event): 336 | self.start_x = event.x 337 | self.start_y = event.y 338 | 339 | def do_move(self, event): 340 | x = self.root.winfo_x() + event.x - self.start_x 341 | y = self.root.winfo_y() + event.y - self.start_y 342 | self.root.geometry(f"+{x}+{y}") 343 | 344 | def update(self, count): 345 | """更新人数显示""" 346 | self.label.config(text=str(count)) 347 | 348 | def show(self): 349 | self.root.deiconify() 350 | 351 | def hide(self): 352 | self.root.withdraw() 353 | 354 | # -------------------- 视频窗口 -------------------- 355 | class VideoWindow: 356 | def __init__(self, on_hide_callback=None): 357 | self.root = tk.Toplevel() 358 | self.root.overrideredirect(True) 359 | self.root.attributes("-topmost", True) 360 | self.width = 107 361 | self.height = 80 362 | self.root.geometry(f"{self.width}x{self.height}+1600+900") 363 | self.root.attributes("-alpha", 0.2) 364 | self.root.protocol("WM_DELETE_WINDOW", self.hide) 365 | self.label = tk.Label(self.root) 366 | self.label.pack(fill="both", expand=True) 367 | self.root.withdraw() 368 | self.running = False 369 | self.boxes = [] 370 | self.frame_size = (self.width, self.height) 371 | self.on_hide_callback = on_hide_callback 372 | 373 | # 拖动支持 374 | self.label.bind("", self.start_move) 375 | self.label.bind("", self.do_move) 376 | 377 | # 右键关闭窗口 378 | self.label.bind("", lambda e: self.hide()) 379 | 380 | def start_move(self, event): 381 | self.start_x = event.x 382 | self.start_y = event.y 383 | 384 | def do_move(self, event): 385 | x = self.root.winfo_x() + event.x - self.start_x 386 | y = self.root.winfo_y() + event.y - self.start_y 387 | self.root.geometry(f"+{x}+{y}") 388 | 389 | def show(self): 390 | self.root.deiconify() 391 | self.running = True 392 | 393 | def hide(self): 394 | self.running = False 395 | self.root.withdraw() 396 | if self.on_hide_callback: 397 | self.on_hide_callback() # 同步勾选状态 398 | 399 | def update_boxes(self, boxes): 400 | self.boxes = boxes or [] 401 | 402 | def update_frame(self, frame): 403 | if not self.running or frame is None: 404 | return 405 | try: 406 | # 转换OpenCV图像为PIL图像 407 | frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) 408 | img = Image.fromarray(frame_rgb) 409 | W_server, H_server = img.size 410 | W_client, H_client = self.frame_size 411 | img = img.resize(self.frame_size) 412 | draw = ImageDraw.Draw(img) 413 | 414 | for box in self.boxes: 415 | if isinstance(box, (list, tuple)) and len(box) >= 4: 416 | x1, y1, x2, y2 = box[:4] 417 | elif isinstance(box, dict): 418 | x1 = box.get("x1", 0) 419 | y1 = box.get("y1", 0) 420 | x2 = box.get("x2", 0) 421 | y2 = box.get("y2", 0) 422 | else: 423 | continue 424 | # 按比例缩放 425 | x1 = int(x1 * W_client / W_server) 426 | y1 = int(y1 * H_client / H_server) 427 | x2 = int(x2 * W_client / W_server) 428 | y2 = int(y2 * H_client / H_server) 429 | draw.rectangle([x1, y1, x2, y2], outline="red", width=2) 430 | 431 | img_tk = ImageTk.PhotoImage(img) 432 | self.label.imgtk = img_tk 433 | self.label.config(image=img_tk) 434 | except Exception as e: 435 | print("更新视频错误:", e) 436 | 437 | def main(): 438 | """主函数""" 439 | print("="*50) 440 | print(" 摸鱼后视镜 - 简化版") 441 | print(" 实时监控后方人员数量") 442 | print("="*50) 443 | 444 | try: 445 | app = SimpleYOLOMirror() 446 | app.run() 447 | except KeyboardInterrupt: 448 | print("\n程序被用户中断") 449 | except Exception as e: 450 | print(f"程序运行错误: {e}") 451 | messagebox.showerror("错误", f"程序运行错误: {e}") 452 | finally: 453 | print("程序结束") 454 | 455 | if __name__ == "__main__": 456 | main() --------------------------------------------------------------------------------