stevenkhan commited on
Commit
a2bfe50
·
verified ·
1 Parent(s): 34a8908

Upload clashcr/core/battle_gating.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. clashcr/core/battle_gating.py +233 -0
clashcr/core/battle_gating.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Battle gating: detect lobby/menu vs battle screen.
2
+
3
+ Never emit card events outside a confirmed battle.
4
+ Reset tracker state between battles.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum, auto
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import cv2
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class BattleState(Enum):
22
+ UNKNOWN = auto()
23
+ MENU = auto()
24
+ BATTLE = auto()
25
+ POST_BATTLE = auto()
26
+
27
+
28
+ @dataclass
29
+ class BattleGateResult:
30
+ state: BattleState
31
+ confidence: float
32
+ arena_roi: Optional[tuple] = None # (x, y, w, h) in frame coords
33
+ own_roi: Optional[tuple] = None
34
+ opponent_roi: Optional[tuple] = None
35
+ is_spectator: bool = False
36
+ mode_hint: str = "" # 'live', 'spectator', 'replay', 'tv_royale'
37
+
38
+
39
+ class BattleGater:
40
+ """Determines whether the current frame shows an active battle.
41
+
42
+ Uses a combination of:
43
+ - Template matching for VS screen (battle start)
44
+ - Arena color/texture heuristics (grass/bridge patterns)
45
+ - HUD element detection (elixir bar, timer)
46
+ - Temporal consistency (require N consecutive battle frames)
47
+ """
48
+
49
+ def __init__(self,
50
+ battle_threshold: float = 0.6,
51
+ consecutive_frames: int = 3,
52
+ vs_template_path: Optional[str] = None):
53
+ self.battle_threshold = battle_threshold
54
+ self.consecutive_frames = consecutive_frames
55
+ self.vs_template_path = vs_template_path
56
+ self._vs_template: Optional[np.ndarray] = None
57
+ self._consecutive_battle = 0
58
+ self._consecutive_menu = 0
59
+ self._state = BattleState.UNKNOWN
60
+ self._last_state_time = 0.0
61
+
62
+ if self.vs_template_path and Path(self.vs_template_path).exists():
63
+ self._vs_template = cv2.imread(self.vs_template_path, cv2.IMREAD_GRAYSCALE)
64
+
65
+ def _detect_vs_screen(self, gray: np.ndarray) -> float:
66
+ if self._vs_template is None:
67
+ return 0.0
68
+ best = 0.0
69
+ for scale in [0.5, 0.75, 1.0, 1.25, 1.5]:
70
+ resized = cv2.resize(self._vs_template, None, fx=scale, fy=scale)
71
+ if resized.shape[0] > gray.shape[0] or resized.shape[1] > gray.shape[1]:
72
+ continue
73
+ res = cv2.matchTemplate(gray, resized, cv2.TM_CCOEFF_NORMED)
74
+ _, max_val, _, _ = cv2.minMaxLoc(res)
75
+ best = max(best, max_val)
76
+ return best
77
+
78
+ def _detect_arena(self, frame: np.ndarray) -> float:
79
+ """Heuristic: does the central region look like an arena?
80
+
81
+ Looks for:
82
+ - Two tower-like structures near top/bottom center
83
+ - Bridge-like horizontal line in middle
84
+ - Green/brown dominant colors
85
+ """
86
+ h, w = frame.shape[:2]
87
+ # Use central crop
88
+ cx1, cx2 = int(w * 0.15), int(w * 0.85)
89
+ cy1, cy2 = int(h * 0.1), int(h * 0.9)
90
+ crop = frame[cy1:cy2, cx1:cx2]
91
+ if crop.size == 0:
92
+ return 0.0
93
+
94
+ # Convert to HSV for color analysis
95
+ hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
96
+ # Arena grass: green hue ~35-75, saturation > 40
97
+ green_mask = cv2.inRange(hsv, (35, 40, 20), (75, 255, 255))
98
+ green_ratio = np.count_nonzero(green_mask) / crop.size
99
+
100
+ # Arena road/bridge: brown/orange hue ~10-25
101
+ brown_mask = cv2.inRange(hsv, (10, 40, 40), (25, 255, 200))
102
+ brown_ratio = np.count_nonzero(brown_mask) / crop.size
103
+
104
+ # Look for vertical symmetry (two sides)
105
+ mid = crop.shape[1] // 2
106
+ left_half = crop[:, :mid]
107
+ right_half = crop[:, mid:]
108
+ if left_half.size > 0 and right_half.size > 0:
109
+ diff = np.mean(cv2.absdiff(left_half, right_half))
110
+ symmetry_score = 1.0 - min(diff / 100.0, 1.0)
111
+ else:
112
+ symmetry_score = 0.0
113
+
114
+ # Combine heuristics
115
+ score = (green_ratio * 0.4) + (brown_ratio * 0.3) + (symmetry_score * 0.3)
116
+ return min(score * 2.0, 1.0) # Scale up a bit
117
+
118
+ def _detect_hud(self, frame: np.ndarray) -> float:
119
+ """Look for timer / elixir bar at top center."""
120
+ h, w = frame.shape[:2]
121
+ top_bar = frame[0:int(h * 0.12), int(w * 0.3):int(w * 0.7)]
122
+ if top_bar.size == 0:
123
+ return 0.0
124
+ gray = cv2.cvtColor(top_bar, cv2.COLOR_BGR2GRAY)
125
+ # Timer usually has high contrast digits
126
+ _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
127
+ white_ratio = np.count_nonzero(thresh) / thresh.size
128
+ # Expect moderate white ratio (digits on dark bg)
129
+ hud_score = 1.0 - abs(white_ratio - 0.15) / 0.15
130
+ return max(0.0, hud_score)
131
+
132
+ def _compute_rois(self, frame: np.ndarray) -> tuple:
133
+ """Compute arena, own-side, opponent-side ROIs.
134
+
135
+ For standard portrait layout:
136
+ - Arena: central area excluding top HUD and bottom hand cards
137
+ - Own side: bottom half of arena
138
+ - Opponent side: top half of arena
139
+ """
140
+ h, w = frame.shape[:2]
141
+ # Exclude top 10% (HUD) and bottom 15% (own hand cards)
142
+ arena_y1 = int(h * 0.10)
143
+ arena_y2 = int(h * 0.85)
144
+ arena_x1 = int(w * 0.05)
145
+ arena_x2 = int(w * 0.95)
146
+
147
+ arena_roi = (arena_x1, arena_y1, arena_x2 - arena_x1, arena_y2 - arena_y1)
148
+ mid_y = (arena_y1 + arena_y2) // 2
149
+ own_roi = (arena_x1, mid_y, arena_x2 - arena_x1, arena_y2 - mid_y)
150
+ opponent_roi = (arena_x1, arena_y1, arena_x2 - arena_x1, mid_y - arena_y1)
151
+
152
+ return arena_roi, own_roi, opponent_roi
153
+
154
+ def process(self, frame: np.ndarray) -> BattleGateResult:
155
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
156
+
157
+ vs_score = self._detect_vs_screen(gray)
158
+ arena_score = self._detect_arena(frame)
159
+ hud_score = self._detect_hud(frame)
160
+
161
+ # Combined battle confidence
162
+ battle_conf = max(arena_score, hud_score, vs_score * 0.5)
163
+
164
+ # State machine with hysteresis
165
+ if battle_conf >= self.battle_threshold:
166
+ self._consecutive_battle += 1
167
+ self._consecutive_menu = 0
168
+ else:
169
+ self._consecutive_menu += 1
170
+ self._consecutive_battle = 0
171
+
172
+ if self._consecutive_battle >= self.consecutive_frames and self._state != BattleState.BATTLE:
173
+ self._state = BattleState.BATTLE
174
+ self._last_state_time = time.monotonic()
175
+ logger.info("Battle detected (conf=%.2f)", battle_conf)
176
+ elif self._consecutive_menu >= self.consecutive_frames and self._state == BattleState.BATTLE:
177
+ self._state = BattleState.POST_BATTLE
178
+ self._last_state_time = time.monotonic()
179
+ logger.info("Battle ended")
180
+ elif self._consecutive_menu >= self.consecutive_frames and self._state != BattleState.MENU:
181
+ self._state = BattleState.MENU
182
+
183
+ arena_roi, own_roi, opponent_roi = self._compute_rois(frame)
184
+
185
+ # Spectator hint: if card icons visible on right side (spectator UI)
186
+ is_spectator = self._detect_spectator_ui(frame)
187
+
188
+ mode_hint = "live"
189
+ if is_spectator:
190
+ mode_hint = "spectator"
191
+ elif vs_score > 0.7:
192
+ mode_hint = "vs_screen"
193
+
194
+ return BattleGateResult(
195
+ state=self._state,
196
+ confidence=battle_conf,
197
+ arena_roi=arena_roi,
198
+ own_roi=own_roi,
199
+ opponent_roi=opponent_roi,
200
+ is_spectator=is_spectator,
201
+ mode_hint=mode_hint,
202
+ )
203
+
204
+ def _detect_spectator_ui(self, frame: np.ndarray) -> bool:
205
+ """Detect spectator/replay UI: small card icons on the right edge."""
206
+ h, w = frame.shape[:2]
207
+ right_strip = frame[int(h * 0.2):int(h * 0.8), int(w * 0.85):int(w * 0.98)]
208
+ if right_strip.size == 0:
209
+ return False
210
+ gray = cv2.cvtColor(right_strip, cv2.COLOR_BGR2GRAY)
211
+ # Look for many small high-contrast rectangles (card icons)
212
+ edges = cv2.Canny(gray, 50, 150)
213
+ contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
214
+ small_rects = 0
215
+ for cnt in contours:
216
+ x, y, cw, ch = cv2.boundingRect(cnt)
217
+ aspect = cw / max(ch, 1)
218
+ if 0.6 < aspect < 1.4 and 15 < cw < 60 and 15 < ch < 60:
219
+ small_rects += 1
220
+ return small_rects >= 4
221
+
222
+ def reset(self) -> None:
223
+ self._consecutive_battle = 0
224
+ self._consecutive_menu = 0
225
+ self._state = BattleState.UNKNOWN
226
+ self._last_state_time = 0.0
227
+
228
+ @property
229
+ def state(self) -> BattleState:
230
+ return self._state
231
+
232
+ def is_battle(self) -> bool:
233
+ return self._state == BattleState.BATTLE