-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinteractive_gui.py
More file actions
1188 lines (971 loc) · 55.2 KB
/
Copy pathinteractive_gui.py
File metadata and controls
1188 lines (971 loc) · 55.2 KB
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
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Interactive Pitch Control GUI with Real-time Dual Analysis
Shows both traditional pitch control and player influence zones in a live GUI
"""
import sys
#!/usr/bin/env python3
"""
Interactive Pitch Control & Team Influence Analysis GUI
Main application providing dual visualization comparing traditional pitch control
with distance-based team influence analysis. Features real-time parameter adjustment,
elliptical influence zones, and comprehensive match event analysis.
Key Features:
- Side-by-side pitch control vs team influence comparison
- Real-time parameter adjustment with auto-update
- Elliptical influence zones oriented by player direction
- Advanced options (infinite radius, disable closest player)
- Interactive event selection and analysis
Usage:
python interactive_gui.py
Author: Pitch Control Analysis Team
Last Modified: February 2026
"""
import os
import tkinter as tk
from tkinter import ttk, messagebox
import threading
import queue
import numpy as np
import pandas as pd
# Add LaurieOnTracking to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'LaurieOnTracking'))
# Try to import matplotlib with tkinter backend
try:
import matplotlib
matplotlib.use('TkAgg') # Use Tkinter backend for embedding
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
from matplotlib.figure import Figure
MATPLOTLIB_AVAILABLE = True
except ImportError as e:
print(f"Warning: matplotlib with TkAgg backend not available: {e}")
MATPLOTLIB_AVAILABLE = False
import Metrica_Velocities as mvel
import Metrica_PitchControl as mpc
from data_converter import load_new_data
from velocity_parameter_control import VelocityParameterController, ParameterOptimizer
class PitchControlAnalysisGUI:
"""
Main GUI application for interactive pitch control and team influence analysis.
Provides a dual-panel interface comparing traditional pitch control (left panel)
with distance-based team influence zones (right panel). Includes real-time
parameter adjustment and advanced visualization options.
Key Components:
- Data loading: FIFA tracking and event data
- Parameter controls: radius, decay, elliptical options
- Dual visualization: matplotlib plots with threading for responsiveness
- Event selection: dropdown with all available match events
Parameters that can be adjusted:
- Radius (1-50m): Distance cutoff for influence calculations
- Decay Factor (0.1-20.0): Rate of influence decrease with distance
- Elliptical Zones: Use player-direction oriented ellipses vs circles
- Ellipse Ratio (1.0-5.0): Forward/backward influence ratio
- Forward Shift (0.0-1.0): Ellipse center displacement
- Infinite Radius: Cover entire field regardless of radius setting
- Disable Closest Player: Don't auto-assign uncontrolled pixels
"""
def __init__(self, root):
self.root = root
self.root.title("FIFA Match - Interactive Pitch Control Analysis")
self.root.geometry("1400x900")
# Data storage
self.tracking_home = None
self.tracking_away = None
self.events = None
self.pass_events = None
self.field_dimen = (106., 68.)
self.params = None
self.GK_numbers = None
self.home_players = None
self.away_players = None
# Velocity-based parameter control
self.velocity_controller = VelocityParameterController()
self.parameter_optimizer = ParameterOptimizer(self.velocity_controller)
self.current_player_parameters = None
# GUI elements
self.current_event_id = None
self.canvas = None
self.figure = None
self.influence_generator = None
# Threading
self.analysis_queue = queue.Queue()
if not MATPLOTLIB_AVAILABLE:
self.show_error_and_exit()
return
self.setup_ui()
self.load_data()
def show_error_and_exit(self):
"""Show error message if matplotlib is not available"""
error_frame = ttk.Frame(self.root, padding="20")
error_frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(error_frame, text="❌ Matplotlib with TkAgg backend not available",
font=("Arial", 14, "bold"), foreground="red").pack(pady=20)
ttk.Label(error_frame, text="Please install matplotlib with: pip install matplotlib",
font=("Arial", 12)).pack(pady=10)
ttk.Button(error_frame, text="Exit", command=self.root.quit).pack(pady=10)
def setup_ui(self):
"""Setup the user interface"""
# Main container
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Top controls frame
controls_frame = ttk.LabelFrame(main_frame, text="Analysis Controls", padding="10")
controls_frame.pack(fill=tk.X, pady=(0, 10))
# Event selection frame
event_frame = ttk.Frame(controls_frame)
event_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(event_frame, text="Event:").grid(row=0, column=0, padx=(0, 5))
self.event_var = tk.StringVar()
self.event_combo = ttk.Combobox(event_frame, textvariable=self.event_var,
state="readonly", width=50)
self.event_combo.grid(row=0, column=1, padx=(0, 10), sticky=(tk.W, tk.E))
self.event_combo.bind('<<ComboboxSelected>>', self.on_event_selected)
ttk.Button(event_frame, text="Analyze", command=self.analyze_current_event).grid(
row=0, column=2, padx=(10, 0))
event_frame.columnconfigure(1, weight=1)
# Parameter Control Mode frame
mode_frame = ttk.LabelFrame(controls_frame, text="Parameter Control Mode", padding="10")
mode_frame.pack(fill=tk.X, pady=(10, 0))
ttk.Label(mode_frame, text="Control Mode:").grid(row=0, column=0, padx=(0, 5))
self.param_mode_var = tk.StringVar(value="Manual")
mode_combo = ttk.Combobox(mode_frame, textvariable=self.param_mode_var,
state="readonly", width=20,
values=["Manual", "Initial Velocity", "Average Velocity"])
mode_combo.grid(row=0, column=1, padx=(0, 20))
mode_combo.bind('<<ComboboxSelected>>', self.on_mode_changed)
# Velocity info label
self.velocity_info_var = tk.StringVar(value="Manual parameter control")
ttk.Label(mode_frame, textvariable=self.velocity_info_var,
font=("Arial", 9, "italic")).grid(row=0, column=2, padx=(20, 0))
# Auto-optimize button
self.optimize_btn = ttk.Button(mode_frame, text="Auto-Optimize Relations",
command=self.auto_optimize_parameters, state=tk.DISABLED)
self.optimize_btn.grid(row=0, column=3, padx=(20, 0))
# Multi-event optimize button
self.multi_optimize_btn = ttk.Button(mode_frame, text="Multi-Event Optimize",
command=self.multi_event_optimize, state=tk.DISABLED)
self.multi_optimize_btn.grid(row=0, column=4, padx=(10, 0))
# Influence parameters frame
params_frame = ttk.LabelFrame(controls_frame, text="Player Influence Parameters", padding="10")
params_frame.pack(fill=tk.X, pady=(10, 0))
# Radius control (for both teams)
ttk.Label(params_frame, text="Radius:").grid(row=0, column=0, padx=(0, 5))
self.radius_var = tk.DoubleVar(value=15.0)
radius_spin = ttk.Spinbox(params_frame, from_=5.0, to=50.0, increment=1.0,
textvariable=self.radius_var, width=10)
radius_spin.grid(row=0, column=1, padx=(0, 20))
# Decay factor control
ttk.Label(params_frame, text="Decay Factor:").grid(row=0, column=2, padx=(0, 5))
self.decay_factor_var = tk.DoubleVar(value=2.0)
decay_spin = ttk.Spinbox(params_frame, from_=0.1, to=20.0, increment=0.1,
textvariable=self.decay_factor_var, width=10)
decay_spin.grid(row=0, column=3, padx=(0, 20))
# Auto-update checkbox
self.auto_update_var = tk.BooleanVar(value=False)
ttk.Checkbutton(params_frame, text="Auto-update on parameter change",
variable=self.auto_update_var).grid(row=0, column=4, padx=(20, 0))
# Second row for shape options
# Elliptical zones option
self.elliptical_var = tk.BooleanVar(value=False)
ttk.Checkbutton(params_frame, text="Use Elliptical Zones",
variable=self.elliptical_var).grid(row=1, column=0, sticky=tk.W, pady=(5, 0))
# Ellipse ratio control
ttk.Label(params_frame, text="Ellipse Ratio:").grid(row=1, column=1, padx=(10, 5), sticky=tk.W, pady=(5, 0))
self.ellipse_ratio_var = tk.DoubleVar(value=2.0)
ellipse_ratio_spin = ttk.Spinbox(params_frame, from_=1.0, to=5.0, increment=0.1,
textvariable=self.ellipse_ratio_var, width=10)
ellipse_ratio_spin.grid(row=1, column=2, padx=(0, 10), sticky=tk.W, pady=(5, 0))
# Forward shift control
ttk.Label(params_frame, text="Forward Shift:").grid(row=1, column=3, padx=(10, 5), sticky=tk.W, pady=(5, 0))
self.forward_shift_var = tk.DoubleVar(value=0.5)
forward_shift_spin = ttk.Spinbox(params_frame, from_=0.0, to=1.0, increment=0.1,
textvariable=self.forward_shift_var, width=10)
forward_shift_spin.grid(row=1, column=4, padx=(0, 20), sticky=tk.W, pady=(5, 0))
# Third row for existing new options
# Infinite radius option
self.infinite_radius_var = tk.BooleanVar(value=False)
ttk.Checkbutton(params_frame, text="Infinite Gaussian Radius (whole field)",
variable=self.infinite_radius_var).grid(row=2, column=0, columnspan=3, sticky=tk.W, pady=(5, 0))
# Disable closest player option
self.disable_closest_var = tk.BooleanVar(value=False)
ttk.Checkbutton(params_frame, text="Disable closest player for unidentified pixels",
variable=self.disable_closest_var).grid(row=2, column=3, columnspan=2, sticky=tk.W, pady=(5, 0))
# Row 3: Separator
ttk.Separator(params_frame, orient='horizontal').grid(row=3, column=0, columnspan=5, sticky=(tk.W, tk.E), pady=10)
# Row 4: Hyperspace options
ttk.Label(params_frame, text="🚀 Hyperspace Kernel Options (3rd Panel):",
font=("Arial", 9, "bold")).grid(row=4, column=0, columnspan=5, sticky=tk.W, pady=(5, 5))
self.hyperspace_var = tk.BooleanVar(value=True)
ttk.Checkbutton(params_frame, text="Enable Hyperspace Kernel Analysis",
variable=self.hyperspace_var).grid(row=5, column=0, columnspan=2, sticky=tk.W, pady=(5, 0))
ttk.Label(params_frame, text="Kernel Type:").grid(row=5, column=2, padx=(10, 5), sticky=tk.W, pady=(5, 0))
self.kernel_type_var = tk.StringVar(value="polynomial")
kernel_combo = ttk.Combobox(params_frame, textvariable=self.kernel_type_var,
values=["polynomial", "rbf", "custom"], state="readonly", width=15)
kernel_combo.grid(row=5, column=3, padx=(0, 10), sticky=tk.W, pady=(5, 0))
ttk.Label(params_frame, text="(Maps 2D → Higher Dimensions, now with elliptical support!)",
font=("Arial", 8), foreground="gray").grid(row=5, column=4, sticky=tk.W, pady=(5, 0))
# Row 6: Separator
ttk.Separator(params_frame, orient='horizontal').grid(row=6, column=0, columnspan=5, sticky=(tk.W, tk.E), pady=10)
# Row 7: Exponential Approximation (Matrix Formulation)
ttk.Label(params_frame, text="🔢 Exponential Approximation (Matrix Formulation):",
font=("Arial", 9, "bold")).grid(row=7, column=0, columnspan=5, sticky=tk.W, pady=(5, 5))
self.approximate_exp_var = tk.BooleanVar(value=False)
ttk.Checkbutton(params_frame, text="Approximate exp() → Linear Algebra",
variable=self.approximate_exp_var).grid(row=8, column=0, columnspan=2, sticky=tk.W, pady=(5, 0))
ttk.Label(params_frame, text="Method:").grid(row=8, column=2, padx=(10, 5), sticky=tk.W, pady=(5, 0))
self.exp_method_var = tk.StringVar(value="pade")
exp_method_combo = ttk.Combobox(params_frame, textvariable=self.exp_method_var,
values=["pade", "taylor", "polynomial"], state="readonly", width=15)
exp_method_combo.grid(row=8, column=3, padx=(0, 10), sticky=tk.W, pady=(5, 0))
ttk.Label(params_frame, text="Order:").grid(row=8, column=4, padx=(10, 5), sticky=tk.W, pady=(5, 0))
self.exp_order_var = tk.IntVar(value=3) # Default to order 3 for better accuracy
exp_order_spin = ttk.Spinbox(params_frame, from_=1, to=4, increment=1,
textvariable=self.exp_order_var, width=8)
exp_order_spin.grid(row=8, column=4, padx=(50, 0), sticky=tk.W, pady=(5, 0))
ttk.Label(params_frame, text="(Replaces exp(-x) with Padé/Taylor/Polynomial - Order 3 gives <0.2% error)",
font=("Arial", 8), foreground="gray").grid(row=9, column=0, columnspan=5, sticky=tk.W, pady=(2, 0))
# SVD Factorization Control (Row 10)
self.use_svd_var = tk.BooleanVar(value=False)
ttk.Checkbutton(params_frame, text="Use SVD Factorization (I = A × B)",
variable=self.use_svd_var).grid(row=10, column=0, columnspan=2, sticky=tk.W, pady=(5, 0))
ttk.Label(params_frame, text="Rank k:").grid(row=10, column=2, padx=(10, 5), sticky=tk.W, pady=(5, 0))
self.svd_rank_var = tk.IntVar(value=4) # Default to rank 4 (exact for 4 players)
svd_rank_spin = ttk.Spinbox(params_frame, from_=1, to=11, increment=1,
textvariable=self.svd_rank_var, width=8)
svd_rank_spin.grid(row=10, column=2, padx=(60, 0), sticky=tk.W, pady=(5, 0))
ttk.Label(params_frame, text="(Matrix multiplication form: k=4 is exact, k=2-3 gives 90-98% accuracy)",
font=("Arial", 8), foreground="gray").grid(row=11, column=0, columnspan=5, sticky=tk.W, pady=(2, 0))
# Bind parameter changes
for var in [self.radius_var, self.decay_factor_var, self.infinite_radius_var, self.disable_closest_var,
self.elliptical_var, self.ellipse_ratio_var, self.forward_shift_var,
self.hyperspace_var, self.kernel_type_var,
self.approximate_exp_var, self.exp_method_var, self.exp_order_var,
self.use_svd_var, self.svd_rank_var]:
var.trace('w', self.on_parameter_changed)
# Model Equation Display Frame
equation_frame = ttk.LabelFrame(controls_frame, text="📐 Competitive Influence Model Equation (Matrix Formulation)", padding="10")
equation_frame.pack(fill=tk.X, pady=(10, 0))
equation_text = """Matrix Form: diag(I_home + I_away) @ I_competitive = I_home [Constrained Least Squares]
SVD Form: I = A @ B where A ∈ R^(m×k) (grid factor), B ∈ R^(k×n) (player factor) [k=4 exact, k<4 approximate]
Expanded: I(x,y) = sum exp(-lambda(v)*d/r(v)) / [sum_home exp(-lambda(v)*d/r(v)) + sum_away exp(-lambda(v)*d/r(v))]
Approximated: exp(-x) ~= Pade: (120-60x+12x^2-x^3)/(120+60x+12x^2+x^3) | Taylor: 1-x+x^2/2-... | Polynomial: fit
Parameters: d = sqrt[(x-x_p)^2+(y-y_p)^2] | v = sqrt(vx^2+vy^2) | r(v) = 10+2.6v | lambda(v) = 5+1.9v | alpha(v) = 1+0.15v"""
equation_label = ttk.Label(equation_frame, text=equation_text,
font=("Courier", 9), foreground="#0066cc",
justify=tk.LEFT)
equation_label.pack(anchor=tk.W)
# Status frame
status_frame = ttk.Frame(controls_frame)
status_frame.pack(fill=tk.X, pady=(10, 0))
self.status_var = tk.StringVar(value="Ready - Load data and select an event")
self.status_label = ttk.Label(status_frame, textvariable=self.status_var)
self.status_label.pack(side=tk.LEFT)
self.progress = ttk.Progressbar(status_frame, mode='indeterminate', length=200)
self.progress.pack(side=tk.RIGHT)
# Main visualization frame
self.viz_frame = ttk.Frame(main_frame)
self.viz_frame.pack(fill=tk.BOTH, expand=True)
# Create matplotlib figure
self.create_matplotlib_widget()
def create_matplotlib_widget(self):
"""Create embedded matplotlib widget with 3 subplots"""
# Create figure with 3 subplots for triple analysis
self.figure = Figure(figsize=(21, 7), facecolor='#1a1a1a')
# Create canvas
self.canvas = FigureCanvasTkAgg(self.figure, self.viz_frame)
self.canvas.draw()
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
# Create toolbar
toolbar = NavigationToolbar2Tk(self.canvas, self.viz_frame)
toolbar.update()
# Initial empty plot
self.show_welcome_message()
def show_welcome_message(self):
"""Show welcome message on empty canvas"""
self.figure.clear()
ax = self.figure.add_subplot(111)
ax.text(0.5, 0.5, "⚽ FIFA Match Pitch Control Analysis\n\n" +
"Triple Comparison View:\n" +
"1. Traditional Pitch Control (Physics-based)\n" +
"2. Distance-Based Influence (Circular/Elliptical)\n" +
"3. Hyperspace Kernel Influence (Higher Dimensions)\n\n" +
"Select an event and click 'Analyze'",
ha='center', va='center', fontsize=14, color='white',
bbox=dict(boxstyle="round,pad=0.5", facecolor='#2d5016', alpha=0.8))
ax.set_facecolor('#1a1a1a')
ax.axis('off')
self.canvas.draw()
def load_data(self):
"""Load FIFA match data in background thread"""
def load_data_thread():
try:
self.root.after(0, lambda: self.status_var.set("Loading FIFA match data..."))
self.root.after(0, lambda: self.progress.start())
current_dir = os.path.dirname(os.path.abspath(__file__))
# Load data
self.tracking_home, self.tracking_away, self.events = load_new_data(current_dir, "3812")
# Convert to metric coordinates
self.tracking_home = self.to_metric_coordinates(self.tracking_home)
self.tracking_away = self.to_metric_coordinates(self.tracking_away)
# Convert event coordinates
self.events['Start X'] = (self.events['Start X'] - 0.5) * self.field_dimen[0]
self.events['Start Y'] = (self.events['Start Y'] - 0.5) * self.field_dimen[1]
self.events['End X'] = (self.events['End X'] - 0.5) * self.field_dimen[0]
self.events['End Y'] = (self.events['End Y'] - 0.5) * self.field_dimen[1]
# Calculate velocities
self.root.after(0, lambda: self.status_var.set("Calculating player velocities..."))
self.tracking_home = mvel.calc_player_velocities(self.tracking_home, smoothing=True)
self.tracking_away = mvel.calc_player_velocities(self.tracking_away, smoothing=True)
# Find goalkeepers and players
self.find_goalkeepers()
# Setup parameters
self.params = mpc.default_model_params()
# Filter to pass events
self.pass_events = self.events[self.events['Type'] == 'PASS'].copy()
# Update UI on main thread
self.root.after(0, self.populate_events)
self.root.after(0, lambda: self.status_var.set(
f"Loaded {len(self.events)} events ({len(self.pass_events)} passes) - Select an event"))
except Exception as e:
self.root.after(0, lambda: messagebox.showerror("Error", f"Failed to load data: {str(e)}"))
self.root.after(0, lambda: self.status_var.set("Error loading data"))
finally:
self.root.after(0, lambda: self.progress.stop())
# Start loading in background
thread = threading.Thread(target=load_data_thread, daemon=True)
thread.start()
def to_metric_coordinates(self, data):
"""Convert positions from normalized units to meters"""
x_columns = [c for c in data.columns if c[-2:] == '_x' and c[:4] in ['Home', 'Away']]
y_columns = [c for c in data.columns if c[-2:] == '_y' and c[:4] in ['Home', 'Away']]
data[x_columns] = (data[x_columns] - 0.5) * self.field_dimen[0]
data[y_columns] = (data[y_columns] - 0.5) * self.field_dimen[1]
return data
def find_goalkeepers(self):
"""Find goalkeeper jersey numbers based on average position"""
home_players = sorted(set([c.split('_')[1] for c in self.tracking_home.columns
if c.startswith('Home_') and c.endswith('_x')]))
away_players = sorted(set([c.split('_')[1] for c in self.tracking_away.columns
if c.startswith('Away_') and c.endswith('_x')]))
home_avg_x = {p: self.tracking_home[f'Home_{p}_x'].mean() for p in home_players}
away_avg_x = {p: self.tracking_away[f'Away_{p}_x'].mean() for p in away_players}
home_gk = min(home_avg_x, key=home_avg_x.get)
away_gk = max(away_avg_x, key=away_avg_x.get)
self.GK_numbers = (home_gk, away_gk)
self.home_players = home_players
self.away_players = away_players
def populate_events(self):
"""Populate the events dropdown"""
if self.pass_events is not None and len(self.pass_events) > 0:
event_options = {}
event_list = []
for event_id, event in self.pass_events.head(50).iterrows(): # Limit to first 50 for performance
option_text = f"Event {event_id}: {event['From']} → {event['To']} ({event['Team']}) - Frame {int(event['Start Frame'])}"
event_options[option_text] = event_id
event_list.append(option_text)
self.event_combo['values'] = event_list
self.event_options = event_options
# Select first event by default
if event_list:
self.event_combo.current(0)
# Trigger event selection for the first item
self.on_event_selected()
def on_event_selected(self, event=None):
"""Handle event selection"""
selected_text = self.event_var.get()
if selected_text and hasattr(self, 'event_options') and selected_text in self.event_options:
self.current_event_id = self.event_options[selected_text]
self.status_var.set(f"Selected event {self.current_event_id} - Ready to analyze")
def on_mode_changed(self, event=None):
"""Handle parameter control mode changes"""
mode = self.param_mode_var.get()
# Update velocity info
if mode == "Manual":
self.velocity_info_var.set("Manual parameter control")
self.optimize_btn.config(state=tk.DISABLED)
self.multi_optimize_btn.config(state=tk.DISABLED)
# Enable manual parameter controls
for child in self.root.nametowidget('.').winfo_children():
self._enable_manual_controls(child, True)
elif mode == "Initial Velocity":
self.velocity_info_var.set("Parameters controlled by initial velocity at event start")
self.optimize_btn.config(state=tk.NORMAL)
self.multi_optimize_btn.config(state=tk.NORMAL)
# Disable manual parameter controls
for child in self.root.nametowidget('.').winfo_children():
self._enable_manual_controls(child, False)
elif mode == "Average Velocity":
self.velocity_info_var.set("Parameters controlled by average velocity over 2 seconds")
self.optimize_btn.config(state=tk.NORMAL)
self.multi_optimize_btn.config(state=tk.NORMAL)
# Disable manual parameter controls
for child in self.root.nametowidget('.').winfo_children():
self._enable_manual_controls(child, False)
# Re-analyze if auto-update is on
if self.auto_update_var.get() and self.current_event_id is not None:
self.analyze_current_event()
def _enable_manual_controls(self, widget, enable):
"""Recursively enable/disable manual parameter controls"""
try:
widget_name = str(widget)
# Check if it's one of our parameter spinboxes
if hasattr(widget, 'config'):
if 'spinbox' in widget_name.lower():
# Check if it's a parameter control (not event selection)
parent = widget.winfo_parent()
if 'parameters' in parent.lower() or 'param' in parent.lower():
widget.config(state='normal' if enable else 'disabled')
except:
pass
# Recurse through children
try:
for child in widget.winfo_children():
self._enable_manual_controls(child, enable)
except:
pass
def on_parameter_changed(self, *args):
"""Handle parameter changes"""
if self.auto_update_var.get() and self.current_event_id is not None:
self.analyze_current_event()
def auto_optimize_parameters(self):
"""Run automatic parameter optimization"""
if self.current_event_id is None or self.tracking_home is None:
messagebox.showwarning("No Data", "Please select an event first")
return
# Show confirmation
result = messagebox.askyesno(
"Auto-Optimize",
"This will test multiple parameter configurations to find the best velocity-parameter relationship.\n\n" +
"This may take a few moments. Continue?"
)
if not result:
return
# Run optimization in background
thread = threading.Thread(target=self.optimize_parameters_thread, daemon=True)
thread.start()
def optimize_parameters_thread(self):
"""Run parameter optimization in background"""
try:
self.root.after(0, lambda: self.progress.start())
self.root.after(0, lambda: self.status_var.set("Running parameter optimization..."))
event_info = self.events.loc[self.current_event_id]
event_frame = int(event_info['Start Frame'])
# Run optimization
results = self.parameter_optimizer.auto_optimize(
self.tracking_home, self.tracking_away, event_frame,
n_tests=20
)
# Show results summary
best_result = max(results, key=lambda x: x['statistics']['avg_radius'])
self.root.after(0, lambda: self.show_optimization_results(results, best_result))
self.root.after(0, lambda: self.status_var.set("Optimization complete"))
except Exception as e:
self.root.after(0, lambda: messagebox.showerror("Error", f"Optimization failed: {str(e)}"))
finally:
self.root.after(0, lambda: self.progress.stop())
def show_optimization_results(self, results, best_result):
"""Show optimization results in a message box"""
stats = best_result['statistics']
message = "Optimization Results:\n\n"
message += f"Tested {len(results)} configurations\n\n"
message += "Best Configuration Averages:\n"
message += f" Radius: {stats['avg_radius']:.1f}m\n"
message += f" Decay Factor: {stats['avg_decay']:.2f}\n"
message += f" Ellipse Ratio: {stats['avg_ellipse_ratio']:.2f}\n"
message += f" Forward Shift: {stats['avg_forward_shift']:.2f}\n"
message += f" Player Velocity: {stats['avg_velocity']:.2f} m/s\n"
messagebox.showinfo("Optimization Complete", message)
def multi_event_optimize(self):
"""Run multi-event optimization to find best velocity-parameter relationships"""
if self.tracking_home is None or self.pass_events is None:
messagebox.showwarning("No Data", "Please wait for data to load")
return
# Show configuration dialog
result = messagebox.askyesno(
"Multi-Event Optimization",
"This will analyze multiple events to find the best velocity-parameter relationships.\n\n" +
"INTENSIVE OPTIMIZATION PROCESS:\\n" +
"• Select 20 random events\\n" +
"• Generate traditional pitch control for each\\n" +
"• Stage 1: Coarse grid search (125 configs)\\n" +
"• Stage 2: Random refinement (100 configs)\\n" +
"• Stage 3: Fine-tuning (50 configs)\\n" +
"• Total: ~275 configurations tested\\n\\n" +
"⏱️ This will take 3-5 minutes. Continue?",
icon='question'
)
if not result:
return
# Run in background thread
thread = threading.Thread(target=self.multi_event_optimize_thread, daemon=True)
thread.start()
def multi_event_optimize_thread(self):
"""Run multi-event optimization in background thread"""
try:
self.root.after(0, lambda: self.progress.start())
self.root.after(0, lambda: self.status_var.set("Multi-event optimization in progress..."))
# Select random events for testing - USE MORE EVENTS
n_events = min(20, len(self.pass_events))
test_event_ids = self.pass_events.head(n_events).index.tolist()
print(f"\n{'='*80}")
print(f"INTENSIVE MULTI-EVENT OPTIMIZATION")
print(f"{'='*80}")
print(f"\nSelected {n_events} events for optimization")
# Prepare data for each event
events_data = []
from team_influence_analysis import PlayerInfluenceZones
import Metrica_PitchControl as mpc
for i, event_id in enumerate(test_event_ids):
print(f"\nPreparing event {i+1}/{n_events} (ID: {event_id})...")
event_info = self.events.loc[event_id]
try:
# Generate traditional pitch control
PPCFa, xgrid, ygrid = mpc.generate_pitch_control_for_event(
event_id, self.events, self.tracking_home, self.tracking_away,
self.params, self.GK_numbers, field_dimen=self.field_dimen,
n_grid_cells_x=50, offsides=False
)
# Generate velocity-based influence with current parameters
pass_frame = int(event_info['Start Frame'])
velocity_mode = 'initial' if self.param_mode_var.get() == "Initial Velocity" else 'average'
player_parameters, _ = self.velocity_controller.get_all_players_parameters(
self.tracking_home, self.tracking_away, pass_frame,
velocity_mode=velocity_mode, window_frames=50
)
influence_calc = PlayerInfluenceZones(self.field_dimen, 50)
home_inf, away_inf, comp_inf, _, _ = influence_calc.generate_player_influence_heatmap(
self.tracking_home, self.tracking_away, event_info,
max_radius=None, decay_factor=None,
infinite_radius=False, disable_closest=False,
use_elliptical=True, ellipse_ratio=None, forward_shift=None,
use_hyperspace=False, kernel_type='polynomial',
approximate_exp=False, exp_method='pade', exp_order=3,
player_parameters=player_parameters
)
events_data.append((
self.tracking_home, self.tracking_away, event_info,
PPCFa, comp_inf
))
print(f" ✓ Event {event_id} prepared (correlation: {np.corrcoef(PPCFa.flatten(), comp_inf.flatten())[0,1]:.3f})")
except Exception as e:
print(f" ✗ Event {event_id} failed: {e}")
continue
if len(events_data) < 3:
self.root.after(0, lambda: messagebox.showerror(
"Error", f"Not enough valid events (got {len(events_data)}, need at least 3)"))
return
print(f"\nSuccessfully prepared {len(events_data)} events")
print("\nStarting intensive optimization...\n")
# Run INTENSIVE optimization
optimization_results = self.parameter_optimizer.optimize_across_events(
events_data, n_iterations=30, intensive=True
)
best_config = optimization_results['best_config']
# Apply best configuration
self.parameter_optimizer.apply_best_configuration(best_config)
# Show results
self.root.after(0, lambda: self.show_multi_event_results(best_config))
self.root.after(0, lambda: self.status_var.set(
f"Optimization complete! Error: {best_config['avg_error']:.4f}, Corr: {best_config['avg_correlation']:.3f}"))
except Exception as e:
import traceback
traceback.print_exc()
self.root.after(0, lambda: messagebox.showerror("Error", f"Optimization failed: {str(e)}"))
self.root.after(0, lambda: self.status_var.set("Optimization failed"))
finally:
self.root.after(0, lambda: self.progress.stop())
def show_multi_event_results(self, best_config):
"""Show multi-event optimization results"""
message = "Multi-Event Optimization Complete!\n\n"
message += f"Average Error: {best_config['avg_error']:.4f}\n"
message += f"Average Correlation: {best_config['avg_correlation']:.3f}\n"
message += f"Average Radius: {best_config.get('avg_radius', 0):.1f}m\n\n"
message += "Best Configuration Applied:\n\n"
message += "Base Multipliers:\n"
for param, val in best_config['base_multipliers'].items():
message += f" {param}: {val:.2f}\n"
message += "\nScale Multipliers:\n"
for param, val in best_config['scale_multipliers'].items():
message += f" {param}: {val:.2f}\n"
message += "\nPower Values:\n"
for param, val in best_config['power_values'].items():
message += f" {param}: {val:.2f}\n"
message += "\n✅ Ensures proper influence overlap\n"
message += "and decay competition!\n\n"
message += "Re-analyze current event to see improved results!"
messagebox.showinfo("Optimization Results", message)
def analyze_current_event(self):
"""Analyze currently selected event"""
if self.current_event_id is None:
messagebox.showwarning("No Event", "Please select an event first")
return
if self.tracking_home is None:
messagebox.showwarning("No Data", "Data is still loading, please wait")
return
# Start analysis in background thread
thread = threading.Thread(target=self.analyze_event_thread, daemon=True)
thread.start()
def analyze_event_thread(self):
"""Perform analysis in background thread"""
try:
self.root.after(0, lambda: self.progress.start())
self.root.after(0, lambda: self.status_var.set("Generating analysis..."))
event_info = self.events.loc[self.current_event_id]
# Generate traditional pitch control
PPCFa, xgrid, ygrid = mpc.generate_pitch_control_for_event(
self.current_event_id, self.events, self.tracking_home, self.tracking_away,
self.params, self.GK_numbers, field_dimen=self.field_dimen,
n_grid_cells_x=50, offsides=False
)
# Generate player influence zones using team influence analysis
pass_frame = event_info['Start Frame']
from team_influence_analysis import PlayerInfluenceZones
influence_calc = PlayerInfluenceZones(self.field_dimen, 50)
# Determine parameter control mode
param_mode = self.param_mode_var.get()
if param_mode == "Manual":
# Use manual GUI parameter values
infinite_radius = self.infinite_radius_var.get()
disable_closest = self.disable_closest_var.get()
use_elliptical = self.elliptical_var.get()
max_radius = self.radius_var.get()
decay_factor = self.decay_factor_var.get()
ellipse_ratio = self.ellipse_ratio_var.get()
forward_shift = self.forward_shift_var.get()
# No player-specific parameters
player_parameters = None
else:
# Use velocity-based parameters
velocity_mode = 'initial' if param_mode == "Initial Velocity" else 'average'
# Calculate parameters for all players based on their velocities
self.current_player_parameters, context_mults = self.velocity_controller.get_all_players_parameters(
self.tracking_home, self.tracking_away, int(pass_frame),
velocity_mode=velocity_mode, window_frames=25
)
# Print parameters for debugging
print(f"\n🎯 Using {param_mode} mode:")
self.velocity_controller.print_player_parameters(self.current_player_parameters, context_mults)
# Use GUI settings for global options only
infinite_radius = self.infinite_radius_var.get()
disable_closest = self.disable_closest_var.get()
use_elliptical = True # Always use elliptical in velocity mode
# These will be overridden per-player
max_radius = None
decay_factor = None
ellipse_ratio = None
forward_shift = None
player_parameters = self.current_player_parameters
# === TEAM INFLUENCE ANALYSIS (Middle Panel) - Distance-Based ===
self.root.after(0, lambda: self.status_var.set("Calculating distance-based influence..."))
# Get exponential approximation settings
approximate_exp = self.approximate_exp_var.get()
exp_method = self.exp_method_var.get()
exp_order = self.exp_order_var.get()
# Get SVD settings
use_svd = self.use_svd_var.get()
svd_rank = self.svd_rank_var.get()
# Choose method: SVD or standard
if use_svd:
self.root.after(0, lambda: self.status_var.set(f"Calculating influence (SVD k={svd_rank})..."))
home_influence, away_influence, combined_influence, _, _ = influence_calc.generate_player_influence_heatmap_svd(
self.tracking_home,
self.tracking_away,
event_info,
max_radius=max_radius,
decay_factor=decay_factor,
infinite_radius=infinite_radius,
use_elliptical=use_elliptical,
ellipse_ratio=ellipse_ratio,
forward_shift=forward_shift,
use_hyperspace=False,
approximate_exp=approximate_exp,
exp_method=exp_method,
exp_order=exp_order,
velocity_impact_decay=0.5, # Default value
velocity_impact_radius=3.0, # Default value
svd_rank=svd_rank,
player_parameters=player_parameters,
disable_closest=disable_closest
)
else:
home_influence, away_influence, combined_influence, _, _ = influence_calc.generate_player_influence_heatmap(
self.tracking_home,
self.tracking_away,
event_info,
max_radius=max_radius,
decay_factor=decay_factor,
infinite_radius=infinite_radius,
disable_closest=disable_closest,
use_elliptical=use_elliptical,
ellipse_ratio=ellipse_ratio,
forward_shift=forward_shift,
use_hyperspace=False,
approximate_exp=approximate_exp,
exp_method=exp_method,
exp_order=exp_order,
player_parameters=player_parameters
)
# Convert to the format expected by the visualization (competitive influence)
combined_influence = combined_influence * 2 - 1 # Convert from [0,1] to [-1,1] for RdBu colormap
# === HYPERSPACE KERNEL INFLUENCE (Right Panel) ===
hyperspace_influence = None
use_hyperspace = self.hyperspace_var.get()
kernel_type = self.kernel_type_var.get()
if use_hyperspace:
self.root.after(0, lambda: self.status_var.set(f"Calculating hyperspace ({kernel_type}) influence..."))
# Choose method: SVD or standard
if use_svd:
home_hyper, away_hyper, combined_hyper, _, _ = influence_calc.generate_player_influence_heatmap_svd(
self.tracking_home,
self.tracking_away,
event_info,
max_radius=max_radius,
decay_factor=decay_factor,
infinite_radius=infinite_radius,
use_elliptical=use_elliptical,
ellipse_ratio=ellipse_ratio,
forward_shift=forward_shift,
use_hyperspace=True,
kernel_type=kernel_type,
approximate_exp=approximate_exp,
exp_method=exp_method,
exp_order=exp_order,
velocity_impact_decay=0.5, # Default value
velocity_impact_radius=3.0, # Default value
svd_rank=svd_rank,
player_parameters=player_parameters,
disable_closest=disable_closest
)
else:
# Generate with hyperspace enabled
home_hyper, away_hyper, combined_hyper, _, _ = influence_calc.generate_player_influence_heatmap(
self.tracking_home,
self.tracking_away,
event_info,
max_radius=max_radius,
decay_factor=decay_factor,
infinite_radius=infinite_radius,
disable_closest=disable_closest,
use_elliptical=use_elliptical, # Apply elliptical in hyperspace if enabled
ellipse_ratio=ellipse_ratio,
forward_shift=forward_shift,
use_hyperspace=True,
kernel_type=kernel_type,
approximate_exp=approximate_exp,
exp_method=exp_method,
exp_order=exp_order,
player_parameters=player_parameters
)
# Convert to competitive format
combined_hyper = combined_hyper * 2 - 1
hyperspace_influence = (home_hyper, away_hyper, combined_hyper)
# Update visualization on main thread
self.root.after(0, lambda: self.update_visualization(
PPCFa,
(home_influence, away_influence, combined_influence),
hyperspace_influence,
event_info,
pass_frame))
# Update status
control_pct = PPCFa.mean()
dist_control = (combined_influence.mean() + 1) / 2
status_msg = f"Analysis complete - Traditional: {control_pct:.1%} | Distance: {dist_control:.1%}"
if hyperspace_influence:
hyper_control = (hyperspace_influence[2].mean() + 1) / 2
status_msg += f" | Hyperspace: {hyper_control:.1%}"
if use_svd:
status_msg += f" | 📊 SVD[k={svd_rank}]"
if approximate_exp:
status_msg += f" | 🔢 {exp_method.upper()}[{exp_order}]"
if param_mode != "Manual":
# Calculate average velocity for status message
all_vels = []
for team in ['Home', 'Away']:
for player_id, params in self.current_player_parameters[team].items():
all_vels.append(params['velocity'])
avg_vel = np.mean(all_vels) if all_vels else 0.0
status_msg += f" | Avg velocity: {avg_vel:.2f} m/s"
self.root.after(0, lambda: self.status_var.set(status_msg))
except Exception as e:
import traceback
traceback.print_exc()
self.root.after(0, lambda: messagebox.showerror("Error", f"Analysis failed: {str(e)}"))
self.root.after(0, lambda: self.status_var.set("Analysis failed"))
finally:
self.root.after(0, lambda: self.progress.stop())
def update_visualization(self, PPCFa, influence_zones, hyperspace_zones, event_info, pass_frame):
"""Update the matplotlib visualization with 3 panels"""
# Clear previous plots
self.figure.clear()
# Create 3 subplots
ax1 = self.figure.add_subplot(131)
ax2 = self.figure.add_subplot(132)
ax3 = self.figure.add_subplot(133)
self.figure.patch.set_facecolor('#1a1a1a')
# ===== LEFT PLOT: Traditional Pitch Control =====
ax1.set_facecolor('#3d7d21')
im1 = ax1.imshow(
np.flipud(PPCFa),
extent=[-53, 53, -34, 34],
cmap='RdBu',
alpha=0.7,
interpolation='gaussian',
vmin=0, vmax=1,
aspect='auto',
zorder=1