J.J. Huang   2026-04-21   Python OpenCV 08.專案實作篇   瀏覽次數:次  

Python | OpenCV 專案:天堂私服遊戲輔助(三)YOLOv8 怪物偵測整合

📑 目錄
  1. ⚠️ 免責聲明
  2. 📚 前言
  3. 🎯 本篇目標
  4. 🗂️ 本篇新增檔案
  5. 🛠️ 套件安裝
  6. 📁 模型檔案從哪來
  7. 🗺️ 遊戲畫面 ROI 查表
    1. window_capture.py 新增常數與函式
    2. 附錄:新增解析度用的 calibrate_game_roi.py
  8. 💻 偵測模組:detector.py
  9. 💻 執行緒架構(本篇)
  10. 💻 主程式:main.py(第三篇版本)
  11. ⚠️ 注意事項
    1. 🧵 執行緒與熱切換
    2. ❓️ 新手常踩的雷
    3. 🗺️ ROI 與格子地圖座標
  12. 🎯 結語

⚠️ 免責聲明

本文章內容僅供學術研究與電腦視覺技術學習之用途,所有程式碼與技術說明均以教育目的為出發點。

  • 本文作者不提供任何形式的輔助程式販售、散佈或商業服務
  • 本文所有範例程式僅限在自行架設的私有伺服器環境中測試,不得用於任何正式營運的線上遊戲伺服器。
  • 使用遊戲輔助程式可能違反個別遊戲的使用者條款,並導致帳號封鎖、法律責任等後果,讀者須自行承擔一切相關風險與責任
  • 本文技術內容若被用於任何違法或損害他人利益之行為,作者概不負責
  • 私有伺服器的架設與使用涉及遊戲著作權相關法律問題,讀者應自行評估所在地區的法規,並確認於合法範圍內使用。

📚 前言

在上一篇 (二)自動補藥 中,我們完成了自動補藥功能,並透過 PostMessage 讓補藥熱鍵即使在背景也能送進目標視窗。

本篇加入 YOLOv8 怪物偵測,核心挑戰在於:YOLO 推論本身耗時,若直接塞進 Thread 1 會拖慢血量偵測的更新速率。解法是新增 Worker Thread 2 專門跑 YOLO,兩個執行緒透過 queue.Queue 溝通。​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

系列總覽:

篇次 主題
主程式 GUI 框架與 HP/MP 血量監控
自動補藥
本篇(三) YOLOv8 怪物偵測整合
四(最終篇) 目標優先序選擇

🎯 本篇目標

  • 建立 detector.py:封裝 YOLOv8 推論,並提供 live(畫 bbox)grid(扁平菱形小地圖) 兩種預覽模式
  • 新增 Thread 2detection_loop)專跑 YOLO,Thread 1 透過 frame_q = queue.Queue(maxsize=2) 丟舊幀給它,保證推論永遠跑最新畫面
  • UI 加入「怪物偵測設定」面板與即時預覽區塊,checkbox 與「🔄 載入模型」都可執行中熱切換
  • window_capture.py 內建 HUD_PCT_BY_SIZE 查表自動判定 ROI;附錄提供 calibrate_game_roi.py 讓你擴充新解析度

🗂️ 本篇新增檔案

1
2
3
4
5
6
7
8
9
10
lineage_assistant/
├── main.py ← 更新(怪物偵測設定面板 + 預覽切換 + Thread 2)
├── window_capture.py ← 更新(加入 HUD_PCT_BY_SIZE 查表 / get_game_roi / get_tile_px)
├── hp_monitor.py ← 不變
├── auto_potion.py ← 不變
├── detector.py ← 新增(本篇重點,含 live / grid 雙模式)
├── calibrate_game_roi.py ← 附錄(要支援新解析度時才跑)
├── models/
│ └── lineage_detector.pt
└── config.json

🛠️ 套件安裝

1
pip install ultralytics pillow

pywin32opencv-pythonnumpy 在前兩篇已安裝,不需要重複裝。

📁 模型檔案從哪來

本篇用到的 models/lineage_detector.pt,就是我們在 天堂私服 YOLOv8 物件偵測實戰 中一路從「私服截圖蒐集 → LabelImg 標記 → YOLOv8 訓練」產出的那顆 runs/detect/lineage_detector/weights/best.pt。​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

只要把它複製或搬到專案的 models/ 目錄下即可:

1
2
3
lineage_assistant/
└── models/
└── lineage_detector.pt ← 從 runs/detect/lineage_detector/weights/best.pt 搬過來

若你還沒訓練過自己的模型,請先回去看上一篇把訓練流程跑完。也可以先拿 yolov8s.pt 官方預訓練權重接上來驗證整條管線通不通,只是偵測結果會是 COCO 類別(person、car…)而非怪物。

Python - 圖 1 (detection architecture)
Python - 圖 2 (detection iso grid)​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

🗺️ 遊戲畫面 ROI 查表

天堂視窗並不是「拍到什麼都拿來算」:標題列、聊天框、小地圖、上方 HUD 這些區塊都不是真正的世界畫面,拿去推座標會錯。所以要把真正的遊戲畫面在整個視窗內的矩形切出來。

好消息是:Lineage 的內部解析度只有幾個固定選項(400×300、800×600、1200×900…),而且每種解析度配上相同的 Windows 標題列後,擷取到的視窗尺寸跟 HUD 佔比都是固定的。所以作法是:

  1. 作者私下用小工具對每種解析度量一次 HUD 佔比
  2. 把結果寫成查表常數放進 window_capture.py
  3. 程式啟動後依 capture_window 實際抓到的視窗大小查表,直接選對應 HUD_PCT

讀者不用自己校準 —— 除非你的遊戲解析度不在表內,才需要回去跑附錄的 calibrate_game_roi.py 補一筆。​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

window_capture.py 新增常數與函式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# window_capture.py 底部新增(本篇新增)

# ── Lineage 各解析度的 HUD 佔比查表 ──
# key 是 capture_window 實際抓到的 (win_w, win_h)(含標題列與邊框)
# value 是該解析度下 HUD 在上/下/左/右四邊各佔視窗的比例
HUD_PCT_BY_SIZE = {
(406, 329): { # 遊戲內 400×300
"top": 0.079,
"bottom": 0.185,
"left": 0.002,
"right": 0.000,
},
(802, 627): { # 遊戲內 800×600
"top": 0.080,
"bottom": 0.220,
"left": 0.000,
"right": 0.000,
},
(1206, 929): { # 遊戲內 1200×900
"top": 0.029,
"bottom": 0.195,
"left": 0.002,
"right": 0.000,
},
}

# 視野可視範圍:Lineage 實測螢幕寬能裝 ±8 tile-widths、螢幕高能裝 ±10 tile-heights
# 也就是 roi_w = 2·8·DW = 16·DW、roi_h = 2·10·DH = 20·DH(不是標準 iso 2:1 而是偏扁)
TILES_PER_HALF_WIDTH = 8 # 水平可視半徑(格)
TILES_PER_HALF_HEIGHT = 10 # 垂直可視半徑(格)


def _pick_hud(win_w: int, win_h: int):
"""依視窗尺寸從查表挑 HUD_PCT;找不到精確匹配就回傳面積最接近的一筆"""
if (win_w, win_h) in HUD_PCT_BY_SIZE:
return HUD_PCT_BY_SIZE[(win_w, win_h)]
target_area = win_w * win_h
nearest = min(HUD_PCT_BY_SIZE.keys(),
key=lambda k: abs(k[0] * k[1] - target_area))
return HUD_PCT_BY_SIZE[nearest]


def get_game_roi(win_w: int, win_h: int):
"""回傳 (x, y, w, h):遊戲渲染畫面在整個視窗內的矩形"""
hud = _pick_hud(win_w, win_h)
t = int(win_h * hud["top"])
b = int(win_h * hud["bottom"])
l = int(win_w * hud["left"])
r = int(win_w * hud["right"])
return (l, t, win_w - l - r, win_h - t - b)


def get_tile_px(win_w: int, win_h: int):
"""回傳一格地磚在畫面上的像素尺寸 (tw, th)

Lineage 視野是「左右 8 格、上下 10 格」的扁長方形,所以:
DW = roi_w / 16(= 2·TILES_PER_HALF_WIDTH)
DH = roi_h / 20(= 2·TILES_PER_HALF_HEIGHT)
"""
_, _, rw, rh = get_game_roi(win_w, win_h)
return (rw / (2 * TILES_PER_HALF_WIDTH),
rh / (2 * TILES_PER_HALF_HEIGHT))

兩個 TILES_PER_HALF_* 是遊戲常數(不會因視窗大小而變);換不同客戶端或視角縮放才需要改。

怎麼量 8 / 10? 在遊戲裡把一個不動的地標(例如怪物或 NPC)放在人物正右方一格,往正左方走到地標剛好在畫面右邊界為止,走的格數 +1 就是水平半徑;垂直半徑同理(放正下方、往正上方走)。作者實測 Lineage 預設視角:水平 8、垂直 10。

附錄:新增解析度用的 calibrate_game_roi.py

HUD_PCT_BY_SIZE 沒涵蓋到你的解析度時才需要,流程:選取視窗 → 擷取一幀 → cv2.selectROI 框出「真正的遊戲畫面」→ 換算成四邊百分比印出來,複製一個 key-value 對貼進 HUD_PCT_BY_SIZE 就行。​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

點此展開 calibrate_game_roi.py 完整程式碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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
# calibrate_game_roi.py
"""
新增解析度支援用:
1. 開啟遊戲視窗並設定成要支援的解析度(保持顯示中、不要最小化)
2. python calibrate_game_roi.py
3. 點擊遊戲視窗鎖定 → 拖曳框出遊戲畫面範圍 → Enter 確認
4. 複製印出的 key-value 貼到 window_capture.py 的 HUD_PCT_BY_SIZE 字典裡
"""
import time

import cv2
import win32api

from window_capture import capture_window, window_from_point

VK_LBUTTON = 0x01
VK_ESCAPE = 0x1B


def pick_window():
print("🎯 請點擊目標遊戲視窗(ESC 取消)...")
while True:
if win32api.GetAsyncKeyState(VK_ESCAPE) & 0x8000:
return None
if win32api.GetAsyncKeyState(VK_LBUTTON) & 0x8000:
x, y = win32api.GetCursorPos()
hwnd, title, _ = window_from_point(x, y)
return hwnd, title
time.sleep(0.03)


def main():
result = pick_window()
if result is None:
print("已取消")
return
hwnd, title = result
print(f"✓ 鎖定視窗:{title} hwnd={hwnd}")

frame = capture_window(hwnd)
if frame is None:
print("擷取失敗(視窗可能被最小化)")
return

win_h, win_w = frame.shape[:2]
print(f"視窗大小(含標題列):{win_w} x {win_h}")
print("請拖曳框出「真正的遊戲畫面」"
"(排除標題列、聊天框、小地圖、上方 HUD),Enter 確認")

x, y, w, h = cv2.selectROI("Calibrate Game ROI",
frame, fromCenter=False, showCrosshair=True)
cv2.destroyAllWindows()
if w == 0 or h == 0:
print("未框選有效範圍,已取消")
return

hud = {
"top": round(y / win_h, 3),
"bottom": round((win_h - y - h) / win_h, 3),
"left": round(x / win_w, 3),
"right": round((win_w - x - w) / win_w, 3),
}

print("\n請把以下 key-value 加到 window_capture.py 的 HUD_PCT_BY_SIZE:\n")
print(f" ({win_w}, {win_h}): {{")
for k in ("top", "bottom", "left", "right"):
print(f' "{k}": {hud[k]:.3f},')
print(" },")


if __name__ == "__main__":
main()

執行 `calibrate_game_roi.py`,點擊遊戲視窗後拖曳框出真正的遊戲畫面範圍,程式即印出可貼進 HUD_PCT_BY_SIZE 的 key-value 對
圖:執行 calibrate_game_roi.py,點擊遊戲視窗後拖曳框出真正的遊戲畫面範圍,程式即印出可貼進 HUD_PCT_BY_SIZE 的 key-value 對

💻 偵測模組:detector.py

Detector 有兩個預覽模式:

  • mode="live":沿用原本的 annotate,在原畫面上畫 bbox(適合調 conf、debug 誤判)
  • mode="grid":呼叫 render_grid,把偵測結果投影到以角色為中心的扁平等角菱形格(左右 ±8、上下 ±10,對應遊戲實際可視範圍;適合運行中快速看怪物相對方位)

格子地圖的核心是等角(isometric)逆變換。天堂視角旋轉 45°,世界座標東向對應畫面右上,所以把偵測中心的像素偏移 (dx, dy) 換成世界格座標:​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

1
2
3
tw, th = get_tile_px(...)   # = roi_w/16, roi_h/20(水平半徑 8、垂直半徑 10)
wx = dx / tw − dy / th # 世界東向(畫面右上為正)
wy = dx / tw + dy / th # 世界南向(畫面右下為正)

四捨五入即為格子座標。判斷是否在視野內不是用方形 |wx|≤N|wy|≤N,而是用菱形|wx+wy| ≤ 16(對應螢幕 ±8 格寬) |wy-wx| ≤ 20(對應螢幕 ±10 格高)。格子地圖也只畫這塊菱形裡的 cell,整個小地圖就會跟遊戲畫面一樣扁、佔用空間也小很多。

⚠️ twth 分母不同(16 vs 20):Lineage 的螢幕可視範圍是左右 8 格、上下 10 格,所以每格在螢幕上是扁的(DW:DH ≈ 2.3:1,不是標準 iso 2:1)。把 thrh/16 算會讓上下方向的偵測位置偏掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# detector.py
from typing import Dict, List, Literal, Optional

import cv2
import numpy as np
from ultralytics import YOLO

from window_capture import (get_game_roi, get_tile_px,
TILES_PER_HALF_WIDTH, TILES_PER_HALF_HEIGHT)

# 菱形視野邊界(把螢幕上「左右 ±8 / 上下 ±10」換算回 iso 世界座標)
VIEW_EW = 2 * TILES_PER_HALF_WIDTH # 東西邊界:|wx + wy| ≤ 16
VIEW_NS = 2 * TILES_PER_HALF_HEIGHT # 南北邊界:|wy - wx| ≤ 20
GRID_HALF = (VIEW_EW + VIEW_NS) // 2 # 迴圈迭代半徑(菱形 4 角 |wx|/|wy| 最大 = 18)

CANVAS_W = 200 # 小地圖畫布寬(視野寬 16·DW = 192,加一點邊距)
CANVAS_H = 130 # 小地圖畫布高(視野高 20·DH = 120,加一點邊距)


class Detector:
def __init__(self, model_path: str, conf: float = 0.5):
self.model = YOLO(model_path)
self.conf = conf
self.enabled = True
self.mode: Literal["live", "grid"] = "live"

def detect(self, frame) -> List[Dict]:
"""
回傳偵測結果,每筆為:
{"name": str, "conf": float,
"box": (x1, y1, x2, y2), "center": (cx, cy)}
"""
results = self.model(frame, conf=self.conf, verbose=False)
detections = []
for r in results:
for box in r.boxes:
name = self.model.names[int(box.cls)]
c = float(box.conf)
x1, y1, x2, y2 = map(int, box.xyxy[0])
cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
detections.append({
"name": name, "conf": c,
"box": (x1, y1, x2, y2), "center": (cx, cy)
})
return detections

def render(self, frame, detections: List[Dict]):
"""主執行緒呼叫入口:依 mode 派發到 annotate 或 render_grid"""
if self.mode == "grid":
return self.render_grid(frame, detections)
return self.annotate(frame, detections)

def annotate(self, frame, detections: List[Dict],
target: Optional[Dict] = None):
"""live 模式:在原畫面上繪製偵測框"""
out = frame.copy()
for d in detections:
x1, y1, x2, y2 = d["box"]
color = (0, 0, 220) if (target and d is target) else (0, 220, 0)
cv2.rectangle(out, (x1, y1), (x2, y2), color, 2)
label = f"{d['name']} {d['conf']:.2f}"
cv2.putText(out, label, (x1, y1 - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
return out

def render_grid(self, frame, detections: List[Dict]):
"""grid 模式:以扁平等角菱形格呈現怪物相對方位(左右 ±8、上下 ±10 的遊戲實際可視範圍)"""
fh, fw = frame.shape[:2]
rx, ry, rw, rh = get_game_roi(fw, fh)
px, py = rx + rw / 2, ry + rh / 2 # 玩家像素座標(ROI 中心)
tw, th = get_tile_px(fw, fh) # 一格地磚像素:roi_w/16, roi_h/20(分母不同!)

# 偵測中心 → 世界格座標(只收視野菱形內的,避免畫到邊界外)
monsters = set()
for d in detections:
cx, cy = d["center"]
dx, dy = cx - px, cy - py
wx = round(dx / tw - dy / th)
wy = round(dx / tw + dy / th)
if abs(wx + wy) <= VIEW_EW and abs(wy - wx) <= VIEW_NS:
monsters.add((wx, wy))

# 畫布與一格菱形的外接矩形大小(小地圖統一用 iso 2:1,方便閱讀)
canvas = np.full((CANVAS_H, CANVAS_W, 3), 26, dtype=np.uint8)
ccx, ccy = CANVAS_W // 2, CANVAS_H // 2
DW, DH = 12, 6

WHITE, BLACK = (240, 240, 240), (16, 16, 16)
YELLOW, EDGE = (0, 220, 255), (95, 95, 115)

def diamond(wx, wy, fill, edge):
# 世界格 → 畫布像素中心(iso 45° 投影)
cx = ccx + (wx + wy) * DW / 2
cy = ccy + (wy - wx) * DH / 2
pts = np.array([
[cx, cy - DH / 2],
[cx + DW / 2, cy ],
[cx, cy + DH / 2],
[cx - DW / 2, cy ],
], dtype=np.int32)
if fill is not None:
cv2.fillConvexPoly(canvas, pts, fill)
cv2.polylines(canvas, [pts], True, edge, 1, cv2.LINE_AA)

for wx in range(-GRID_HALF, GRID_HALF + 1):
for wy in range(-GRID_HALF, GRID_HALF + 1):
# 菱形裁切:只畫「遊戲實際可視範圍」內的格子
if abs(wx + wy) > VIEW_EW: continue # 東西邊界(螢幕左右 ±8)
if abs(wy - wx) > VIEW_NS: continue # 南北邊界(螢幕上下 ±10)
if wx == 0 and wy == 0:
diamond(wx, wy, BLACK, WHITE) # 玩家
elif (wx, wy) in monsters:
diamond(wx, wy, YELLOW, EDGE) # 怪物
else:
diamond(wx, wy, None, EDGE) # 空格(只畫邊框)
return canvas

💻 執行緒架構(本篇)

1
2
3
4
5
6
7
8
9
10
11
12
Worker Thread 1 (monitor_loop)
└── capture_window(state.hwnd) → HPMonitor.read() → potion.check()
└── frame_q.put_nowait(frame) ← 傳幀給 Thread 2

Worker Thread 2 (detection_loop)
└── frame_q.get() → detector.detect()
→ detector.render() ← 依 mode 派發到 annotate 或 render_grid
→ SharedState.detections / preview

Main Thread (tkinter)
└── root.after(200ms) → _poll → 讀 SharedState → 更新 HP/MP + 預覽圖
→ 面板設定即時推進 AutoPotion / Detector

💻 主程式:main.py(第三篇版本)

這段內容已加密
需要 ☕ 咖啡會員 或更高等級的密碼才能閱讀
💡 年度公開密碼,到 /support 直接取得

雙執行緒架構,Thread 1 擷取畫面傳入 frame_q,Thread 2 跑 YOLOv8 推論後把 detections / preview 寫回 SharedState,主執行緒每 200ms 更新 tkinter 預覽視窗
圖:雙執行緒架構,Thread 1 擷取畫面傳入 frame_q,Thread 2 跑 YOLOv8 推論後把 detections / preview 寫回 SharedState,主執行緒每 200ms 更新 tkinter 預覽視窗​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

⚠️ 注意事項

🧵 執行緒與熱切換

  • frame_q(maxsize=2) 丟舊幀:佇列滿就先 get_nowait() 丟最舊一幀再放新幀,Thread 2 永遠跑最新畫面、不因推論慢而累積延遲。
  • 三種熱切換皆下一幀生效、不用停啟:(1)啟用怪物偵測 checkbox 改 detector.enabled;(2)「🔄 載入」新建 Detector(...) 原子指派給 self.detector,Thread 2 透過 lambda: self.detector 下一輪自動吃新模型;(3)conf 滑桿由 _poll 每 200ms 寫回 detector.conf
  • 載模型失敗不會崩潰_load_detector 用 try/except 包住,路徑錯或檔案損毀只寫日誌、保留上一顆模型,監控與補藥照常運作;啟動時找不到預設模型只記 log,勾 checkbox 會提醒先按「🔄 載入」。

❓️ 新手常踩的雷

  • ImageTk.PhotoImage 必須保留參考:存到 self._tk_img 才不會被 Python GC 回收,否則預覽 Label 會變空白。
  • worker 掛掉要讓 UI 知道:兩條 worker 都用 try/finally + try/except 吞例外;_poll 偵測到都掛了會把 ▶/■ 切回「啟動」並在日誌提示。

🗺️ ROI 與格子地圖座標

  • 查表 key 是「含標題列的視窗尺寸」capture_window 抓到整個視窗(含標題列與邊框),所以 key 比遊戲內部解析度大 ~2×27 像素。若解析度不在表內,_pick_hud 會找面積最接近的當 fallback,但角標可能對不準 —— 建議跑 calibrate_game_roi.py 補一筆。
  • Lineage 的 tile 是扁的get_tile_px 回傳 (roi_w/16, roi_h/20)分母不同(螢幕寬裝得下 16 個 tile-width、高只裝得下 20 個 tile-height,DW:DH ≈ 2.3:1)。若把 th 也寫成 rh/16,上下方向的偵測位置會偏掉。
  • 偵測與繪製都用菱形視野|wx+wy| ≤ 16 |wy-wx| ≤ 20,對應螢幕上左右 ±8、上下 ±10 的可視範圍;超出菱形的偵測結果忽略、超出菱形的空格不畫。

🎯 結語

本篇完成了最核心的多執行緒分工:Thread 1 負責螢幕擷取與補藥,Thread 2 負責 YOLO 推論,主執行緒只做 UI,中間用 frame_qSharedState 解耦。

下一篇將加入 (四)目標優先序選擇,讓 Thread 2 在眾多偵測結果中挑出最優先攻擊的目標,並在預覽視窗中用紅框標記它。

📖 如在學習過程中遇到疑問,或是想了解更多相關主題,建議回顧一下 Python | OpenCV 系列導讀,掌握完整的章節目錄,方便快速找到你需要的內容。

​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​​‌‌​​‌​​​‌‌​​​​​​‌‌​​‌​​​‌‌​‌‌​​​‌‌​​​​​​‌‌​‌​​​​‌‌​​‌​​​‌‌​​​‌​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌‌​​‌​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌​‌‌‌​​​‌​‌‌​‌​‌‌​‌‌‌‌​‌‌‌​​​​​‌‌​​‌​‌​‌‌​‌‌‌​​‌‌​​​‌‌​‌‌‌​‌‌​​​‌​‌‌​‌​‌‌‌​​​​​‌‌‌​​‌​​‌‌​‌‌‌‌​‌‌​‌​‌​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​​‌​‌‌​‌​‌‌​‌‌​​​‌‌​‌​​‌​‌‌​‌‌‌​​‌‌​​‌​‌​‌‌​​​​‌​‌‌​​‌‌‌​‌‌​​‌​‌​​‌​‌‌​‌​‌‌​​‌​​​‌‌​​‌​‌​‌‌‌​‌​​​‌‌​​‌​‌​‌‌​​​‌‌​‌‌‌​‌​​​‌‌​‌​​‌​‌‌​‌‌‌‌​‌‌​‌‌‌​

註:以上參考了
Ultralytics YOLOv8 官方文件
Pillow ImageTk 文件
Python queue.Queue 文件
tkinter ttk.Combobox