stevenkhan commited on
Commit
68ce791
·
verified ·
1 Parent(s): e567e4a

Upload clashcr/cli.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. clashcr/cli.py +340 -0
clashcr/cli.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ClashCR CLI entry point.
2
+
3
+ Commands:
4
+ sync-cards Sync card registry from APIs
5
+ record-battle Record emulator gameplay
6
+ label-recording Interactive label editor for recordings
7
+ train-event-model (placeholder) Train vision models
8
+ evaluate-recording Evaluate predictions vs labels
9
+ run Live tracking run
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import logging
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ from clashcr.utils.card_registry import CardRegistry
22
+ from clashcr.core.evaluator import OfflineEvaluator
23
+
24
+ logger = logging.getLogger("clashcr")
25
+
26
+
27
+ def setup_logging(verbose: bool = False) -> None:
28
+ level = logging.DEBUG if verbose else logging.INFO
29
+ logging.basicConfig(
30
+ level=level,
31
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
32
+ datefmt="%Y-%m-%d %H:%M:%S",
33
+ )
34
+
35
+
36
+ def load_config(path: str) -> dict:
37
+ with open(path, "r", encoding="utf-8") as f:
38
+ return yaml.safe_load(f)
39
+
40
+
41
+ def cmd_sync_cards(args) -> int:
42
+ config = load_config(args.config) if args.config else {}
43
+ registry = CardRegistry(cache_path=config.get("card_registry_cache"))
44
+ token = config.get("clash_royale_api_token")
45
+ try:
46
+ meta = registry.sync(api_token=token, fail_on_stale=True)
47
+ print(f"Synced {meta.card_count} cards at {meta.timestamp}")
48
+ print(f" RoyaleAPI source: {meta.source_card_count_royaleapi}")
49
+ print(f" Official API source: {meta.source_card_count_official}")
50
+ for w in meta.warnings:
51
+ print(f" WARNING: {w}")
52
+ registry.to_yaml(str(Path(registry.cache_path).with_suffix(".yaml")))
53
+ except RuntimeError as e:
54
+ print(f"SYNC FAILED (stale): {e}")
55
+ return 1
56
+ return 0
57
+
58
+
59
+ def cmd_record_battle(args) -> int:
60
+ setup_logging(verbose=args.verbose)
61
+ config = load_config(args.config) if args.config else {}
62
+ output_dir = args.output
63
+ seconds = args.seconds
64
+ fps = args.fps
65
+
66
+ from clashcr.core.capture import EmulatorCapture
67
+ from clashcr.core.battle_gating import BattleGater
68
+ from clashcr.core.recorder import EvidenceRecorder, FrameMetadata
69
+
70
+ cap_config = config.get("capture", {})
71
+ capture = EmulatorCapture(
72
+ target_title_substring=cap_config.get("title_substring"),
73
+ target_class_name=cap_config.get("class_name"),
74
+ )
75
+
76
+ gater = BattleGater(
77
+ battle_threshold=config.get("battle_threshold", 0.6),
78
+ consecutive_frames=config.get("battle_consecutive_frames", 3),
79
+ )
80
+
81
+ recorder = EvidenceRecorder(
82
+ output_dir=output_dir,
83
+ save_frames=True,
84
+ max_fps=fps,
85
+ )
86
+
87
+ with recorder:
88
+ start = time.monotonic()
89
+ frame_idx = 0
90
+ while time.monotonic() - start < seconds:
91
+ frame = capture.capture(save_raw_path=None)
92
+ if frame is None:
93
+ time.sleep(0.1)
94
+ continue
95
+
96
+ battle = gater.process(frame)
97
+ info = capture.get_info()
98
+
99
+ meta = FrameMetadata(
100
+ timestamp=time.monotonic(),
101
+ frame_idx=frame_idx,
102
+ window_title=info.title if info else "",
103
+ window_hwnd=info.hwnd if info else 0,
104
+ window_width=info.client_width if info else 0,
105
+ window_height=info.client_height if info else 0,
106
+ dpi_scale=info.dpi_scale if info else 1.0,
107
+ battle_state=battle.state.name,
108
+ battle_confidence=battle.confidence,
109
+ mode_hint=battle.mode_hint,
110
+ arena_roi=battle.arena_roi,
111
+ own_roi=battle.own_roi,
112
+ opponent_roi=battle.opponent_roi,
113
+ )
114
+ recorder.record_frame(frame, meta)
115
+ frame_idx += 1
116
+ time.sleep(max(0, 1.0 / fps - 0.01))
117
+
118
+ print(f"Recording saved to {output_dir}")
119
+ return 0
120
+
121
+
122
+ def cmd_label_recording(args) -> int:
123
+ print("Label recording: open labels.csv in the recording directory and add rows:")
124
+ print(" timestamp,frame_idx,side,card_key,confidence,manual_note,source")
125
+ print(f" Recording: {args.recording}")
126
+ return 0
127
+
128
+
129
+ def cmd_train_event_model(args) -> int:
130
+ print("Training placeholder: implement YOLO training on recorded+labeled data.")
131
+ print(f"Data dir: {args.data}")
132
+ return 0
133
+
134
+
135
+ def cmd_evaluate_recording(args) -> int:
136
+ evaluator = OfflineEvaluator(timing_tolerance_seconds=args.tolerance)
137
+ result = evaluator.evaluate_recording_dir(args.recording)
138
+ print(f"Evaluation Results for {args.recording}")
139
+ print(f" Precision: {result.precision:.3f}")
140
+ print(f" Recall: {result.recall:.3f}")
141
+ print(f" F1: {result.f1:.3f}")
142
+ print(f" FP/min: {result.false_positives_per_minute:.3f}")
143
+ print(f" Missed: {result.missed_events}")
144
+ print(f" Mean timing error: {result.mean_timing_error_seconds:.2f}s")
145
+ print(f" Predictions: {result.total_predictions}, Labels: {result.total_labels}, Correct: {result.correct}")
146
+ return 0
147
+
148
+
149
+ def cmd_run(args) -> int:
150
+ setup_logging(verbose=args.verbose)
151
+ config = load_config(args.config) if args.config else {}
152
+
153
+ # Deferred imports to avoid loading cv2 for non-vision commands
154
+ from clashcr.core.capture import EmulatorCapture
155
+ from clashcr.core.battle_gating import BattleGater
156
+ from clashcr.core.event_detector import EventDetector
157
+ from clashcr.core.recorder import EvidenceRecorder, FrameMetadata
158
+ from clashcr.models.evidence_model import EvidenceModel
159
+ from clashcr.models.card_resolver import CardResolver
160
+ from clashcr.game.elixir_tracker import ElixirTracker
161
+ from clashcr.game.deck_tracker import DeckTracker
162
+
163
+ # Initialize components
164
+ registry = CardRegistry(cache_path=config.get("card_registry_cache"))
165
+ if not registry.load():
166
+ logger.error("Card registry not loaded. Run sync-cards first.")
167
+ return 1
168
+
169
+ cap_config = config.get("capture", {})
170
+ capture = EmulatorCapture(
171
+ target_title_substring=cap_config.get("title_substring"),
172
+ target_class_name=cap_config.get("class_name"),
173
+ )
174
+
175
+ gater = BattleGater(
176
+ battle_threshold=config.get("battle_threshold", 0.6),
177
+ consecutive_frames=config.get("battle_consecutive_frames", 3),
178
+ )
179
+
180
+ model_config = config.get("model", {})
181
+ evidence_model = EvidenceModel(
182
+ model_path=model_config.get("yolo_path"),
183
+ conf_threshold=model_config.get("conf_threshold", 0.5),
184
+ spell_enabled=model_config.get("spell_enabled", True),
185
+ device=model_config.get("device", "cpu"),
186
+ )
187
+
188
+ resolver = CardResolver(registry=registry)
189
+ elixir = ElixirTracker(registry=registry)
190
+ deck = DeckTracker()
191
+
192
+ detector = EventDetector(
193
+ diff_threshold=config.get("diff_threshold", 25),
194
+ min_event_area=config.get("min_event_area", 200),
195
+ debug_dir=args.debug_dir if args.debug_dir else None,
196
+ )
197
+
198
+ recorder = None
199
+ if args.record_dir:
200
+ recorder = EvidenceRecorder(output_dir=args.record_dir, save_frames=args.raw, max_fps=8.0)
201
+ recorder.open()
202
+
203
+ logger.info("Starting live run...")
204
+ frame_idx = 0
205
+ try:
206
+ while True:
207
+ frame = capture.capture(save_raw_path=args.debug_frame if args.debug_frame and frame_idx % 30 == 0 else None)
208
+ if frame is None:
209
+ time.sleep(0.05)
210
+ continue
211
+
212
+ battle = gater.process(frame)
213
+
214
+ if recorder:
215
+ info = capture.get_info()
216
+ meta = FrameMetadata(
217
+ timestamp=time.monotonic(),
218
+ frame_idx=frame_idx,
219
+ window_title=info.title if info else "",
220
+ window_hwnd=info.hwnd if info else 0,
221
+ window_width=info.client_width if info else 0,
222
+ window_height=info.client_height if info else 0,
223
+ dpi_scale=info.dpi_scale if info else 1.0,
224
+ battle_state=battle.state.name,
225
+ battle_confidence=battle.confidence,
226
+ mode_hint=battle.mode_hint,
227
+ arena_roi=battle.arena_roi,
228
+ own_roi=battle.own_roi,
229
+ opponent_roi=battle.opponent_roi,
230
+ )
231
+ recorder.record_frame(frame, meta)
232
+
233
+ if not gater.is_battle():
234
+ frame_idx += 1
235
+ time.sleep(0.05)
236
+ continue
237
+
238
+ # Detect events
239
+ events = detector.process(frame, battle)
240
+ for ev in events:
241
+ if ev.suppressed or ev.side != "opponent":
242
+ continue
243
+
244
+ # Extract opponent ROI crop
245
+ x, y, w, h = ev.bbox
246
+ crop = frame[y:y+h, x:x+w]
247
+ if crop.size == 0:
248
+ continue
249
+
250
+ bundle = evidence_model.process(crop, side="opponent")
251
+ resolved = resolver.resolve(bundle)
252
+
253
+ if resolved.is_ambiguous:
254
+ logger.info("[AMBIGUOUS] %s (reason: %s)", resolved.card_key, resolved.ambiguity_reason)
255
+ if recorder:
256
+ recorder.add_prediction(ev.timestamp, frame_idx, "opponent",
257
+ resolved.card_key, resolved.confidence,
258
+ str(bundle.possible_cards), resolved.reasoning)
259
+ continue
260
+
261
+ if resolver.deduplicate(resolved, cooldown_seconds=2.0):
262
+ continue
263
+
264
+ elixir.deduct(resolved.card_key, resolved.confidence)
265
+ deck.register_play(resolved.card_key, resolved.confidence)
266
+
267
+ logger.info("[EVENT] %s (conf=%.2f, elixir=%.1f, deck=%s)",
268
+ resolved.card_key, resolved.confidence,
269
+ elixir.get_state().current, deck.state.deck)
270
+
271
+ if recorder:
272
+ recorder.add_prediction(ev.timestamp, frame_idx, "opponent",
273
+ resolved.card_key, resolved.confidence,
274
+ str(bundle.possible_cards), resolved.reasoning)
275
+
276
+ frame_idx += 1
277
+ time.sleep(0.05)
278
+ except KeyboardInterrupt:
279
+ logger.info("Stopping live run.")
280
+ finally:
281
+ if recorder:
282
+ recorder.close()
283
+
284
+ return 0
285
+
286
+
287
+ def main(argv: Optional[list] = None) -> int:
288
+ parser = argparse.ArgumentParser(prog="clashcr", description="Clash Royale opponent card tracker")
289
+ parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
290
+ sub = parser.add_subparsers(dest="command")
291
+
292
+ p_sync = sub.add_parser("sync-cards", help="Sync card registry")
293
+ p_sync.add_argument("--config", default="config.yaml", help="Config file")
294
+
295
+ p_rec = sub.add_parser("record-battle", help="Record battle footage")
296
+ p_rec.add_argument("--config", default="config.yaml")
297
+ p_rec.add_argument("--output", required=True, help="Output directory")
298
+ p_rec.add_argument("--seconds", type=int, default=180, help="Recording duration")
299
+ p_rec.add_argument("--fps", type=float, default=8.0, help="Capture FPS")
300
+
301
+ p_label = sub.add_parser("label-recording", help="Label a recording")
302
+ p_label.add_argument("--recording", required=True)
303
+
304
+ p_train = sub.add_parser("train-event-model", help="Train event detection model")
305
+ p_train.add_argument("--config", default="config.yaml")
306
+ p_train.add_argument("--data", required=True)
307
+
308
+ p_eval = sub.add_parser("evaluate-recording", help="Evaluate recording predictions")
309
+ p_eval.add_argument("--config", default="config.yaml")
310
+ p_eval.add_argument("--recording", required=True)
311
+ p_eval.add_argument("--labels", default=None)
312
+ p_eval.add_argument("--tolerance", type=float, default=3.0, help="Timing tolerance in seconds")
313
+
314
+ p_run = sub.add_parser("run", help="Live tracking run")
315
+ p_run.add_argument("--config", default="config.yaml")
316
+ p_run.add_argument("--raw", action="store_true", help="Save raw frames")
317
+ p_run.add_argument("--debug-frame", default=None, help="Save debug frame path")
318
+ p_run.add_argument("--debug-dir", default=None, help="Save debug event crops")
319
+ p_run.add_argument("--record-dir", default=None, help="Record session to directory")
320
+
321
+ args = parser.parse_args(argv)
322
+ if not args.command:
323
+ parser.print_help()
324
+ return 1
325
+
326
+ setup_logging(verbose=args.verbose)
327
+
328
+ handlers = {
329
+ "sync-cards": cmd_sync_cards,
330
+ "record-battle": cmd_record_battle,
331
+ "label-recording": cmd_label_recording,
332
+ "train-event-model": cmd_train_event_model,
333
+ "evaluate-recording": cmd_evaluate_recording,
334
+ "run": cmd_run,
335
+ }
336
+ return handlers[args.command](args)
337
+
338
+
339
+ if __name__ == "__main__":
340
+ sys.exit(main())