🔥 新作首發 🎮 天堂私服 YOLOv8 物件偵測實戰 — 從資料蒐集、模型訓練到即時偵測 立即閱讀 →
熱門系列
Like Share Discussion Bookmark Smile

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

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

⚠️ 免責聲明

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

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

📚 前言

在上一篇 (二)自動補藥 中,我們完成了自動補藥功能,並透過 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…)而非怪物。


🗺️ 遊戲畫面 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 對

💻 偵測模組: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(第三篇版本)

相較第二篇,主要變更:

  1. SharedState 新增 detectionspreview 兩個欄位
  2. monitor_loop 多了 frame_q.put_nowait(frame),佇列滿就丟最舊一幀
  3. 新增 detection_loop(Thread 2),讀 frame_q、跑 YOLO、寫回 SharedState
  4. UI 新增「YOLOv8 偵測設定」面板與「偵測預覽」區塊
  5. _poll 額外把最新 preview 圖轉成 ImageTk.PhotoImage 貼到預覽 Label,並即時推送 conf 閾值到 Detector
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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
# main.py(第三篇版本)
import json
import os
import queue
import threading
import time
import tkinter as tk
from tkinter import ttk, scrolledtext
from typing import Optional

import cv2
import numpy as np
import win32api
from PIL import Image, ImageTk

from window_capture import capture_window, window_from_point
from hp_monitor import HPMonitor, CONFIG_FILE
from auto_potion import AutoPotion
from detector import Detector

VK_LBUTTON = 0x01
VK_ESCAPE = 0x1B

POTION_KEYS = ["F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]
PREVIEW_W, PREVIEW_H = 200, 130


# ── Shared State ───────────────────────────────────────────────
class SharedState:
"""執行緒安全的共享資料容器"""
def __init__(self):
self._lock = threading.Lock()
self.hp = 1.0
self.mp = 1.0
self.hwnd = None
self.running = False
self.detections = [] # List[Dict]
self.preview = None # BGR numpy array

def update(self, **kwargs):
with self._lock:
for k, v in kwargs.items():
setattr(self, k, v)

def get(self, *keys):
with self._lock:
if len(keys) == 1:
return getattr(self, keys[0])
return tuple(getattr(self, k) for k in keys)


# ── Worker Thread 1:監控 + 補藥 + 傳幀 ───────────────────────
def monitor_loop(state: SharedState, monitor: HPMonitor, log_q: queue.Queue,
frame_q: queue.Queue,
potion: Optional[AutoPotion] = None):
hwnd = state.get("hwnd")
log_q.put(f"監控執行緒啟動,hwnd={hwnd}")

try:
while state.get("running"):
try:
frame = capture_window(hwnd)
if frame is None:
time.sleep(0.2)
continue
hp, mp = monitor.read(frame)
state.update(hp=hp, mp=mp)
if potion is not None:
potion.check(hp, mp)

# 傳幀給 Thread 2(佇列滿就丟最舊,永遠跑最新畫面)
try:
frame_q.put_nowait(frame)
except queue.Full:
try:
frame_q.get_nowait()
except queue.Empty:
pass
frame_q.put_nowait(frame)

time.sleep(0.2)
except Exception as e:
log_q.put(f"⚠️ 監控迴圈錯誤:{type(e).__name__}: {e}")
time.sleep(0.5)
finally:
state.update(running=False)
log_q.put("監控執行緒結束")


# ── Worker Thread 2:YOLO 偵測 ─────────────────────────────────
def detection_loop(state: SharedState, log_q: queue.Queue,
frame_q: queue.Queue, get_detector):
"""
Thread 2 無條件啟動、全程常駐;透過 get_detector() 動態讀當前模型:
- 沒載入/被停用:frame 丟棄不推論(維持 frame_q 流通)
- 主執行緒 _load_detector 指派新模型後,下一輪 get_detector() 即生效
"""
log_q.put("偵測執行緒啟動")

try:
while state.get("running"):
try:
frame = frame_q.get(timeout=1.0)
except queue.Empty:
continue

detector = get_detector()
if detector is None or not detector.enabled:
continue # 空轉:不推論但保持 frame_q 流通

try:
detections = detector.detect(frame)
preview = detector.render(frame, detections)
state.update(detections=detections, preview=preview)
except Exception as e:
log_q.put(f"⚠️ 偵測迴圈錯誤:{type(e).__name__}: {e}")
time.sleep(0.5)
finally:
log_q.put("偵測執行緒結束")


# ── tkinter 主視窗 ─────────────────────────────────────────────
class LineageAssistant:
BG = "#1a1a2e"
FG = "#e0e0e0"
RED = "#e94560"
BLUE = "#4fc3f7"
GREEN = "#00ff99"
AMBER = "#f5a623"
AMBER_HOVER = "#ffc857"
MUTED = "#555577"

def __init__(self):
self.state = SharedState()
self.monitor = HPMonitor()
self.log_q = queue.Queue()
self.frame_q = queue.Queue(maxsize=2)
self.t_monitor = None
self.t_detection = None
self.potion = None # _start 時建立、checkbox 即時切換
self.detector = None # _start 時建立、_poll 即時調整 conf
self._tk_img = None # 防止 ImageTk 被 GC 回收
self._build_ui()
self._autoload_detector() # 啟動時自動載入預設路徑的模型
self._poll()
self.root.mainloop()

def _make_icon_btn(self, parent, text, cmd, font):
btn = tk.Button(
parent, text=text,
bg=self.BG, fg=self.AMBER,
activebackground=self.BG, activeforeground=self.AMBER_HOVER,
disabledforeground=self.MUTED,
font=font, relief="flat", bd=0, cursor="hand2",
command=cmd)

def on_enter(_):
if btn["state"] != "disabled":
btn.config(fg=self.AMBER_HOVER)

def on_leave(_):
if btn["state"] != "disabled":
btn.config(fg=self.AMBER)

btn.bind("<Enter>", on_enter)
btn.bind("<Leave>", on_leave)
return btn

def _build_ui(self):
self.root = tk.Tk()
self.root.title("天堂私服輔助 v0.3")
self.root.configure(bg=self.BG)
self.root.resizable(False, False)

# ── 標題 ──
tk.Label(self.root, text="⚔ 天堂私服輔助",
bg=self.BG, fg=self.FG,
font=("Consolas", 16, "bold")).pack(pady=8)

# ── 工具列 ──
toolbar = tk.Frame(self.root, bg=self.BG)
toolbar.pack(padx=20, pady=(0, 4), fill="x")

self.btn_pick = self._make_icon_btn(
toolbar, "🎯 選取視窗", self._pick_window,
font=("Consolas", 11, "bold"))
self.btn_pick.pack(side="left")

self.btn_calib = self._make_icon_btn(
toolbar, "⚙ 校準血條", self._calibrate,
font=("Consolas", 11, "bold"))
self.btn_calib.pack(side="right")

# ── 目標視窗資訊 ──
self.target_var = tk.StringVar(value="目標視窗:未選擇")
tk.Label(self.root, textvariable=self.target_var,
bg=self.BG, fg=self.FG,
font=("Consolas", 10), anchor="w"
).pack(padx=20, pady=(0, 6), fill="x")

# ── HP / MP 血條顯示 ──
bar_frame = tk.Frame(self.root, bg=self.BG)
bar_frame.pack(padx=20, fill="x")

style = ttk.Style()
style.theme_use("default")
style.configure("Red.Horizontal.TProgressbar",
troughcolor="#330000", background="#e94560")
style.configure("Blue.Horizontal.TProgressbar",
troughcolor="#001133", background="#4fc3f7")

tk.Label(bar_frame, text="HP", bg=self.BG, fg=self.RED,
font=("Consolas", 11, "bold"), width=4
).grid(row=0, column=0, sticky="w")
self.hp_var = tk.DoubleVar(value=1.0)
ttk.Progressbar(bar_frame, variable=self.hp_var, maximum=1.0,
length=280, style="Red.Horizontal.TProgressbar"
).grid(row=0, column=1, padx=5, pady=3)
self.hp_lbl = tk.Label(bar_frame, text="100%",
bg=self.BG, fg=self.FG,
font=("Consolas", 10), width=6)
self.hp_lbl.grid(row=0, column=2)

tk.Label(bar_frame, text="MP", bg=self.BG, fg=self.BLUE,
font=("Consolas", 11, "bold"), width=4
).grid(row=1, column=0, sticky="w")
self.mp_var = tk.DoubleVar(value=1.0)
ttk.Progressbar(bar_frame, variable=self.mp_var, maximum=1.0,
length=280, style="Blue.Horizontal.TProgressbar"
).grid(row=1, column=1, padx=5, pady=3)
self.mp_lbl = tk.Label(bar_frame, text="100%",
bg=self.BG, fg=self.FG,
font=("Consolas", 10), width=6)
self.mp_lbl.grid(row=1, column=2)

# ── 自動補藥設定面板 ──
pot_frame = tk.LabelFrame(self.root, text=" 自動補藥設定 ",
bg=self.BG, fg="#aaaaaa",
font=("Consolas", 9))
pot_frame.pack(padx=12, pady=4, fill="x")

self.potion_enabled = tk.BooleanVar(value=True)
tk.Checkbutton(pot_frame, text="啟用自動補藥",
variable=self.potion_enabled,
command=self._on_potion_toggle,
bg=self.BG, fg=self.FG, selectcolor=self.BG,
font=("Consolas", 9)
).grid(row=0, column=0, columnspan=5,
sticky="w", padx=6, pady=2)

tk.Label(pot_frame, text="HP 觸發 %", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=1, column=0, sticky="w", padx=6)
self.hp_thresh = tk.IntVar(value=60)
ttk.Scale(pot_frame, from_=10, to=90, variable=self.hp_thresh,
orient="horizontal", length=120
).grid(row=1, column=1, padx=4)
self.hp_thresh_lbl = tk.Label(pot_frame, text="60%",
bg=self.BG, fg=self.RED,
font=("Consolas", 9), width=5)
self.hp_thresh_lbl.grid(row=1, column=2)
tk.Label(pot_frame, text="鍵", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=1, column=3, padx=2)
self.hp_key_var = tk.StringVar(value="F5")
ttk.Combobox(pot_frame, textvariable=self.hp_key_var,
values=POTION_KEYS, state="readonly",
width=4, font=("Consolas", 10)
).grid(row=1, column=4, padx=4)

tk.Label(pot_frame, text="MP 觸發 %", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=2, column=0, sticky="w", padx=6)
self.mp_thresh = tk.IntVar(value=40)
ttk.Scale(pot_frame, from_=10, to=90, variable=self.mp_thresh,
orient="horizontal", length=120
).grid(row=2, column=1, padx=4)
self.mp_thresh_lbl = tk.Label(pot_frame, text="40%",
bg=self.BG, fg=self.BLUE,
font=("Consolas", 9), width=5)
self.mp_thresh_lbl.grid(row=2, column=2)
tk.Label(pot_frame, text="鍵", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=2, column=3, padx=2)
self.mp_key_var = tk.StringVar(value="F6")
ttk.Combobox(pot_frame, textvariable=self.mp_key_var,
values=POTION_KEYS, state="readonly",
width=4, font=("Consolas", 10)
).grid(row=2, column=4, padx=4)

# ── 怪物偵測設定 ──
det_frame = tk.LabelFrame(self.root, text=" 怪物偵測設定 ",
bg=self.BG, fg="#aaaaaa",
font=("Consolas", 9))
det_frame.pack(padx=12, pady=4, fill="x")

self.detect_enabled = tk.BooleanVar(value=True)
tk.Checkbutton(det_frame, text="啟用怪物偵測",
variable=self.detect_enabled,
command=self._on_detect_toggle,
bg=self.BG, fg=self.FG, selectcolor=self.BG,
font=("Consolas", 9)
).grid(row=0, column=0, columnspan=3,
sticky="w", padx=6, pady=2)

tk.Label(det_frame, text="模型路徑", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=1, column=0, sticky="w", padx=6)
self.model_path_var = tk.StringVar(value="models/lineage_detector.pt")
tk.Entry(det_frame, textvariable=self.model_path_var, width=26,
bg="#0d0d1a", fg=self.FG, font=("Consolas", 9),
insertbackground="white"
).grid(row=1, column=1, padx=4, pady=2)
self.btn_load_model = self._make_icon_btn(
det_frame, "🔄 載入", self._load_detector,
font=("Consolas", 10, "bold"))
self.btn_load_model.grid(row=1, column=2, padx=4)

tk.Label(det_frame, text="信心度閾值", bg=self.BG, fg=self.FG,
font=("Consolas", 9)).grid(row=2, column=0, sticky="w", padx=6)
self.conf_thresh = tk.IntVar(value=50)
ttk.Scale(det_frame, from_=10, to=90, variable=self.conf_thresh,
orient="horizontal", length=120
).grid(row=2, column=1, padx=4)
self.conf_lbl = tk.Label(det_frame, text="50%",
bg=self.BG, fg=self.GREEN,
font=("Consolas", 9), width=5)
self.conf_lbl.grid(row=2, column=2)

# ── 偵測預覽視窗 ──
preview_frame = tk.LabelFrame(self.root, text=" 偵測預覽 ",
bg=self.BG, fg="#aaaaaa",
font=("Consolas", 9))
preview_frame.pack(padx=12, pady=4, fill="x")

# 預覽模式切換(上方):即時畫面 / 格子地圖
mode_row = tk.Frame(preview_frame, bg=self.BG)
mode_row.pack(fill="x", padx=6, pady=(2, 0))
self.preview_mode = tk.StringVar(value="live")
for val, txt in [("live", "即時畫面"), ("grid", "格子地圖")]:
tk.Radiobutton(mode_row, text=txt,
variable=self.preview_mode, value=val,
command=self._on_preview_mode,
bg=self.BG, fg=self.FG, selectcolor=self.BG,
activebackground=self.BG, activeforeground=self.FG,
font=("Consolas", 9)
).pack(side="left", padx=6)

blank = np.zeros((PREVIEW_H, PREVIEW_W, 3), dtype=np.uint8)
self._tk_img = ImageTk.PhotoImage(Image.fromarray(blank))
self.preview_lbl = tk.Label(preview_frame, image=self._tk_img,
bg=self.BG)
self.preview_lbl.pack(padx=4, pady=4)

self.det_count_lbl = tk.Label(preview_frame, text="偵測數:0",
bg=self.BG, fg=self.GREEN,
font=("Consolas", 9))
self.det_count_lbl.pack()

# ── 控制按鈕:單顆 ▶ / ■ toggle ──
btn_frame = tk.Frame(self.root, bg=self.BG)
btn_frame.pack(pady=8)

self.btn_toggle = self._make_icon_btn(
btn_frame, "▶ 啟動", self._toggle,
font=("Consolas", 13, "bold"))
self.btn_toggle.pack()

# ── 狀態列 ──
self.status_var = tk.StringVar(value="就緒")
tk.Label(self.root, textvariable=self.status_var,
bg=self.BG, fg="#888888",
font=("Consolas", 9)).pack()

# ── 日誌視窗 ──
self.log_box = scrolledtext.ScrolledText(
self.root, width=52, height=8, state="disabled",
bg="#0d0d1a", fg=self.GREEN,
font=("Consolas", 9), insertbackground="white")
self.log_box.pack(padx=10, pady=(5, 10))

def _log(self, msg: str):
self.log_q.put(msg)

def _flush_log(self):
while not self.log_q.empty():
msg = self.log_q.get_nowait()
ts = time.strftime("%H:%M:%S")
self.log_box.config(state="normal")
self.log_box.insert("end", f"{ts} {msg}\n")
self.log_box.see("end")
self.log_box.config(state="disabled")

# ── 🎯 滑鼠點擊選窗 ───────────────────────────────────────
def _pick_window(self):
if self.state.get("running"):
self._log("請先停止監控再重選目標視窗")
return
self._log("🎯 請點擊目標遊戲視窗(ESC 可取消)")
for btn in (self.btn_pick, self.btn_calib,
self.btn_load_model, self.btn_toggle):
btn.config(state="disabled")
self.root.config(cursor="crosshair")
self._poll_pick()

def _poll_pick(self):
if win32api.GetAsyncKeyState(VK_ESCAPE) & 0x8000:
self._reset_pick()
self._log("已取消選擇視窗")
return
if win32api.GetAsyncKeyState(VK_LBUTTON) & 0x8000:
x, y = win32api.GetCursorPos()
hwnd, title, pid = window_from_point(x, y)
self._reset_pick()
if hwnd:
self.state.update(hwnd=hwnd)
label = title or "<無標題>"
short = label if len(label) <= 30 else label[:28] + "…"
self.target_var.set(f"目標:{short} (pid={pid})")
self._log(f"已鎖定:{label} hwnd={hwnd} pid={pid}")
else:
self._log("未取得有效視窗,請再試一次")
return
self.root.after(30, self._poll_pick)

def _reset_pick(self):
self.root.config(cursor="")
for btn in (self.btn_pick, self.btn_calib,
self.btn_load_model, self.btn_toggle):
btn.config(state="normal")

# ── ⚙ ROI 校準 ───────────────────────────────────────
def _calibrate(self):
if self.state.get("running"):
self._log("請先停止監控再校準")
return
hwnd = self.state.get("hwnd")
if not hwnd:
self._log("請先按「🎯 選取視窗」選取目標視窗")
return

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

self._log("框選 HP 血條(紅色),Enter 確認")
hp = cv2.selectROI("校準 HP 血條(Enter 確認 / C 重選)",
frame, fromCenter=False, showCrosshair=True)
cv2.destroyAllWindows()

self._log("框選 MP 魔力條(藍色),Enter 確認")
mp = cv2.selectROI("校準 MP 魔力條(Enter 確認 / C 重選)",
frame, fromCenter=False, showCrosshair=True)
cv2.destroyAllWindows()

if hp[2] == 0 or mp[2] == 0:
self._log("校準已取消(未框選有效範圍)")
return

self.monitor.hp_roi = tuple(hp)
self.monitor.mp_roi = tuple(mp)
self._save_config()
self._log(f"✅ 校準完成:HP={hp}, MP={mp}")

def _save_config(self):
cfg = {
"hp_roi": list(self.monitor.hp_roi),
"mp_roi": list(self.monitor.mp_roi),
}
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2)
self._log(f"已寫入 {CONFIG_FILE}")

# ── ▶ / ■ 切換 ──
def _toggle(self):
if self.state.get("running"):
self._stop()
else:
self._start()

def _on_potion_toggle(self):
"""checkbox 即時生效:直接切換 AutoPotion.enabled,不需停止/重啟"""
if self.potion is not None:
self.potion.enabled = self.potion_enabled.get()
self._log(f"自動補藥:{'啟用' if self.potion_enabled.get() else '停用'}")

def _on_detect_toggle(self):
"""checkbox 即時生效:切換 Detector.enabled;尚未載入模型則提示"""
want = self.detect_enabled.get()
if self.detector is None:
if want:
self._log("尚未載入模型,請先填入路徑並按「🔄 載入」")
return
self.detector.enabled = want
self._log(f"怪物偵測:{'啟用' if want else '停用'}")

def _on_preview_mode(self):
"""radio 即時生效:切換 Detector.mode,下一幀就換畫面"""
mode = self.preview_mode.get()
if self.detector is not None:
self.detector.mode = mode
self._log(f"預覽模式:{'即時畫面' if mode == 'live' else '格子地圖'}")

def _autoload_detector(self):
"""啟動時嘗試自動載入預設路徑的模型;找不到檔案只寫日誌不報錯"""
path = self.model_path_var.get().strip()
if path and os.path.exists(path):
self._load_detector()
else:
self._log(
f"找不到預設模型:{path or '(未設定)'},"
f"請把 best.pt 放到該路徑,或改路徑後按「🔄 載入」"
)

def _load_detector(self):
"""手動載入/重載 YOLO 模型:啟動前或執行中皆可,Thread 2 下一輪自動套用"""
path = self.model_path_var.get().strip()
if not path:
self._log("請先填入模型路徑")
return
self._log(f"載入模型中:{path} ...")
try:
new_det = Detector(model_path=path,
conf=self.conf_thresh.get() / 100)
new_det.enabled = self.detect_enabled.get()
new_det.mode = self.preview_mode.get()
# CPython 屬性指派是原子操作,Thread 2 下一輪 get_detector() 即熱換
self.detector = new_det
self._log(
f"✅ 已載入模型:{path}"
f"(conf≥{self.conf_thresh.get()}%,"
f"{'啟用' if new_det.enabled else '停用'})"
)
except Exception as e:
self._log(f"⚠️ 載入模型失敗:{type(e).__name__}: {e}")

def _start(self):
hwnd = self.state.get("hwnd")
if not hwnd:
self._log("請先按「🎯 選取視窗」選取目標視窗")
return

# AutoPotion:一律建立,enabled 控制是否真的按鍵
self.potion = AutoPotion(
hwnd = hwnd,
hp_threshold = self.hp_thresh.get() / 100,
mp_threshold = self.mp_thresh.get() / 100,
hp_key = self.hp_key_var.get().lower(),
mp_key = self.mp_key_var.get().lower(),
cooldown = 1.5,
enabled = self.potion_enabled.get(),
log_fn = self._log,
)
self._log(
f"補藥設定:HP<{self.hp_thresh.get()}% 按{self.hp_key_var.get()},"
f"MP<{self.mp_thresh.get()}% 按{self.mp_key_var.get()}"
f"({'啟用' if self.potion_enabled.get() else '停用'})"
)

# Detector 已在 __init__ 自動載入;若失敗這裡再提醒一次
if self.detector is None and self.detect_enabled.get():
self._log("提示:目前尚無模型,可調整路徑後按「🔄 載入」再繼續")

self.state.update(running=True)

self.t_monitor = threading.Thread(
target=monitor_loop,
args=(self.state, self.monitor, self.log_q,
self.frame_q, self.potion),
daemon=True)
self.t_monitor.start()

# Thread 2 無條件常駐:透過 lambda 動態讀當前 Detector,支援執行中熱換模型
self.t_detection = threading.Thread(
target=detection_loop,
args=(self.state, self.log_q, self.frame_q,
lambda: self.detector),
daemon=True)
self.t_detection.start()

self.btn_toggle.config(text="■ 停止")
self.status_var.set("運行中...")

def _stop(self):
self.state.update(running=False)
self.btn_toggle.config(text="▶ 啟動")
self.status_var.set("已停止")
self._log("使用者手動停止")

def _poll(self):
"""每 200ms 將 SharedState 同步到 UI;順便把面板設定即時推到背景模組"""
hp, mp, detections, preview = self.state.get(
"hp", "mp", "detections", "preview")

# 血條 + 閾值 label
self.hp_var.set(hp)
self.mp_var.set(mp)
self.hp_lbl.config(text=f"{hp * 100:.0f}%")
self.mp_lbl.config(text=f"{mp * 100:.0f}%")
self.hp_thresh_lbl.config(text=f"{self.hp_thresh.get()}%")
self.mp_thresh_lbl.config(text=f"{self.mp_thresh.get()}%")
self.conf_lbl.config(text=f"{self.conf_thresh.get()}%")

# 偵測預覽:把 BGR preview 轉 RGB → PhotoImage
if preview is not None:
img_rgb = cv2.cvtColor(
cv2.resize(preview, (PREVIEW_W, PREVIEW_H)),
cv2.COLOR_BGR2RGB)
self._tk_img = ImageTk.PhotoImage(Image.fromarray(img_rgb))
self.preview_lbl.config(image=self._tk_img)
self.det_count_lbl.config(text=f"偵測數:{len(detections)}")

# 偵測任一 worker 是否意外結束
workers_alive = any(
t is not None and t.is_alive()
for t in (self.t_monitor, self.t_detection))
if (not workers_alive
and self.btn_toggle.cget("text").startswith("■")):
self.state.update(running=False)
self.btn_toggle.config(text="▶ 啟動")
self.status_var.set("背景執行緒已停止(請看日誌)")

# 面板即時生效(CPython 屬性寫入在 GIL 下為原子操作)
if self.potion is not None:
self.potion.hp_threshold = self.hp_thresh.get() / 100
self.potion.mp_threshold = self.mp_thresh.get() / 100
self.potion.hp_key = self.hp_key_var.get().lower()
self.potion.mp_key = self.mp_key_var.get().lower()
if self.detector is not None:
self.detector.conf = self.conf_thresh.get() / 100

self._flush_log()
self.root.after(200, self._poll)


if __name__ == "__main__":
LineageAssistant()


圖:雙執行緒架構,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