File size: 91,644 Bytes
e195886
 
 
 
 
 
 
 
 
 
 
 
33b443b
95ad9d6
e195886
 
 
 
 
f6ccbdd
e195886
f6ccbdd
 
 
 
e195886
 
 
f6ccbdd
33b443b
f6ccbdd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33b443b
 
 
 
 
 
 
 
e195886
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
33b443b
 
e195886
33b443b
 
f6ccbdd
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
 
 
 
 
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
e195886
33b443b
 
 
 
 
 
 
 
 
f6ccbdd
 
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
e195886
33b443b
 
e195886
33b443b
 
 
f6ccbdd
33b443b
 
f6ccbdd
33b443b
 
 
 
 
f6ccbdd
33b443b
e195886
33b443b
 
 
 
 
 
f6ccbdd
33b443b
36d65da
 
95ad9d6
36d65da
95ad9d6
e195886
36d65da
e195886
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36d65da
 
 
 
 
 
e195886
 
 
 
 
 
 
 
36d65da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
 
36d65da
 
 
 
 
 
 
 
 
f6ccbdd
36d65da
f6ccbdd
 
 
 
36d65da
f6ccbdd
 
36d65da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
 
36d65da
 
 
 
 
 
 
 
 
 
e195886
36d65da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
36d65da
 
 
 
 
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
36d65da
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
36d65da
e195886
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
 
e195886
 
 
 
f6ccbdd
e195886
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33b443b
e195886
33b443b
e195886
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
33b443b
 
 
 
 
 
 
830ceae
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
33b443b
 
 
 
 
 
 
 
36d65da
33b443b
 
 
 
 
 
 
 
 
 
 
 
e195886
33b443b
 
 
 
 
e195886
33b443b
 
 
 
 
 
 
 
 
e195886
33b443b
 
 
 
 
e195886
33b443b
 
 
 
 
 
 
 
 
e195886
33b443b
 
 
e195886
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
 
33b443b
 
 
 
 
 
 
 
e195886
33b443b
 
 
 
 
 
e195886
33b443b
 
e195886
33b443b
 
 
 
 
e195886
 
36d65da
33b443b
 
f6ccbdd
 
e195886
36d65da
f6ccbdd
36d65da
f6ccbdd
 
 
36d65da
 
 
 
 
 
 
f6ccbdd
36d65da
 
 
 
f6ccbdd
 
36d65da
f6ccbdd
 
 
 
 
 
 
 
 
 
 
36d65da
f6ccbdd
 
 
 
 
 
 
 
 
36d65da
33b443b
f6ccbdd
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
36d65da
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36d65da
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36d65da
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
33b443b
36d65da
e195886
33b443b
f6ccbdd
 
fb73316
f6ccbdd
fb73316
33b443b
 
 
 
 
 
 
 
 
 
fb73316
 
 
36d65da
fb73316
33b443b
 
36d65da
f6ccbdd
33b443b
36d65da
33b443b
e195886
 
33b443b
f6ccbdd
fb73316
33b443b
 
f6ccbdd
33b443b
 
 
 
 
 
 
 
f6ccbdd
 
fb73316
33b443b
 
 
f6ccbdd
 
 
 
 
 
 
fb73316
 
33b443b
f6ccbdd
33b443b
 
 
 
 
 
f6ccbdd
33b443b
 
f6ccbdd
33b443b
e195886
36d65da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
36d65da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
33b443b
 
fb73316
33b443b
 
fb73316
33b443b
 
 
 
fb73316
 
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb73316
 
 
 
 
33b443b
e195886
 
 
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e195886
 
 
33b443b
 
e195886
 
 
fb73316
36d65da
fb73316
 
 
 
e195886
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
 
 
 
 
 
 
 
 
 
 
 
fb73316
33b443b
fb73316
33b443b
 
f6ccbdd
 
 
 
33b443b
f6ccbdd
 
 
 
 
 
 
 
 
fb73316
 
 
 
 
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
 
33b443b
 
 
 
f6ccbdd
 
 
 
 
 
33b443b
 
 
 
f6ccbdd
33b443b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ccbdd
33b443b
 
 
 
fb73316
f6ccbdd
36d65da
f6ccbdd
36d65da
f6ccbdd
 
 
 
 
33b443b
 
 
f6ccbdd
 
 
36d65da
fb73316
f6ccbdd
 
 
33b443b
 
f6ccbdd
36d65da
fb73316
 
 
 
 
f6ccbdd
fb73316
 
f6ccbdd
fb73316
f6ccbdd
 
 
33b443b
 
 
fb73316
33b443b
fb73316
33b443b
 
 
 
 
 
 
 
 
 
 
f6ccbdd
fb73316
 
33b443b
 
 
 
 
 
36d65da
 
33b443b
fb73316
 
36d65da
 
 
33b443b
 
 
 
 
 
 
 
 
36d65da
 
e195886
 
 
 
 
36d65da
 
 
33b443b
e195886
 
33b443b
 
e195886
33b443b
 
e195886
fb73316
33b443b
e195886
fb73316
 
 
36d65da
 
 
 
 
e195886
 
fb73316
 
830ceae
fb73316
f6ccbdd
 
830ceae
fb73316
f6ccbdd
fb73316
 
f6ccbdd
 
 
 
 
 
e195886
f6ccbdd
 
 
 
33b443b
f6ccbdd
e195886
33b443b
 
 
 
 
 
 
e195886
33b443b
 
 
 
 
36d65da
 
fb73316
 
 
 
 
36d65da
 
 
 
f6ccbdd
 
 
 
 
 
 
36d65da
e195886
 
33b443b
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
"""
Front-Facing Golf Swing Metrics Calculator

This module contains all the mathematical calculations for front-facing golf swing metrics.
The metrics_calculator.py file handles DTL (Down-the-Line) view metrics.
"""

import math
import numpy as np
import cv2
from typing import Dict, List, Tuple, Optional, Union



# Landmark indices
L_HIP, R_HIP = 23, 24
L_SHO, R_SHO = 11, 12
L_EYE, R_EYE = 2, 5

# Debug helper - set to True to see detailed metric calculations
VERBOSE = False
def set_debug(on: bool = True):
    global VERBOSE
    VERBOSE = bool(on)

def dbg(msg):
    if VERBOSE: print(f"DEBUG: {msg}")

# Version tag for tracking code changes
METRICS_VERSION = "front-ff v2025-09-26-added-hip-shoulder-separation-4-metrics"

def _best_foot_pair(lm, want_world=False):
    """
    Return a robust (left,right) foot pair tuple for TOES/FOOT-INDEX with highest availability.
    In MediaPipe Pose:
      29 = L_HEEL, 30 = R_HEEL, 31 = L_FOOT_INDEX (toe tip), 32 = R_FOOT_INDEX (toe tip)
    We prefer (31,32). If missing, fall back to (29,30).
    """
    if lm is None: return None
    # World landmarks have 3D tuples; image landmarks have [x,y,vis]
    def ok(pt):
        if not pt: return False
        if want_world:
            return len(pt) >= 3 and all(np.isfinite(pt[:3]))
        else:
            return len(pt) >= 3 and pt[2] >= 0.4 and np.isfinite(pt[0]) and np.isfinite(pt[1])

    # Prefer FOOT_INDEX (toes)
    l_idx, r_idx = 31, 32
    if (len(lm) > r_idx) and ok(lm[l_idx]) and ok(lm[r_idx]):
        return (l_idx, r_idx)
    # Fall back to HEEL
    l_idx, r_idx = 29, 30
    if (len(lm) > r_idx) and ok(lm[l_idx]) and ok(lm[r_idx]):
        return (l_idx, r_idx)
    return None

def _toe_line_unit_img(lm, H, W, roll_deg):
    """
    2D: return unit toe-line vector in the de-rolled image frame.
    Uses best available (31,32) or (29,30). Returns None if unavailable.
    """
    pair = _best_foot_pair(lm, want_world=False)
    if not pair: return None
    iL, iR = pair
    dx = (lm[iR][0] - lm[iL][0]) * (W if max(abs(lm[iR][0]-lm[iL][0]), abs(lm[iR][1]-lm[iL][1])) <= 2.0 else 1.0)
    dy = (lm[iR][1] - lm[iL][1]) * (H if max(abs(lm[iR][0]-lm[iL][0]), abs(lm[iR][1]-lm[iL][1])) <= 2.0 else 1.0)
    cr, sr = math.cos(math.radians(roll_deg)), math.sin(math.radians(roll_deg))
    tx, ty = (cr*dx + sr*dy, -sr*dx + cr*dy)  # de-rolled
    n = math.hypot(tx, ty)
    if n < 1e-6: return None
    return (tx/n, ty/n)

def _toe_line_unit_world(wf):
    """
    3D/XZ: return unit toe-line vector (R-L) in XZ. Prefers (31,32), else (29,30).
    """
    pair = _best_foot_pair(wf, want_world=True)
    if not pair: return None
    iL, iR = pair
    vx = wf[iR][0] - wf[iL][0]
    vz = wf[iR][2] - wf[iL][2]
    n = math.hypot(vx, vz)
    if n < 1e-6: return None
    return (vx/n, vz/n)

# Core math utilities
def wrap180(a): 
    return (a + 180.0) % 360.0 - 180.0

def unit2(v):
    """Normalize 2D vector"""
    n = (v[0]*v[0] + v[1]*v[1])**0.5
    return (v[0]/n, v[1]/n) if n > 1e-6 else (0.0, 1.0)

def signed_angle2(u, v):
    """Signed angle between two 2D vectors in degrees (-180, 180]"""
    dot = u[0]*v[0] + u[1]*v[1]
    det = u[0]*v[1] - u[1]*v[0]
    return math.degrees(math.atan2(det, dot))

def _eye_roll_deg(lx, ly, rx, ry):
    """Calculate angle of the line from right eye to left eye"""
    return math.degrees(math.atan2(ly - ry, lx - rx))

def _midpt(a, b):
    """Calculate midpoint of two 2D points"""
    return ((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5)

def smooth_landmarks(pose_data: Dict[int, List], alpha: float = 0.35) -> Dict[int, List]:
    """Apply exponential moving average smoothing to landmarks"""
    if not pose_data:
        return pose_data
    
    frame_indices = sorted(pose_data.keys())
    smoothed_data = {}
    
    for frame_idx in frame_indices:
        landmarks = pose_data[frame_idx]
        if not landmarks or not isinstance(landmarks, list):
            continue
            
        smoothed_landmarks = []
        for i, lm in enumerate(landmarks):
            if lm and isinstance(lm, list) and len(lm) >= 3:
                x, y, vis = lm[0], lm[1], lm[2]
                
                # Get previous smoothed values
                if frame_idx > 0 and frame_idx - 1 in smoothed_data:
                    prev_lms = smoothed_data[frame_idx - 1]
                    if (i < len(prev_lms) and prev_lms[i] and 
                        isinstance(prev_lms[i], list) and len(prev_lms[i]) >= 3):
                        px, py, pv = prev_lms[i][0], prev_lms[i][1], prev_lms[i][2]
                        x = alpha * x + (1 - alpha) * px
                        y = alpha * y + (1 - alpha) * py  
                        vis = alpha * vis + (1 - alpha) * pv
                
                smoothed_landmarks.append([x, y, vis])
            else:
                smoothed_landmarks.append(lm)
        
        smoothed_data[frame_idx] = smoothed_landmarks
    
    return smoothed_data

def has_world_ok_scale(world_landmarks, frame_idx=None):
    """Check if world landmarks have reasonable scale (relaxed ranges for hip/shoulder span)"""
    if not world_landmarks:
        return False
    
    frames_to_check = [frame_idx] if frame_idx is not None else list(world_landmarks.keys())[:5]
    
    for f in frames_to_check:
        if f not in world_landmarks:
            continue
        wf = world_landmarks[f]
        if not wf or len(wf) < 25:
            continue
            
        # Check hip span (relaxed range: 0.20-0.70m)
        if wf[23] and wf[24]:  # L_HIP, R_HIP
            hip_span = math.hypot(wf[24][0] - wf[23][0], wf[24][2] - wf[23][2])
            if 0.20 <= hip_span <= 0.70:
                dbg(f"[SCALE] Hip span {hip_span:.3f}m is reasonable")
                return True
                
        # Check shoulder span (relaxed range: 0.25-0.70m)
        if wf[11] and wf[12]:  # L_SHO, R_SHO
            sho_span = math.hypot(wf[12][0] - wf[11][0], wf[12][2] - wf[11][2])
            if 0.25 <= sho_span <= 0.70:
                dbg(f"[SCALE] Shoulder span {sho_span:.3f}m is reasonable")
                return True
    
    # Debug why scale check failed
    if frames_to_check:
        f = frames_to_check[0]
        if f in world_landmarks:
            wf = world_landmarks[f]
            if wf and len(wf) >= 25:
                if wf[23] and wf[24]:
                    hip_span = math.hypot(wf[24][0] - wf[23][0], wf[24][2] - wf[23][2])
                    dbg(f"[SCALE] Hip span {hip_span:.3f}m out of range [0.20, 0.70]")
                if wf[11] and wf[12]:
                    sho_span = math.hypot(wf[12][0] - wf[11][0], wf[12][2] - wf[11][2])
                    dbg(f"[SCALE] Shoulder span {sho_span:.3f}m out of range [0.25, 0.70]")
    
    return False

def unit3(v):
    """Normalize 3D vector"""
    n = math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2])
    return np.array(v) / n if n > 1e-6 else np.array([0.0, 0.0, 1.0])

def world_vec(world_landmarks, frame_idx, landmark_name):
    """Get world vector for landmark"""
    landmark_map = {'C7': 0, 'L_HIP': 23, 'R_HIP': 24}  # C7 approximated as nose for now
    if landmark_name == 'C7':
        # Approximate C7 as midpoint between shoulders raised slightly
        if (frame_idx in world_landmarks and len(world_landmarks[frame_idx]) > 12 and
            world_landmarks[frame_idx][11] and world_landmarks[frame_idx][12]):
            l_sho = np.array(world_landmarks[frame_idx][11][:3])
            r_sho = np.array(world_landmarks[frame_idx][12][:3])
            # Raise midpoint slightly to approximate C7
            c7_approx = (l_sho + r_sho) / 2 + np.array([0, 0.08, 0])  # 8cm higher
            return c7_approx
    
    idx = landmark_map.get(landmark_name)
    if idx is None or frame_idx not in world_landmarks:
        return None
    wf = world_landmarks[frame_idx]
    if not wf or len(wf) <= idx or not wf[idx]:
        return None
    return np.array(wf[idx][:3])

def any_nan(arr):
    """Check if array contains any NaN values"""
    return np.any(np.isnan(arr)) if arr is not None else True

def calibrate_sidebend_sign(world_landmarks, impact_idx, handedness, fwd_hat_3d):
    """
    Returns +1 or -1: multiplier to convert 2D dx sign into 'away-from-target = +'
    Uses short window around impact to vote with 3D sign, falls back to handed rule.
    """
    if not has_world_ok_scale(world_landmarks):
        return 1 if handedness == 'right' else -1

    W = range(max(0, impact_idx-2), min(len(list(world_landmarks.keys())), impact_idx+3))
    votes = 0
    for i in W:
        if i not in world_landmarks:
            continue
        c7 = world_vec(world_landmarks, i, 'C7')
        l_hip = world_vec(world_landmarks, i, 'L_HIP')
        r_hip = world_vec(world_landmarks, i, 'R_HIP')
        if c7 is None or l_hip is None or r_hip is None:
            continue
        hip = (l_hip + r_hip) * 0.5
        spine = unit3(c7 - hip)
        # lateral-right unit on ground plane from forward
        lat = unit3(np.cross([0,1,0], fwd_hat_3d))
        s3d = np.sign(np.dot(spine, lat))  # + means leaning to player's right
        votes += s3d

    # majority 3D says which side is player-right; convert to 'away from target = +'
    # For RH, away-from-target == player-right; for LH, opposite.
    prefer_player_right_positive = np.sign(votes) if votes != 0 else 1
    return prefer_player_right_positive if handedness == 'right' else -prefer_player_right_positive

def calculate_torso_sidebend_at_impact(pose_data, swing_phases, frames=None, image_shape=None, handedness='right', world_landmarks=None):
    """
    Front-facing: torso side-bend in degrees at impact.
    Defined as angle between (neck midpoint → hip midpoint) and screen-vertical,
    computed in derolled screen space.
    """
    dbg(f"[SB] handed={handedness}")
    
    # --- Resolve impact frame (with a tiny +δ tolerance) ---
    impact_frames = swing_phases.get("impact", [])
    if impact_frames:
        impact_idx = int(round(impact_frames[0]))
    else:
        # Fallback: find impact using peak hand speed if no swing phases provided
        impact_idx = _find_impact_frame(pose_data, world_landmarks)
        if impact_idx is None:
            impact_idx = sorted(pose_data.keys())[-1] if pose_data else None
    
    # Surgical asserts to guarantee true impact frame
    assert impact_idx is not None, "[SB] impact_idx None (missing world_landmarks pass-thru?)"
    n = len(pose_data["landmarks"]) if "landmarks" in pose_data else len(pose_data)
    assert impact_idx < n-1, f"[SB] Using last frame ({impact_idx}/{n-1}) — not true impact"
    dbg(f"[SB] impact_idx={impact_idx} of {n} frames (OK)")
    
    if impact_idx is None:
        dbg("[TORSO] No impact frame found")
        return {"metric": "torso_side_bend_deg", "value": None, "quality": "no-data"}

    num_frames = len(pose_data["landmarks"]) if "landmarks" in pose_data else len(pose_data)
    # TEMPORAL SMOOTHING: Use ±2-frame window around impact for better timing
    candidates = [impact_idx - 2, impact_idx - 1, impact_idx, impact_idx + 1, impact_idx + 2]
    candidates = [i for i in candidates if 0 <= i < num_frames and i in pose_data]

    # --- Improved de-roll using eyes + hips (not eyes-only) to reduce drift ---
    lo = max(0, impact_idx - 4)
    hi = min(num_frames - 1, impact_idx + 5)
    combined_rolls = []
    for i in range(lo, hi + 1):
        if i not in pose_data:
            continue
        L_EYE, R_EYE = 2, 5
        L_HIP, R_HIP = 23, 24
        lm = pose_data[i]
        if not lm or len(lm) <= max(R_EYE, R_HIP):
            continue
        
        # Collect both eye and hip roll angles
        frame_rolls = []
        
        # Eye roll
        le, re = lm[L_EYE], lm[R_EYE] 
        if le is not None and re is not None:
            (lx, ly), (rx, ry) = le[:2], re[:2]
            frame_rolls.append(_eye_roll_deg(lx, ly, rx, ry))
        
        # Hip roll
        lh, rh = lm[L_HIP], lm[R_HIP]
        if lh is not None and rh is not None:
            (lx, ly), (rx, ry) = lh[:2], rh[:2]
            frame_rolls.append(_eye_roll_deg(lx, ly, rx, ry))
        
        # Use median of available rolls for this frame
        if frame_rolls:
            combined_rolls.append(np.median(frame_rolls))

    roll_deg = 0.0
    if combined_rolls:
        arr = np.array(combined_rolls, dtype=float)
        lo_q, hi_q = np.percentile(arr, [15, 85])
        arr = arr[(arr >= lo_q) & (arr <= hi_q)]
        roll_deg = float(np.median(np.clip(arr, -25.0, 25.0)))
    dbg(f"[TORSO] eyes+hips roll (trimmed median) = {roll_deg:.1f}°")

    # --- Choose best candidate (prefer middle of temporal window) ---
    valid_candidates = []
    L_SHO, R_SHO, L_HIP, R_HIP = 11, 12, 23, 24
    
    for i in candidates:
        lm = pose_data[i]
        if not lm or len(lm) <= max(L_SHO, R_SHO, L_HIP, R_HIP):
            continue
        ls = lm[L_SHO]
        rs = lm[R_SHO]
        lh = lm[L_HIP]
        rh = lm[R_HIP]
        if None not in (ls, rs, lh, rh):
            valid_candidates.append(i)
    
    if not valid_candidates:
        dbg("[TORSO] No valid landmarks around impact")
        return {"metric": "torso_side_bend_deg", "value": None, "quality": "no-data"}
    
    # TEMPORAL MEDIAN: Calculate side-bend from all valid frames for stability
    def _get_HW():
        if frames:
            H, W = frames[0].shape[:2]; return float(H), float(W)
        if image_shape and len(image_shape) >= 2:
            H, W = image_shape[:2]; return float(H), float(W)
        return None, None
    H, W = _get_HW()
    cx, cy = (W / 2.0 if W else 0.0), (H / 2.0 if H else 0.0)
    rad = math.radians(-roll_deg)
    cr, sr = math.cos(rad), math.sin(rad)

    def _deroll(pt):
        x, y = pt[:2]  # Handle both [x,y] and [x,y,vis] formats
        x0, y0 = x - cx, y - cy
        xr = cr * x0 - sr * y0 + cx
        yr = sr * x0 + cr * y0 + cy
        return (xr, yr)

    # Calculate side-bend for all valid frames and take median
    L_SHO, R_SHO, L_HIP, R_HIP = 11, 12, 23, 24
    sidebend_values = []
    
    for frame_idx in valid_candidates:
        lm = pose_data[frame_idx]
        ls = _deroll(lm[L_SHO])
        rs = _deroll(lm[R_SHO])
        lh = _deroll(lm[L_HIP])
        rh = _deroll(lm[R_HIP])

        neck = _midpt(ls, rs)
        midhip = _midpt(lh, rh)
        dx = neck[0] - midhip[0]
        dy = neck[1] - midhip[1]  # y grows downward

        # 2D frontal-plane magnitude only
        dy = abs(dy) + 1e-6  # Ensure positive denominator
        sidebend_abs_frame = math.degrees(math.atan2(abs(dx), dy))
        sidebend_values.append((dx, sidebend_abs_frame))  # Store both dx and magnitude
    
    # Use median magnitude and median dx sign
    if sidebend_values:
        dx_values = [sv[0] for sv in sidebend_values]
        abs_values = [sv[1] for sv in sidebend_values]
        dx = np.median(dx_values)  # Median dx for sign
        sidebend_abs = np.median(abs_values)  # Median magnitude
        chosen = valid_candidates[len(valid_candidates)//2]  # For debugging reference
    else:
        return {"metric": "torso_side_bend_deg", "value": None, "quality": "no-data"}
    
    # Get forward direction for sign calibration
    fwd_hat_3d = None
    if world_landmarks is not None:
        setup_frames = swing_phases.get("setup", [])[:5] or sorted(world_landmarks.keys())[:5]
        for f in setup_frames:
            if f in world_landmarks:
                toe_vec_3d = _toe_line_unit_world(world_landmarks[f])
                if toe_vec_3d is not None:
                    # Forward perpendicular to toe line in XZ plane
                    fwd_hat_3d = np.array([-toe_vec_3d[1], 0, toe_vec_3d[0]])
                    break
    
    # Standardized sign convention: positive toward target for right-handed
    sign_2d_raw = 1.0 if dx > 0 else -1.0
    cal = calibrate_sidebend_sign(world_landmarks, impact_idx, handedness, fwd_hat_3d)
    
    # Apply standardized sign convention: positive = away from target
    # For right-handed: away from target = toward player's right
    # For left-handed: away from target = toward player's left
    final_sign = sign_2d_raw * cal
    sidebend_deg = final_sign * sidebend_abs
    
    # Guardrails: clip extreme values but warn
    if abs(sidebend_deg) > 30:
        dbg(f"[SB] rejecting |sb|={abs(sidebend_deg):.1f}° > 30°, check roll/coords")
        sidebend_deg = math.copysign(30.0, sidebend_deg)
    
    # Debug output matching expected format
    dbg(f"[SB] 2D mag={sidebend_abs:.1f}°, cal={cal:+.0f}, final={sidebend_deg:.1f}°")
    dbg(f"[TORSO] side-bend = {sidebend_deg:.1f}° (dx={dx:.1f}, dy={abs(dy):.1f}, abs={sidebend_abs:.1f}°)")

    # Optional: shoulder drop % of head height (kept because it's useful)
    head_h = pose_data.get("head_h", {}).get(chosen)
    drop_pct = None
    if head_h and head_h > 0:
        # lead vs trail by handedness; y increases downward in image coords
        lead_y = ls[1] if handedness == 'right' else rs[1]
        trail_y = rs[1] if handedness == 'right' else ls[1]
        dy_sho = (lead_y - trail_y)
        drop_pct = 100.0 * (dy_sho / head_h)
        dbg(f"[TORSO] lead-shoulder drop = {drop_pct:.1f}% head")

    # Trust but verify debug printout
    dbg(f"[SB] final torso_sidebend={sidebend_deg:.1f}° at frame={chosen}")
    
    return {
        "metric": "torso_side_bend_deg",
        "value": sidebend_deg,
        "quality": "ok",
        "details": {
            "frame": chosen,
            "roll_used_deg": roll_deg,
            "shoulder_drop_pct_head": drop_pct
        }
    }


def calculate_shoulder_tilt_at_impact(*args, **kwargs):
    """
    DEPRECATED: Shoulder tilt removed due to occlusion instability. 
    Returns torso side-bend instead for backward compatibility.
    """
    dbg("[DEPRECATION] Shoulder tilt removed. Returning torso side-bend instead.")
    res = calculate_torso_sidebend_at_impact(*args, **kwargs)
    # keep old key for downstream consumers if needed:
    return res["value"]  # Return just the numeric value for compatibility


def _find_impact_frame(pose_data, world_landmarks=None):
    """Find impact frame using peak hand speed if not provided in swing phases"""
    if not world_landmarks:
        return None
    
    frames = sorted(world_landmarks.keys())
    if len(frames) < 3:
        return None
    
    speeds = []
    for i in range(1, len(frames)):
        f_curr = frames[i]
        f_prev = frames[i-1]
        
        curr_frame = world_landmarks.get(f_curr)
        prev_frame = world_landmarks.get(f_prev)
        
        if (curr_frame and len(curr_frame) > 16 and 
            prev_frame and len(prev_frame) > 16 and
            curr_frame[16] and prev_frame[16]):  # Right wrist
            
            curr_wrist = curr_frame[16]
            prev_wrist = prev_frame[16]
            
            # Calculate 3D speed
            dx = curr_wrist[0] - prev_wrist[0]
            dy = curr_wrist[1] - prev_wrist[1] 
            dz = curr_wrist[2] - prev_wrist[2]
            speed = math.sqrt(dx*dx + dy*dy + dz*dz)
            
            speeds.append((f_curr, speed))
    
    if speeds:
        # Find peak speed frame
        peak_frame = max(speeds, key=lambda x: x[1])[0]
        # Impact is typically 1 frame before peak speed
        impact_frame = peak_frame - 1
        return impact_frame if impact_frame in frames else peak_frame
    
    return None





def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right'):
    """Build stable target axis from toe line at address with outlier trimming"""
    LEFT_TOE, RIGHT_TOE = 31, 32
    
    toe_vecs = []
    for f in setup_frames:
        if f in world_landmarks:
            frame = world_landmarks[f]
            if frame and len(frame) > 32:
                try:
                    Ltoe = frame[LEFT_TOE]
                    Rtoe = frame[RIGHT_TOE]
                    if Ltoe and Rtoe and len(Ltoe) >= 3 and len(Rtoe) >= 3:
                        toe_vec = (Rtoe[0]-Ltoe[0], Rtoe[2]-Ltoe[2])
                        if (toe_vec[0]**2 + toe_vec[1]**2) > 1e-6:
                            toe_vecs.append(toe_vec)
                except Exception:
                    continue
    
    if not toe_vecs:
        dbg("target_axis - NO VALID TOE VECTORS, using default")
        return (1.0, 0.0)
    
    # Drop 20% widest-angle outliers before median
    toe_vecs = np.array(toe_vecs, dtype=float)
    angles = np.degrees(np.arctan2(toe_vecs[:,1], toe_vecs[:,0]))
    med, dev = np.median(angles), np.abs(angles - np.median(angles))
    keep = dev <= np.percentile(dev, 80)
    target_hat = unit2(np.median(toe_vecs[keep], axis=0))
    
    # Optional: flip sign for consistency if you want +X toward target for left-handed
    if handedness == 'left':
        target_hat = (-target_hat[0], -target_hat[1])
    
    return target_hat



def _pick_stable_setup_frames(sm, swing_phases, max_frames=6):
    """Pick first address frames with low pelvis motion (reduces waggle bias)."""
    cand = swing_phases.get("setup", [])[:12]
    if not cand:
        return []
    def pelvis_ctr_px(f):
        lm = sm.get(f)
        if not lm or not lm[23] or not lm[24]: return None
        return np.array([(lm[23][0]+lm[24][0])*0.5, (lm[23][1]+lm[24][1])*0.5], float)
    centers = [(f, pelvis_ctr_px(f)) for f in cand]
    centers = [(f, c) for f,c in centers if c is not None]
    if len(centers) < 2: return [f for f,_ in centers][:max_frames]
    # speed threshold: keep earliest frames whose step-to-step pelvis motion is small
    speeds = []
    for i in range(1, len(centers)):
        f0,c0 = centers[i-1]; f1,c1 = centers[i]
        speeds.append((f1, float(np.linalg.norm(c1-c0))))
    if not speeds: return [centers[0][0]]
    # pick prefix until motion inflates (waggle begins)
    thr = max(1.5, np.median([s for _,s in speeds])*2.5)
    keep = [centers[0][0]]
    for f,s in speeds:
        if s <= thr and len(keep) < max_frames:
            keep.append(f)
        else:
            break
    return keep


def _build_foot_only_homography(sm, setup_frames):
    """
    Build ground-plane H from 4 foot points only (no hips).
    MediaPipe indices: 27=L_heel, 28=R_heel, 29=L_ankle, 30=R_ankle, 31=L_foot_index, 32=R_foot_index
    Image pts use (31,32) for toes or (29,30) for ankles as fallback.
    Ground pts are a rectangle in arbitrary inches; scale will be calibrated later.
    """
    import cv2
    for f in setup_frames:
        lm = sm.get(f)
        if not lm: continue
        needed = [29,30,31,32]
        if any((i>=len(lm) or lm[i] is None or lm[i][2] < 0.5) for i in needed):
            continue
        # Image points - use consistent ankle/toe mapping
        img = np.array([
            [lm[29][0], lm[29][1]],  # L_ankle  -> treat as heel
            [lm[30][0], lm[30][1]],  # R_ankle  -> heel
            [lm[31][0], lm[31][1]],  # L_foot_index -> toe
            [lm[32][0], lm[32][1]],  # R_foot_index -> toe
        ], dtype=np.float32)
        
        # Ground rectangle (left/right must match the image ordering above)
        ground = np.array([
            [-8.0,   0.0],  # L heel
            [ 8.0,   0.0],  # R heel
            [-8.0,  11.0],  # L toe
            [ 8.0,  11.0],  # R toe
        ], dtype=np.float32)
        H, mask = cv2.findHomography(img, ground, cv2.RANSAC, 2.0)
        if H is None: 
            continue
        det = abs(np.linalg.det(H[:2,:2]))
        if det > 1e-5:
            return H, True, f
    return None, False, None

def _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases=None, world_landmarks=None):
    """Improved pixel-based sway fallback with world landmark scale calibration"""
    def get_lm(frame_idx):
        return sm.get(frame_idx)

    # pick a setup frame that has feet + hips
    setup_ok = [f for f in setup_frames
                if get_lm(f) and get_lm(f)[23] and get_lm(f)[24] and get_lm(f)[31] and get_lm(f)[32]]
    if not setup_ok:
        return 0.0

    f0 = setup_ok[0]
    lm0 = get_lm(f0)

    # pelvis centers (pixels)
    def pelvis_ctr_px(f):
        lm = get_lm(f)
        if not lm or not lm[23] or not lm[24]:
            return None
        return np.array([(lm[23][0] + lm[24][0]) * 0.5,
                         (lm[23][1] + lm[24][1]) * 0.5], dtype=float)

    setup_ctrs = [pelvis_ctr_px(f) for f in setup_ok]
    setup_ctrs = [c for c in setup_ctrs if c is not None]
    if not setup_ctrs:
        return 0.0
    setup_ctr = np.median(np.stack(setup_ctrs), axis=0)

    top_ctr = pelvis_ctr_px(top_frame)
    if top_ctr is None:
        return 0.0

    # toe vector in pixels from the same setup frame
    toe_v = np.array([lm0[32][0] - lm0[31][0],
                      lm0[32][1] - lm0[31][1]], dtype=float)
    n = np.linalg.norm(toe_v)
    if n < 1e-6:
        # fallback to horizontal approximation if toes are missing
        toe_hat_px = np.array([1.0, 0.0])
    else:
        toe_hat_px = toe_v / n

    # Target axis (toe line itself) in pixel space
    target_hat_px = toe_hat_px / (np.linalg.norm(toe_hat_px) + 1e-9)

    # --- NEW: sign disambiguation using address -> impact pelvis centers in pixel space ---
    if swing_phases and swing_phases.get("impact"):
        f_imp = swing_phases["impact"][0]
        # median address pelvis center in px
        addr_ctrs = [pelvis_ctr_px(f) for f in setup_ok]
        addr_ctrs = [c for c in addr_ctrs if c is not None]
        imp_ctr = pelvis_ctr_px(f_imp)
        if addr_ctrs and imp_ctr is not None:
            addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
            fwd_vec_px = imp_ctr - addr_ctr
            if float(np.dot(fwd_vec_px, target_hat_px)) < 0.0:
                dbg("[SWAY] Flipped target_hat_px based on addr→impact motion")
                target_hat_px = -target_hat_px

    delta_px = top_ctr - setup_ctr
    sway_px = float(np.dot(delta_px, target_hat_px))

    # Improved px→inch scale using world landmarks with consistency check
    px_per_inch_candidates = []
    default_px_per_inch = 10.0
    
    if world_landmarks is not None:
        # Try to get px_per_inch from multiple setup frames
        for f in setup_ok:
            if f in world_landmarks:
                try:
                    wf = world_landmarks[f]
                    lm = get_lm(f)
                    L_FOOT_INDEX, R_FOOT_INDEX = 31, 32  # foot_index points (toe tips)
                    Lt, Rt = wf[L_FOOT_INDEX], wf[R_FOOT_INDEX]
                    if Lt and Rt and lm and lm[31] and lm[32]:
                        # 3D toe spacing in meters → inches
                        d_m = math.hypot(Rt[0]-Lt[0], Rt[2]-Lt[2])
                        d_in_true = d_m * 39.3701
                        # Pixel toe spacing for this frame
                        toe_v_frame = np.array([lm[32][0] - lm[31][0], lm[32][1] - lm[31][1]], dtype=float)
                        d_px = float(np.linalg.norm(toe_v_frame))
                        if d_px > 1e-3 and d_in_true > 1e-3:
                            candidate = d_px / d_in_true
                            # Sanity check: reasonable range for px_per_inch (5-25 depending on resolution/crop)
                            if 5.0 <= candidate <= 25.0:
                                px_per_inch_candidates.append(candidate)
                                dbg(f"pixel_fallback: frame {f} px_per_inch={candidate:.2f}")
                except Exception:
                    continue
    
    # Apply consistency check: reject frames with >10% deviation from median
    if len(px_per_inch_candidates) > 1:
        initial_median = np.median(px_per_inch_candidates)
        consistent_candidates = []
        for val in px_per_inch_candidates:
            deviation_pct = abs(val - initial_median) / initial_median * 100
            if deviation_pct <= 10.0:  # Within 10% of median
                consistent_candidates.append(val)
            else:
                dbg(f"pixel_fallback: rejecting inconsistent px_per_inch={val:.2f} (deviation={deviation_pct:.1f}%)")
        px_per_inch_candidates = consistent_candidates
    
    # Use median of candidates if available, otherwise use default
    if px_per_inch_candidates:
        px_per_inch = float(np.median(px_per_inch_candidates))
        dbg(f"pixel_fallback: median px_per_inch={px_per_inch:.2f} from {len(px_per_inch_candidates)} consistent frames")
    else:
        px_per_inch = default_px_per_inch
        dbg(f"pixel_fallback: using default px_per_inch={px_per_inch:.2f}")
    
    sway_inches = sway_px / px_per_inch
    
    # Apply same robust baseline approach to pixel fallback
    if len(setup_ctrs) >= 2:
        setup_x_px = [c[0] for c in setup_ctrs]
        t = np.arange(len(setup_x_px))
        m, b = np.polyfit(t, setup_x_px, 1) if len(setup_x_px) >= 2 else (0, setup_x_px[0])
        residuals = np.array(setup_x_px) - (m * t + b)
        mad_residuals = np.median(np.abs(residuals - np.median(residuals)))
        jitter_px = 1.4826 * mad_residuals
        jitter_in = jitter_px / px_per_inch
    else:
        jitter_in = 0.1  # Minimal fallback
    
    # Calculate trust ratio
    sway_jitter_ratio = abs(sway_inches) / max(jitter_in, 1e-6)
    trust_flag = "trusted" if sway_jitter_ratio >= 1.5 else "low-trust"
    
    # Normalize by stance width if using pixel fallback and world landmarks available
    normalized_sway = None
    stance_width_in = None
    if world_landmarks:
        stance_widths = []
        for f in setup_ok:
            if f in world_landmarks:
                try:
                    Lt_w, Rt_w = world_landmarks[f][31], world_landmarks[f][32]
                    toe_world_m = np.linalg.norm(np.array(Lt_w[:3]) - np.array(Rt_w[:3]))
                    stance_widths.append(float(toe_world_m * 39.3701))  # Convert to inches
                except:
                    pass
        
        if stance_widths:
            stance_width_in = np.median(stance_widths)
            if 14 <= stance_width_in <= 28:
                normalized_sway = sway_inches / stance_width_in
    
    # Enhanced pixel fallback logging matching main function
    norm_str = f"{normalized_sway:.3f}" if normalized_sway is not None else "NA"
    dbg(f"pixel_fallback: dx={sway_inches:.2f}in, norm={norm_str}, jitter={jitter_in:.2f}in, ratio={sway_jitter_ratio:.1f}, trust={trust_flag}")
    if stance_width_in:
        dbg(f"pixel_fallback: stance_width={stance_width_in:.1f}\", target_normalized≈0.16 for pros")
    
    return sway_inches


def _subframe_impact_refinement(world_landmarks: Dict[int, List], impact_candidate: int) -> float:
    """
    Refine impact timing to sub-frame precision using parabolic fit to hand speed
    Fits parabola around the impact candidate and returns fractional frame timestamp
    """
    try:
        # Get frames around impact candidate for parabolic fit
        test_frames = [f for f in range(impact_candidate-2, impact_candidate+3) 
                      if f in world_landmarks]
        
        if len(test_frames) < 3:
            return float(impact_candidate)  # Not enough data for refinement
        
        # Calculate hand speeds around impact
        speeds = []
        frame_indices = []
        
        for i in range(1, len(test_frames)):
            f_curr = test_frames[i]
            f_prev = test_frames[i-1]
            
            curr_frame = world_landmarks[f_curr]
            prev_frame = world_landmarks[f_prev]
            
            if (curr_frame and len(curr_frame) > 16 and 
                prev_frame and len(prev_frame) > 16 and
                curr_frame[16] and prev_frame[16]):
                
                curr_wrist = curr_frame[16]
                prev_wrist = prev_frame[16]
                
                # Calculate 3D speed
                dx = curr_wrist[0] - prev_wrist[0]
                dy = curr_wrist[1] - prev_wrist[1] 
                dz = curr_wrist[2] - prev_wrist[2]
                speed = math.sqrt(dx*dx + dy*dy + dz*dz)
                
                speeds.append(speed)
                frame_indices.append(f_curr)
        
        if len(speeds) < 3:
            return float(impact_candidate)
        
        # Fit parabola to speed curve: speed = a*t^2 + b*t + c
        # Find peak (maximum) of parabola
        t = np.array(frame_indices, dtype=float)
        s = np.array(speeds)
        
        # Polynomial fit (degree 2)
        coeffs = np.polyfit(t, s, 2)
        a, b, c = coeffs
        
        if a >= 0:  # Parabola opens upward → no maximum
            return float(impact_candidate)
        
        # Maximum at t = -b/(2a)
        peak_frame_fractional = -b / (2*a)
        
        # Impact happens just before peak (typically 0.5-1.0 frames before)
        # Use the original logic of peak-1, but with fractional precision
        impact_fractional = peak_frame_fractional - 0.7  # Refined offset
        
        # Constrain to reasonable range around original estimate
        if abs(impact_fractional - impact_candidate) > 2.0:
            impact_fractional = float(impact_candidate)
        
        dbg(f"subframe_refinement - parabola_coeffs=[{a:.6f}, {b:.6f}, {c:.6f}]")
        dbg(f"subframe_refinement - peak_frame={peak_frame_fractional:.2f}, impact_refined={impact_fractional:.2f}")
        
        return impact_fractional
        
    except Exception as e:
        dbg(f"subframe_refinement - failed: {e}, using integer frame")
        return float(impact_candidate)


def _address_frames(swing_phases: Dict[str, List]) -> List[int]:
    """Get address/setup frames"""
    return swing_phases.get("setup", [])[:10]

def _impact_frame(world_landmarks: Dict[int, List], swing_phases: Dict[str, List]) -> Optional[int]:
    """Find impact using peak lead-hand speed - 1 frame with inline refinement"""
    downswing_frames = swing_phases.get("downswing", [])
    impact_frames = swing_phases.get("impact", [])
    
    analysis_frames = sorted(downswing_frames + impact_frames)
    if len(analysis_frames) < 5:
        return impact_frames[0] if impact_frames else None
    
    # Calculate hand speeds
    hand_speeds = []
    valid_frames = []
    
    for i in range(1, len(analysis_frames)):
        f_curr = analysis_frames[i]
        f_prev = analysis_frames[i-1]
        
        if f_curr in world_landmarks and f_prev in world_landmarks:
            curr_frame = world_landmarks[f_curr]
            prev_frame = world_landmarks[f_prev]
            
            if (curr_frame and len(curr_frame) > 16 and 
                prev_frame and len(prev_frame) > 16):
                
                # Use lead hand (left wrist for right-handed golfer)
                wrist_idx = 15  # Lead wrist for right-handed
                curr_wrist = curr_frame[wrist_idx]
                prev_wrist = prev_frame[wrist_idx]
                
                if (curr_wrist and len(curr_wrist) >= 3 and 
                    prev_wrist and len(prev_wrist) >= 3):
                    
                    dx = curr_wrist[0] - prev_wrist[0]
                    dy = curr_wrist[1] - prev_wrist[1]
                    dz = curr_wrist[2] - prev_wrist[2]
                    speed = math.sqrt(dx*dx + dy*dy + dz*dz)
                    
                    hand_speeds.append(speed)
                    valid_frames.append(f_curr)
    
    if len(hand_speeds) >= 3:
        # Find peak speed frame
        max_speed_idx = np.argmax(hand_speeds)
        peak_speed_frame = valid_frames[max_speed_idx]
        
        # Initial estimate: peak speed - 1 frame 
        impact_candidate = peak_speed_frame
        if max_speed_idx > 0:
            impact_candidate = valid_frames[max_speed_idx - 1]
        
        
        # Clamp to ±2 frames around original estimate
        original = valid_frames[max_speed_idx - 1] if max_speed_idx > 0 else peak_speed_frame
        impact_candidate = max(original - 2, min(original + 2, impact_candidate))
        
        return impact_candidate
    
    return impact_frames[0] if impact_frames else None


# Removed complex 2D fallback function - using simple direct measurement only

# ================== HIP-SHOULDER SEPARATION METRIC ==================

def _angle_deg(p_left: np.ndarray, p_right: np.ndarray) -> float:
    """
    Angle of the line Left->Right relative to screen-horizontal, in degrees.
    Image coords: x right+, y down+.
    """
    dx = float(p_right[0] - p_left[0])
    dy = float(p_right[1] - p_left[1])
    return np.degrees(np.arctan2(dy, dx))

def _wrap_deg(a: float) -> float:
    """Wrap to (-180, 180]."""
    a = (a + 180.0) % 360.0 - 180.0
    return a

def _span(p_left: np.ndarray, p_right: np.ndarray) -> float:
    """Euclidean span in pixels."""
    return float(np.hypot(p_right[0] - p_left[0], p_right[1] - p_left[1]))

def _trimmed_median(values: List[float], trim: float = 0.2) -> float:
    """Robust center estimate."""
    if not values:
        return 0.0
    arr = np.sort(np.asarray(values, dtype=float))
    n = len(arr)
    k = int(np.floor(trim * n))
    arr = arr[k: n - k] if n - 2*k > 0 else arr
    return float(np.median(arr))

def _circular_mean_deg(values: List[float]) -> float:
    """Circular mean for angle values to avoid wrap artifacts."""
    if not values:
        return 0.0
    a = np.radians(values)
    return float(np.degrees(np.arctan2(np.mean(np.sin(a)), np.mean(np.cos(a)))))

def estimate_global_roll_deg(
    lm_seq: np.ndarray,
    setup_idxs: List[int],
    use_eyes: bool = True
) -> float:
    """
    Estimate camera roll from early setup frames using eyes + shoulders only.
    Uses circular mean to avoid wrap artifacts. Excludes hips to prevent bias from pelvis rotation.
    Returns a single roll angle in degrees (to subtract from later line angles).
    """
    eye_angles, sho_angles = [], []
    for t in setup_idxs:
        if t >= lm_seq.shape[0]:
            continue
        lm = lm_seq[t]
        try:
            if use_eyes and len(lm) > max(L_EYE, R_EYE):
                eye_angles.append(_angle_deg(lm[L_EYE], lm[R_EYE]))
            if len(lm) > max(L_SHO, R_SHO):
                sho_angles.append(_angle_deg(lm[L_SHO], lm[R_SHO]))
        except Exception:
            continue

    # Combine all angles for circular mean (exclude hips to avoid pelvis bias)
    all_angles = []
    if eye_angles: all_angles.extend(eye_angles)
    if sho_angles: all_angles.extend(sho_angles)
    
    if not all_angles:
        return 0.0

    # Use circular mean to avoid wrap artifacts like 171° instead of -18°
    return _circular_mean_deg(all_angles)

def hip_shoulder_separation_deg(
    lm_seq: np.ndarray,
    idx: int,
    setup_idxs: List[int],
    handed: str = "R",
    min_span_px: float = 20.0,
    smooth_half_window: int = 2,  # 5-frame median around idx
    apply_yaw_correction: bool = True
) -> Dict[str, float]:
    """
    Compute signed hip-shoulder separation at a given frame.
    Positive (right-handed) => hips more open than shoulders (hips leading).
    For left-handed, sign is flipped so positive still means hips leading.
    
    New features:
    - Temporal smoothing: Uses median over ±smooth_half_window frames around idx
    - Yaw compensation: Corrects for camera angle by comparing shoulder spans
    - Geometry confidence: Flags when foreshortening may affect accuracy
    
    Returns: dict with:
        'hip_shoulder_sep_deg': Corrected separation angle
        'hip_shoulder_sep_abs_deg': Absolute value of separation
        'confidence': 0-1 quality of landmark detection
        'span_ratio': Shoulder span at idx vs address (for foreshortening check)
        'geometry_ok': True if camera appears square to player
    """
    T = lm_seq.shape[0]
    # Neighborhood for temporal median smoothing
    lo = max(0, idx - smooth_half_window)
    hi = min(T - 1, idx + smooth_half_window)
    window = range(lo, hi + 1)

    # Estimate roll from setup
    roll_deg = estimate_global_roll_deg(lm_seq, setup_idxs, use_eyes=True)

    hip_angles, sho_angles, confs = [], [], []
    for t in window:
        if t >= T:
            continue
        lm = lm_seq[t]
        if len(lm) <= max(L_SHO, R_SHO, L_HIP, R_HIP):
            continue
        
        Ls, Rs = lm[L_SHO], lm[R_SHO]
        Lh, Rh = lm[L_HIP], lm[R_HIP]

        # Confidence via spans
        span_sho = _span(Ls, Rs)
        span_hip = _span(Lh, Rh)
        good = float((span_sho >= min_span_px) and (span_hip >= min_span_px))
        confs.append(good)

        # Raw angles
        a_sho = _angle_deg(Ls, Rs) - roll_deg
        a_hip = _angle_deg(Lh, Rh) - roll_deg

        # Wrap both post de-roll
        a_sho = _wrap_deg(a_sho)
        a_hip = _wrap_deg(a_hip)

        sho_angles.append(a_sho)
        hip_angles.append(a_hip)

    if not sho_angles or not hip_angles:
        return dict(hip_shoulder_sep_deg=0.0, hip_shoulder_sep_abs_deg=0.0, confidence=0.0)

    # Temporal median to reduce jitter
    a_sho_med = float(np.median(sho_angles))
    a_hip_med = float(np.median(hip_angles))
    conf = float(np.mean(confs))  # 0..1 proportion of frames with decent span

    # Separation: hips minus shoulders
    sep = _wrap_deg(a_hip_med - a_sho_med)

    # DEBUG PRINT: Add debugging info for the angles and separation
    print(f"[DBG] roll={roll_deg:.1f}°, a_hip_med={a_hip_med:.1f}°, a_sho_med={a_sho_med:.1f}°, sep={sep:.1f}°")

    # FORESHORTENING CHECK: Compare shoulder span from address to impact
    span_addr = None
    span_ratio = 1.0
    geom_ok = True
    severe_foreshortening = False
    
    if apply_yaw_correction and setup_idxs:
        try:
            # Calculate median shoulder and hip spans during address
            addr_sho_spans, addr_hip_spans = [], []
            for t in setup_idxs:
                if t < lm_seq.shape[0]:
                    lm = lm_seq[t]
                    if len(lm) > max(L_SHO, R_SHO):
                        addr_sho_spans.append(_span(lm[L_SHO], lm[R_SHO]))
                    if len(lm) > max(L_HIP, R_HIP):
                        addr_hip_spans.append(_span(lm[L_HIP], lm[R_HIP]))
            
            if addr_sho_spans:
                span_addr_sho = np.median(addr_sho_spans)
                span_addr_hip = np.median(addr_hip_spans) if addr_hip_spans else span_addr_sho
                
                # Current frame spans
                lm_current = lm_seq[idx] if idx < lm_seq.shape[0] else None
                if lm_current is not None and len(lm_current) > max(L_SHO, R_SHO):
                    span_imp_sho = _span(lm_current[L_SHO], lm_current[R_SHO])
                    span_imp_hip = _span(lm_current[L_HIP], lm_current[R_HIP]) if len(lm_current) > max(L_HIP, R_HIP) else span_imp_sho
                    
                    # Calculate ratios and use the better (less compressed) one
                    ratio_sho = span_imp_sho / (span_addr_sho + 1e-6)
                    ratio_hip = span_imp_hip / (span_addr_hip + 1e-6)
                    span_ratio = max(ratio_sho, ratio_hip)  # Use less-compressed ratio
                    
                    print(f"[DBG] shoulder_span addr={span_addr_sho:.1f}px, impact={span_imp_sho:.1f}px, ratio_sho={ratio_sho:.2f}")
                    print(f"[DBG] hip_span addr={span_addr_hip:.1f}px, impact={span_imp_hip:.1f}px, ratio_hip={ratio_hip:.2f}")
                    print(f"[DBG] using_ratio={span_ratio:.2f} (better of sho/hip)")
                    
                    # Apply clamping to realistic range (0.7-1.0) to avoid underestimation
                    ratio_clamped = min(1.0, max(0.7, span_ratio))
                    sep_raw = sep
                    sep = sep / ratio_clamped
                    
                    print(f"[DBG] sep_raw={sep_raw:.1f}°, sep_corr={sep:.1f}° (ratio_clamped={ratio_clamped:.2f})")
                    
                    # Geometry confidence flag - flag low-confidence results when span ratio <0.5
                    geom_ok = span_ratio >= 0.7  # reasonable geometry
                    severe_foreshortening = span_ratio < 0.5  # flag low-confidence results
                    
        except Exception as e:
            print(f"[DBG] Foreshortening calculation failed: {e}")

    # Handedness normalization: keep "positive = hips leading" for both
    if handed.upper().startswith("L"):
        sep *= -1.0

    # Adjust confidence based on foreshortening severity
    final_conf = conf
    if severe_foreshortening:
        final_conf *= 0.2  # Very low confidence for severe foreshortening (span ratio <0.5)
    elif not geom_ok:
        final_conf *= 0.6  # Reduced confidence for geometry issues (span ratio <0.7)
    
    return dict(
        hip_shoulder_sep_deg=float(sep),
        hip_shoulder_sep_abs_deg=float(abs(sep)),
        confidence=float(final_conf),
        span_ratio=float(span_ratio),
        geometry_ok=bool(geom_ok),
        severe_foreshortening=bool(severe_foreshortening)
    )

def metric_at_address_top_impact(
    lm_seq: np.ndarray,
    address_idxs: List[int],
    top_idx: Optional[int],
    impact_idx: int,
    handed: str = "R"
) -> Dict[str, Dict[str, float]]:
    """
    Compute the metric at address (median over address_idxs), top (optional), and impact.
    address_idxs: e.g., first 8–12 frames while static at setup.
    """
    out = {}

    # Address: use address window both for setup roll and target idx smoothing
    addr_idx = int(np.median(address_idxs))
    out["address"] = hip_shoulder_separation_deg(
        lm_seq, addr_idx, setup_idxs=address_idxs, handed=handed
    )

    if top_idx is not None:
        out["top"] = hip_shoulder_separation_deg(
            lm_seq, top_idx, setup_idxs=address_idxs, handed=handed
        )

    out["impact"] = hip_shoulder_separation_deg(
        lm_seq, impact_idx, setup_idxs=address_idxs, handed=handed
    )
    return out

def calculate_hip_shoulder_separation_at_impact(
    pose_data: Dict[int, List], 
    swing_phases: Dict[str, List], 
    handedness: str = 'right'
) -> Dict[str, float]:
    """
    Calculate hip-shoulder separation at impact using the new metric.
    
    Args:
        pose_data: Dictionary mapping frame indices to pose keypoints
        swing_phases: Dictionary mapping phase names to lists of frame indices
        handedness: 'right' or 'left' handed player
        
    Returns:
        Dict with hip_shoulder_sep_deg, hip_shoulder_sep_abs_deg, confidence
    """
    # Convert pose_data to numpy array format expected by the metric
    if not pose_data:
        return {"hip_shoulder_sep_deg": None, "hip_shoulder_sep_abs_deg": None, "confidence": 0.0}
    
    # Get frame indices
    frame_indices = sorted(pose_data.keys())
    max_frame_idx = max(frame_indices)
    
    # Create numpy array with shape [T, N, 2] where N >= 33 (Mediapipe format)
    lm_seq = np.zeros((max_frame_idx + 1, 33, 2), dtype=float)
    
    # Fill the array with pose data
    for frame_idx, landmarks in pose_data.items():
        if landmarks and len(landmarks) >= 25:  # Need at least hip landmarks
            for i, lm in enumerate(landmarks):
                if i < 33 and lm and len(lm) >= 2:
                    lm_seq[frame_idx, i, 0] = lm[0]  # x
                    lm_seq[frame_idx, i, 1] = lm[1]  # y
    
    # Get swing phase indices
    setup_frames = swing_phases.get("setup", [])
    impact_frames = swing_phases.get("impact", [])
    
    if not setup_frames or not impact_frames:
        dbg("[HIP-SHO] Missing setup or impact frames")
        return {"hip_shoulder_sep_deg": None, "hip_shoulder_sep_abs_deg": None, "confidence": 0.0}
    
    # Use first 8-10 setup frames for address
    address_idxs = setup_frames[:min(10, len(setup_frames))]
    impact_idx = impact_frames[0]
    
    # Convert handedness format
    handed = "R" if handedness.lower().startswith('r') else "L"
    
    try:
        # Calculate the metric at impact (5-frame median for temporal smoothing)
        result = hip_shoulder_separation_deg(
            lm_seq=lm_seq,
            idx=impact_idx,
            setup_idxs=address_idxs,
            handed=handed,
            smooth_half_window=2  # 5-frame median around impact (±2 frames)
        )
        
        # Enhanced debug output with geometry information
        geom_warning = ""
        if result.get('severe_foreshortening', False):
            geom_warning = " (severely underest. - severe foreshortening)"
        elif not result.get('geometry_ok', True):
            geom_warning = " (underest. likely; yaw/foreshortening)"
        
        dbg(f"[HIP-SHO] Impact separation: {result['hip_shoulder_sep_deg']:.1f}°, confidence: {result['confidence']:.2f}, span_ratio: {result.get('span_ratio', 1.0):.2f}{geom_warning}")
        return result
        
    except Exception as e:
        dbg(f"[HIP-SHO] Calculation failed: {e}")
        return {"hip_shoulder_sep_deg": None, "hip_shoulder_sep_abs_deg": None, "confidence": 0.0}

# ================== END HIP-SHOULDER SEPARATION METRIC ==================

def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List], 
                              world_landmarks: Optional[Dict[int, List]] = None) -> Union[float, None]:
    # Apply additional temporal smoothing (Savitzky-Golay or EWMA) before measurement
    sm = smooth_landmarks(pose_data, alpha=0.25)  # Stronger smoothing for hip sway
    setup_frames = _pick_stable_setup_frames(sm, swing_phases, max_frames=6)
    if not setup_frames: return None
    backswing_frames = swing_phases.get("backswing", [])
    if not backswing_frames: return None
    top_frame = _pick_top_by_wrist_height(sm, backswing_frames) or backswing_frames[-1]

    H, ok, H_frame = _build_foot_only_homography(sm, setup_frames)
    if not ok or H is None:
        # simple pixel fallback with world toes scale if present
        return _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases, world_landmarks)

    import cv2
    def warp2(pts):
        A = np.array(pts, dtype=np.float32).reshape(1, -1, 2)
        return cv2.perspectiveTransform(A, H)[0]

    # forward axis = perp to toe line in ground plane
    toe_vecs = []
    for f in setup_frames:
        lm = sm.get(f)
        if not lm or not lm[31] or not lm[32]: continue
        g = warp2([[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]])
        v = g[1] - g[0]
        n = np.linalg.norm(v)
        if n > 1e-6: toe_vecs.append(v/n)
    if not toe_vecs: return None
    toe_hat = np.median(np.stack(toe_vecs), axis=0); toe_hat /= (np.linalg.norm(toe_hat)+1e-9)
    fwd_hat = np.array([-toe_hat[1], toe_hat[0]])
    fwd_hat /= (np.linalg.norm(fwd_hat)+1e-9)

    # sign disambiguation with addr→impact pelvis motion (optional but stable)
    imp = swing_phases.get("impact", [])
    cand = imp[0] if imp else backswing_frames[-1]
    def pelvis_ctr_g(f):
        lm = sm.get(f); 
        if not lm or not lm[23] or not lm[24]: return None
        g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]]); 
        return (g[0]+g[1])/2
    addr_ctrs = [pelvis_ctr_g(f) for f in setup_frames]; addr_ctrs = [c for c in addr_ctrs if c is not None]
    imp_ctr = pelvis_ctr_g(cand)
    if addr_ctrs and imp_ctr is not None:
        addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
        if float(np.dot(imp_ctr-addr_ctr, fwd_hat)) < 0: 
            dbg("[SWAY] Flipped fwd_hat based on addr→impact pelvis motion")
            fwd_hat = -fwd_hat

    # Establish robust address baseline to stop drift from inflating jitter
    setup_ctr = np.median(np.stack(addr_ctrs), axis=0)
    
    # Robust baseline with detrending: use tight window around address
    addr_ctrs_array = np.stack(addr_ctrs)
    addr_x_positions = addr_ctrs_array[:, 0]  # X coordinates only
    
    # Light detrend to remove slow drift
    t = np.arange(len(addr_x_positions))
    if len(addr_x_positions) >= 2:
        m, b = np.polyfit(t, addr_x_positions, 1)  # Linear fit
        residuals = addr_x_positions - (m * t + b)
        # Robust jitter using MAD (Median Absolute Deviation)
        mad_residuals = np.median(np.abs(residuals - np.median(residuals)))
        setup_jitter_px = 1.4826 * mad_residuals  # MAD to std conversion
        addr_baseline_px = b  # Robust address baseline
    else:
        setup_jitter_px = 0.0
        addr_baseline_px = addr_x_positions[0] if len(addr_x_positions) > 0 else setup_ctr[0]
    
    dbg(f"[SWAY] Robust baseline: jitter_px={setup_jitter_px:.2f} (was std-based), addr_baseline={addr_baseline_px:.1f}")
    setup_jitter = setup_jitter_px  # Use robust jitter
    
    top_ctr = pelvis_ctr_g(top_frame)
    if top_ctr is None: return None
    
    # Apply jitter-aware correction - reduce small movements that may be noise
    raw_delta = top_ctr - setup_ctr
    if np.linalg.norm(raw_delta) < setup_jitter * 1.5:
        dbg(f"[SWAY] Movement ({np.linalg.norm(raw_delta):.2f}) < 1.5x setup jitter ({setup_jitter:.2f}), applying correction")
        # Partial correction for movements smaller than jitter threshold
        correction_factor = max(0.0, (np.linalg.norm(raw_delta) - setup_jitter) / np.linalg.norm(raw_delta)) if np.linalg.norm(raw_delta) > 0 else 0.0
        corrected_delta = raw_delta * correction_factor
        top_ctr = setup_ctr + corrected_delta
    
    # Validate vertical drift but DO NOT repick top to minimize it
    TOP_DRIFT_GU_WARN = 3.0   # warn if pelvis vertical drift > 3 gu, but DO NOT change top
    TOP_DRIFT_GU_REJECT = 6.0 # only reject if absurd
    
    vertical_drift = abs(top_ctr[1] - setup_ctr[1])  # Ground-plane Y difference
    if vertical_drift > TOP_DRIFT_GU_REJECT:
        dbg(f"[SWAY] vertical drift {vertical_drift:.1f} gu -> unreliable sway")
        return None
    elif vertical_drift > TOP_DRIFT_GU_WARN:
        dbg(f"[SWAY] vertical drift {vertical_drift:.1f} gu (warn)")
    
    # DO NOT repick top based on vertical drift - top must be picked by club kinematics

    # Ground-plane calibration: gu_per_inch stabilization with consistency check
    gu_per_inch_list = []
    use_pixel_fallback = False
    
    for k in setup_frames:
        if world_landmarks and k in world_landmarks:
            try:
                # 1) World toe distance (meters -> inches)
                Lt_w, Rt_w = world_landmarks[k][31], world_landmarks[k][32]
                toe_world_m = np.linalg.norm(np.array(Lt_w[:3]) - np.array(Rt_w[:3]))
                toe_world_in = float(toe_world_m * 39.3701)
                
                # 2) Ground-plane toe distance (same frame, via homography)
                lmK = sm.get(k)
                if lmK and lmK[31] and lmK[32]:
                    toe_g = warp2([
                        [lmK[31][0], lmK[31][1]],
                        [lmK[32][0], lmK[32][1]],
                    ])  # shape (2,2)
                    toe_g_dist = float(np.linalg.norm(toe_g[0] - toe_g[1]))  # ground units
                    
                    if toe_g_dist > 1e-3 and toe_world_in > 1e-3:
                        gu_per_in = toe_g_dist / max(toe_world_in, 1e-6)  # ground-units per inch
                        
                        # Homography plausibility check: stance width should be ~8–30 inches
                        if 8.0 <= toe_world_in <= 30.0 and 0.1 <= gu_per_in <= 10.0:
                            gu_per_inch_list.append(gu_per_in)
                            dbg(f"[SWAY] frame {k}: toe_g={toe_g_dist:.2f} gu, toe_world={toe_world_in:.2f} in, gu_per_in={gu_per_in:.3f}")
                        else:
                            dbg(f"[SWAY] implausible frame {k}: toe_world_in={toe_world_in:.2f}, gu_per_in={gu_per_in:.3f}")
            except Exception as e:
                dbg(f"[SWAY] calibration failed for frame {k}: {e}")
    
    # Reject frames with inconsistent calibration (>10% deviation from median)
    if len(gu_per_inch_list) > 1:
        initial_median = np.median(gu_per_inch_list)
        consistent_values = []
        for val in gu_per_inch_list:
            deviation_pct = abs(val - initial_median) / initial_median * 100
            if deviation_pct <= 10.0:  # Within 10% of median
                consistent_values.append(val)
            else:
                dbg(f"[SWAY] Rejecting inconsistent gu_per_in={val:.3f} (deviation={deviation_pct:.1f}%)")
        
        if consistent_values:
            gu_per_inch_list = consistent_values
            dbg(f"[SWAY] Kept {len(consistent_values)}/{len(gu_per_inch_list)} consistent calibration values")
    
    if gu_per_inch_list:
        gu_per_in = np.median(gu_per_inch_list)
        gu_per_in = float(np.clip(gu_per_in, 0.1, 10.0))  # ground-plane appropriate range
        use_pixel_fallback = False
        dbg(f"[SWAY] using ground calibration: median gu_per_in={gu_per_in:.3f} from {len(gu_per_inch_list)} frames")
    else:
        dbg("[SWAY] no consistent ground calibration; will try pixel fallback")
        use_pixel_fallback = True
        gu_per_in = 1.0
    
    # Option B pixel fallback with consistency check
    if use_pixel_fallback and world_landmarks and setup_frames:
        px_per_inch_list = []
        for k in setup_frames:
            if k in world_landmarks:
                try:
                    Lt_w, Rt_w = world_landmarks[k][31], world_landmarks[k][32]
                    toe_world_m = np.linalg.norm(np.array(Lt_w[:3]) - np.array(Rt_w[:3]))
                    toe_world_in = float(toe_world_m * 39.3701)
                    
                    lmK = sm.get(k)
                    if lmK and lmK[31] and lmK[32]:
                        # Image pixel distance
                        toe_px = float(np.linalg.norm(np.array(lmK[31][:2]) - np.array(lmK[32][:2])))
                        
                        if toe_px > 1e-3 and toe_world_in > 1e-3:
                            px_per_in = toe_px / max(toe_world_in, 1e-6)
                            if 3.0 <= px_per_in <= 60.0:  # image pixels appropriate range
                                px_per_inch_list.append(px_per_in)
                                dbg(f"[SWAY] FALLBACK frame {k}: toe_px={toe_px:.1f}, toe_world_in={toe_world_in:.2f}, px_per_in={px_per_in:.2f}")
                except:
                    pass
        
        # Apply same consistency check to pixel fallback
        if len(px_per_inch_list) > 1:
            initial_median = np.median(px_per_inch_list)
            consistent_px_values = []
            for val in px_per_inch_list:
                deviation_pct = abs(val - initial_median) / initial_median * 100
                if deviation_pct <= 10.0:  # Within 10% of median
                    consistent_px_values.append(val)
                else:
                    dbg(f"[SWAY] Rejecting inconsistent px_per_in={val:.2f} (deviation={deviation_pct:.1f}%)")
            px_per_inch_list = consistent_px_values
        
        if px_per_inch_list:
            gu_per_in = np.median(px_per_inch_list)  # reuse variable name for simplicity
            dbg(f"[SWAY] using pixel fallback: median px_per_in={gu_per_in:.2f} from {len(px_per_inch_list)} frames")
        else:
            gu_per_in = 1.0
            dbg("[SWAY] all calibration failed, using unit scale")
    
    inch_scale = 1.0 / gu_per_in if gu_per_in > 0 else 1.0

    # Measure ∆x in ground units end-to-end for consistency
    # Convert pelvis positions to ground units
    setup_ctr_gu = np.array([addr_baseline_px, setup_ctr[1]])  # Use robust baseline
    top_ctr_gu = top_ctr
    
    # Two-stage denoising: apply rolling median + EWMA smoothing to top position
    # (Note: setup already detrended above)
    if len(addr_ctrs) >= 3:
        # Simple moving median for top position stability (if multiple candidates available)
        top_candidates = []
        for f_nearby in range(max(setup_frames[0], top_frame-2), min(setup_frames[-1], top_frame+3)):
            if f_nearby in range(len(pose_data)) and f_nearby != top_frame:
                nearby_ctr = pelvis_ctr_g(f_nearby)
                if nearby_ctr is not None:
                    top_candidates.append(nearby_ctr)
        
        if top_candidates and len(top_candidates) >= 2:
            top_positions = np.stack([top_ctr] + top_candidates)
            # Median filter followed by light smoothing
            top_x_values = top_positions[:, 0]
            top_x_median = np.median(top_x_values)
            # EWMA with conservative alpha on the median-filtered result
            alpha = 0.2
            top_ctr_gu = np.array([top_x_median, top_ctr[1]])
            dbg(f"[SWAY] Two-stage denoising: raw_top_x={top_ctr[0]:.2f}, median_filtered={top_x_median:.2f}")
    
    # Calculate sway in ground units, then convert to inches
    if top_ctr_gu is not None and setup_ctr_gu is not None and fwd_hat is not None:
        sway_gu = float(np.dot(top_ctr_gu - setup_ctr_gu, fwd_hat))
        hip_sway_in = sway_gu * inch_scale
    else:
        dbg("[SWAY] Missing required data for sway calculation")
        return None
    
    # Convert jitter to inches using same scale
    setup_jitter_in = setup_jitter * inch_scale if setup_jitter is not None else 0.0
    
    # Lightweight yaw/foreshortening correction using hip span ratio
    ratio_hip = 1.0  # Default if no correction available
    if world_landmarks and setup_frames:
        try:
            # Calculate hip span ratio from setup to top for yaw correction
            setup_hip_spans = []
            for f in setup_frames[:3]:  # Use first few setup frames
                if f in world_landmarks:
                    wf = world_landmarks[f]
                    if len(wf) > 24 and wf[23] and wf[24]:  # L_HIP, R_HIP
                        span = np.linalg.norm(np.array(wf[24][:3]) - np.array(wf[23][:3]))
                        if 0.15 <= span <= 0.6:  # Reasonable hip span in meters
                            setup_hip_spans.append(span)
            
            if setup_hip_spans and top_frame in world_landmarks:
                wf_top = world_landmarks[top_frame]
                if len(wf_top) > 24 and wf_top[23] and wf_top[24]:
                    top_hip_span = np.linalg.norm(np.array(wf_top[24][:3]) - np.array(wf_top[23][:3]))
                    setup_hip_span = np.median(setup_hip_spans)
                    ratio_hip = top_hip_span / setup_hip_span
                    ratio_hip = np.clip(ratio_hip, 0.80, 1.00)  # Conservative clamp
                    dbg(f"[SWAY] Hip span ratio: {ratio_hip:.3f} (setup={setup_hip_span:.3f}m, top={top_hip_span:.3f}m)")
        except Exception as e:
            dbg(f"[SWAY] Hip span ratio calculation failed: {e}")
    
    # Apply yaw correction
    hip_sway_in_corrected = hip_sway_in / ratio_hip
    dbg(f"[SWAY] Yaw correction: raw={hip_sway_in:.2f}\", corrected={hip_sway_in_corrected:.2f}\" (ratio={ratio_hip:.3f})")
    
    # Use corrected value
    hip_sway_in = hip_sway_in_corrected
    
    # STANCE WIDTH NORMALIZATION: Calculate stance width for normalized sway
    stance_width_in = None
    if gu_per_inch_list and world_landmarks:
        # Use the same calibration frames to get stance width
        stance_widths = []
        for k in setup_frames:
            if k in world_landmarks:
                try:
                    Lt_w, Rt_w = world_landmarks[k][31], world_landmarks[k][32]
                    toe_world_m = np.linalg.norm(np.array(Lt_w[:3]) - np.array(Rt_w[:3]))
                    stance_widths.append(float(toe_world_m * 39.3701))  # Convert to inches
                except:
                    pass
        if stance_widths:
            stance_width_in = np.median(stance_widths)
    
    # Normalize and log (don't change return type)
    normalized_sway = None
    if stance_width_in and 14 <= stance_width_in <= 28:  # Reasonable stance width range
        normalized_sway = hip_sway_in / stance_width_in
    
    # Calculate trust ratio with robust jitter
    sway_jitter_ratio = abs(hip_sway_in) / max(setup_jitter_in, 1e-6)
    trust_flag = "trusted" if sway_jitter_ratio >= 1.5 else "low-trust"
    
    # Enhanced debug output with all the key metrics
    norm_str = f"{normalized_sway:.3f}" if normalized_sway is not None else "NA"
    dbg(f"[SWAY] dx={hip_sway_in:.2f}in, norm={norm_str}, jitter={setup_jitter_in:.2f}in, ratio={sway_jitter_ratio:.1f}")
    
    # Safe formatting for sway_gu which might be None in some edge cases
    sway_gu_str = f"{sway_gu:.2f}" if sway_gu is not None else "None"
    gu_per_in_str = f"{gu_per_in:.3f}" if gu_per_in is not None else "None"
    dbg(f"[SWAY] addr→top pelvis Δx={sway_gu_str}gu @ gu_per_in={gu_per_in_str}, trust={trust_flag}")
    
    if stance_width_in:
        dbg(f"[SWAY] stance_width={stance_width_in:.1f}\", target_normalized≈0.16 for pros")
    
    # Trust but verify debug printout 
    hip_sway_str = f"{hip_sway_in:.1f}" if hip_sway_in is not None else "None"
    gu_per_in_final_str = f"{gu_per_in:.3f}" if gu_per_in is not None else "None"
    dbg(f"[SWAY] final hip_sway_in={hip_sway_str}\" with gu_per_in={gu_per_in_final_str}")
    
    # Return signed, un-clipped value to preserve direction; clip only for UI bands if desired
    return float(hip_sway_in)


def _shaft_axis_hat(lm, handedness='right'):
    """Shaft axis proxy: line through wrists (trail->lead). Deterministic and stable."""
    if not lm: return None
    L_WR, R_WR = 15, 16
    if not lm[L_WR] or not lm[R_WR] or lm[L_WR][2] < 0.3 or lm[R_WR][2] < 0.3:
        return None
    
    # Build trail→lead vector based on handedness
    if handedness == 'right':
        # Right-handed: trail=16 (right wrist), lead=15 (left wrist)
        trail_wrist, lead_wrist = R_WR, L_WR
    else:
        # Left-handed: trail=15 (left wrist), lead=16 (right wrist)  
        trail_wrist, lead_wrist = L_WR, R_WR
    
    v = np.array([lm[lead_wrist][0] - lm[trail_wrist][0], lm[lead_wrist][1] - lm[trail_wrist][1]], float)  # trail->lead
    n = np.linalg.norm(v)
    return v / n if n > 1e-6 else None


def _hinge2d_shaft(lm, handed='right', roll_deg=0.0):
    """Lead-forearm only wrist hinge = angle between lead forearm and shaft vectors.
    Softened gating and consistent lead-only measurement."""
    if not lm: return None
    
    # Lead forearm only - consistent landmark selection
    EL, WR = (13, 15) if handed=='right' else (14, 16)  # lead elbow/wrist
    if not lm[EL] or not lm[WR] or lm[EL][2] < 0.4 or lm[WR][2] < 0.4:  # Relaxed visibility threshold
        return None

    # Lead forearm vector from elbow to wrist
    forearm_vec = np.array([lm[WR][0] - lm[EL][0], lm[WR][1] - lm[EL][1]], float)

    # Shaft vector using both wrists for more stable direction
    shaft_vec = _shaft_axis_hat(lm, handed)
    if shaft_vec is None:
        # Fallback: use wrist-to-wrist vector as shaft proxy
        if lm[15] and lm[16] and lm[15][2] >= 0.4 and lm[16][2] >= 0.4:
            # Build trail→lead vector based on handedness for consistency
            if handed == 'right':
                trail_wrist, lead_wrist = 16, 15
            else:
                trail_wrist, lead_wrist = 15, 16
            shaft_vec = np.array([lm[lead_wrist][0] - lm[trail_wrist][0], lm[lead_wrist][1] - lm[trail_wrist][1]], float)
        else:
            return None

    # De-roll both vectors using the provided roll correction
    cr, sr = math.cos(math.radians(-roll_deg)), math.sin(math.radians(-roll_deg))
    def deroll(v): return np.array([cr*v[0] - sr*v[1], sr*v[0] + cr*v[1]], float)
    forearm_vec = deroll(forearm_vec)
    shaft_vec = deroll(shaft_vec)

    # Normalize vectors
    forearm_norm = np.linalg.norm(forearm_vec)
    shaft_norm = np.linalg.norm(shaft_vec)
    if forearm_norm < 1e-6 or shaft_norm < 1e-6:
        return None

    # Calculate angle between lead forearm and shaft
    cos_angle = float(np.clip(np.dot(forearm_vec, shaft_vec) / (forearm_norm * shaft_norm), -1.0, 1.0))
    angle_between = math.degrees(math.acos(abs(cos_angle)))  # Always positive angle
    
    # Report acute angle (angle between forearm and shaft) instead of obtuse
    # This puts results in the expected pro range (~50-70° acute)
    hinge_acute = angle_between
    hinge_obtuse = 180.0 - angle_between  # Traditional radial deviation for debugging
    
    # Debug logging: keep both for debugging but standardize to acute
    dbg(f"[HINGE] acute={hinge_acute:.1f}°, obtuse={hinge_obtuse:.1f}° (reporting acute)")
    
    return float(hinge_acute)


def _pelvis_ctr_ground_series(sm, frames, warp2):
    """Get pelvis centers in ground plane for given frames"""
    ctrs = []
    for f in frames:
        lm = sm.get(f)
        if not lm or not lm[23] or not lm[24]: 
            ctrs.append(None)
            continue
        g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]])
        ctrs.append(((g[0]+g[1])/2).astype(float))
    return ctrs

def _refine_top_by_lat_vel(sm, backswing_frames, warp2, target_hat_g, init_top):
    """Refine top frame using lateral pelvis velocity zero-crossing"""
    # compute pelvis centers along backswing and project onto target axis
    ctrs = _pelvis_ctr_ground_series(sm, backswing_frames, warp2)
    ts = [i for i,c in enumerate(ctrs) if c is not None]
    if len(ts) < 5: 
        return init_top  # not enough samples
    proj = np.array([float(np.dot(ctrs[i], target_hat_g)) if ctrs[i] is not None else np.nan for i in range(len(ctrs))])
    # smooth (5-pt)
    k = 5
    proj_s = np.convolve(np.nan_to_num(proj), np.ones(k)/k, mode='same')
    # finite diff
    vel = np.gradient(proj_s)
    # take frame near init_top where vel≈0 and |vel| is locally minimal (change of direction)
    idx0 = backswing_frames.index(init_top) if init_top in backswing_frames else len(backswing_frames)-1
    win = range(max(0, idx0-4), min(len(vel), idx0+5))
    # find zero-cross or min |vel|
    cand = min(win, key=lambda i: abs(vel[i]))
    return backswing_frames[cand]

def _point_ground(frame_idx, sm, warp2, i):
    """Get ground-plane position of landmark i in frame frame_idx"""
    lm = sm.get(frame_idx)
    if not lm or not lm[i]: return None
    return warp2([[lm[i][0], lm[i][1]]])[0].astype(float)



def _pick_p3_lead_arm_parallel(sm, backswing_frames, handed='right', tol_deg=15.0):
    """Pick the backswing frame where the lead shoulder→lead wrist vector is most horizontal."""
    L_SHO, WR = (11, 15) if handed=='right' else (12, 16)
    best = None
    best_abs = 1e9
    for f in backswing_frames:
        lm = sm.get(f)
        if not lm or not lm[L_SHO] or not lm[WR]: 
            continue
        if lm[L_SHO][2] < 0.5 or lm[WR][2] < 0.5:
            continue
        dx = lm[WR][0] - lm[L_SHO][0]
        dy = lm[WR][1] - lm[L_SHO][1]
        if abs(dx) + abs(dy) < 1e-6:
            continue
        ang = abs(math.degrees(math.atan2(dy, dx)))  # 0° is perfectly horizontal
        if ang < best_abs:
            best_abs, best = ang, f
    return best

def _pick_top_by_wrist_height(sm, backswing, handed='right', min_vis=0.7):
    """Pick top frame by highest wrist position with improved validation"""
    WR = 15 if handed=='right' else 16
    SH = 11 if handed=='right' else 12  # lead shoulder
    
    best, best_y = None, 1e9
    candidates = []
    consecutive_rejects = 0
    max_consecutive_rejects = 10  # Stop after N consecutive rejects to reduce spam
    
    for f in backswing:
        lm = sm.get(f)
        if not lm or not lm[WR] or not lm[SH] or lm[WR][2] < min_vis or lm[SH][2] < min_vis:
            consecutive_rejects += 1
            if consecutive_rejects >= max_consecutive_rejects:
                dbg(f"[WRIST] Skipping {max_consecutive_rejects}+ consecutive bad frames, jumping ahead")
                break
            continue
            
        # Reject frames with shoulder-to-wrist line near vertical (club laid off/occluded)
        # Calculate angle of shoulder-to-wrist line from horizontal
        dx = lm[WR][0] - lm[SH][0]
        dy = lm[WR][1] - lm[SH][1]
        
        if abs(dx) < 1e-6:  # Perfectly vertical
            consecutive_rejects += 1
            if consecutive_rejects >= max_consecutive_rejects:
                dbg(f"[WRIST] Skipping {max_consecutive_rejects}+ consecutive steep frames, jumping ahead")
                break
            continue
            
        angle_from_horizontal = abs(math.degrees(math.atan2(dy, dx)))
        
        # Unified gating constant - use everywhere that gates by shoulder-wrist angle
        SHO_WRIST_STEEP_MAX = 120.0  # was ~105 in some functions
        
        if angle_from_horizontal > SHO_WRIST_STEEP_MAX:
            consecutive_rejects += 1
            if consecutive_rejects >= max_consecutive_rejects:
                dbg(f"[WRIST] Skipping {max_consecutive_rejects}+ consecutive steep frames, jumping ahead")
                break
            # Only log first few rejects to reduce spam
            if consecutive_rejects <= 3:
                dbg(f"[WRIST] Rejecting frame {f}: shoulder-wrist angle {angle_from_horizontal:.1f}° too steep")
            continue
        
        # Found a good frame, reset consecutive reject counter
        consecutive_rejects = 0
            
        y = lm[WR][1]  # smaller y = higher hand (screen coords)
        candidates.append((f, y, angle_from_horizontal))
        
        if y < best_y:
            best_y, best = y, f
    
    # Additional validation: ensure chosen frame is not an outlier
    if best is not None and candidates:
        # Check if there are other reasonable candidates nearby in height
        height_tolerance = 20  # pixels
        reasonable_candidates = [c for c in candidates if abs(c[1] - best_y) <= height_tolerance]
        
        if len(reasonable_candidates) > 1:
            # Among height-similar candidates, prefer one with better shoulder-wrist geometry
            reasonable_candidates.sort(key=lambda x: x[2])  # Sort by angle (smaller is better)
            best_candidate = reasonable_candidates[0]
            best = best_candidate[0]
            dbg(f"[WRIST] Selected frame {best} with angle {best_candidate[2]:.1f}° from {len(reasonable_candidates)} candidates")
    
    return best



def _pick_top_frame_hinge(sm, backswing_frames, handed='right', roll_deg=0.0):
    """Pick frame with maximum hinge in backswing, with visibility and geometry validation"""
    if not backswing_frames:
        return None
    
    SH = 11 if handed=='right' else 12  # lead shoulder
    WR = 15 if handed=='right' else 16  # lead wrist
    
    # Restrict to P3→P4 window by limiting to last 70% of backswing
    p3_start_idx = max(0, int(0.3 * len(backswing_frames)))
    p3_p4_frames = backswing_frames[p3_start_idx:]
    
    top_vals = []
    for f in p3_p4_frames:
        lm = sm.get(f)
        if not lm or not lm[WR] or not lm[SH] or lm[WR][2] < 0.7 or lm[SH][2] < 0.7:
            continue
            
        # Reject frames with shoulder-to-wrist line near vertical (same logic as height-based)
        dx = lm[WR][0] - lm[SH][0]
        dy = lm[WR][1] - lm[SH][1]
        
        if abs(dx) < 1e-6:  # Perfectly vertical
            continue
            
        angle_from_horizontal = abs(math.degrees(math.atan2(dy, dx)))
        SHO_WRIST_STEEP_MAX = 120.0  # Unified constant
        if angle_from_horizontal > SHO_WRIST_STEEP_MAX:  # Use unified threshold
            continue
            
        h = _hinge2d_shaft(lm, handed, roll_deg)
        if h is not None:
            # Additional sanity check for hinge values (h is now acute angle)
            if 10.0 <= h <= 120.0:  # plausible range for acute angles
                top_vals.append((f, h))
    
    if not top_vals:
        return None
    
    # Return frame with maximum hinge
    top_frame = max(top_vals, key=lambda x: x[1])[0]
    return top_frame

def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List], 
                                handedness: str = 'right') -> Union[Dict[str, float], None]:
    """Calculate wrist hinge using shaft-based method with both P3 and top measurements"""
    # Define indices locally to avoid hidden globals
    L_EYE, R_EYE = 2, 5
    L_HIP, R_HIP = 23, 24
    
    sm = smooth_landmarks(pose_data)
    
    backswing_frames = swing_phases.get("backswing", [])
    if not backswing_frames:
        # Fallback: use any available frames as "backswing"
        all_frames = sorted(pose_data.keys())
        if all_frames:
            # Use middle third of available frames as "backswing"
            mid_start = len(all_frames) // 3
            mid_end = 2 * len(all_frames) // 3
            backswing_frames = all_frames[mid_start:mid_end] or all_frames
        else:
            return None  # Truly no data
    
    # Reuse consensus roll from shoulder tilt function
    def consensus_roll(lm, H=None, W=None):
        cands = []
        for iL, iR in [(L_EYE, R_EYE), (L_HIP, R_HIP)]:
            if lm[iL] and lm[iR]:
                dx = lm[iR][0] - lm[iL][0]
                dy = lm[iR][1] - lm[iL][1]
                if abs(dx)+abs(dy) > 1e-6:
                    t = math.degrees(math.atan2(dy, dx))
                    t = ((t + 90.0) % 180.0) - 90.0
                    if abs(t) <= 20: cands.append(t)
        return float(np.median(cands)) if cands else 0.0
    
    # Initial roll from setup frames (clipped conservatively)
    setup_frames = swing_phases.get("setup", [])[:8]
    setup_rolls = [consensus_roll(sm.get(f)) for f in setup_frames if sm.get(f)]
    setup_roll_deg = float(np.clip(np.median(setup_rolls) if setup_rolls else 0.0, -10.0, 10.0))
    
    # More robust top frame selection: try multiple methods
    top_frame = _pick_top_by_wrist_height(sm, backswing_frames, handed=handedness)
    if not top_frame:
        top_frame = _pick_top_frame_hinge(sm, backswing_frames, handed=handedness, roll_deg=setup_roll_deg)
    if not top_frame and backswing_frames:
        # Last resort: use frame with max backswing index that has good landmarks
        for f in reversed(backswing_frames):
            lm = sm.get(f)
            EL, WR = (13, 15) if handedness=='right' else (14, 16)
            if lm and lm[EL] and lm[WR] and lm[EL][2] >= 0.5 and lm[WR][2] >= 0.5:
                top_frame = f
                dbg(f"[WRIST] Using fallback top frame: {f}")
                break
    
    # Ensure we never have None for top_frame
    if top_frame is None and backswing_frames:
        top_frame = backswing_frames[-1]
    
    # Compute local roll around top frame for better accuracy
    roll_deg = setup_roll_deg  # Start with setup roll as baseline
    if top_frame is not None:
        # Get frames around top for local roll calculation
        local_window = [f for f in range(max(backswing_frames[0], top_frame-4),
                                        min(backswing_frames[-1], top_frame+5)+1) if f in backswing_frames]
        local_rolls = [consensus_roll(sm.get(f)) for f in local_window if sm.get(f)]
        if local_rolls:
            # Use wider clipping range for top position (±20-25°)
            local_roll = float(np.median(local_rolls))
            roll_deg = float(np.clip(local_roll, -25.0, 25.0))
            dbg(f"[WRIST] Local roll at top: {roll_deg:.1f}° (setup: {setup_roll_deg:.1f}°)")
        else:
            dbg(f"[WRIST] Using setup roll: {roll_deg:.1f}° (no local roll data)")
    
    # FIXED: Calculate hinge with moving window average around top frame for smoothness
    hinge_top = None
    if top_frame is not None:
        # Use ±2 frames around top for moving window average (5-frame window)
        win = [f for f in range(max(backswing_frames[0], top_frame-2),
                                min(backswing_frames[-1], top_frame+2)+1) if f in backswing_frames]
        valid_hinges = []
        
        for f in win:
            landmarks = sm.get(f)
            if landmarks:
                h = _hinge2d_shaft(landmarks, handedness, roll_deg)
                if h is not None:
                    # h here is now acute angle (angle between forearm and shaft)
                    if 10.0 <= h <= 120.0:  # plausible range for acute angles
                        valid_hinges.append((f, h))
                        dbg(f"[WRIST] frame {f}: hinge_acute={h:.1f}°")
        
        if valid_hinges:
            # Use moving window average of valid hinge values for smoothness
            hinge_values = [h for _, h in valid_hinges]
            if len(hinge_values) >= 3:
                # Remove outliers and take median for robustness
                hinge_array = np.array(hinge_values)
                median_val = np.median(hinge_array)
                mad = np.median(np.abs(hinge_array - median_val))
                outlier_threshold = median_val + 2.5 * mad  # Remove values more than 2.5 MAD from median
                filtered_hinges = [h for h in hinge_values if abs(h - median_val) <= 2.5 * mad]
                if filtered_hinges:
                    hinge_top = float(np.mean(filtered_hinges))  # Use mean of filtered values
                    dbg(f"[WRIST] Smoothed hinge (mean of {len(filtered_hinges)} values): {hinge_top:.1f}°")
                else:
                    hinge_top = float(median_val)
            else:
                # Not enough data for robust averaging, use median
                hinge_top = float(np.median(hinge_values))
                dbg(f"[WRIST] Limited data, using median of {len(hinge_values)} values: {hinge_top:.1f}°")
        else:
            # Fallback: find best single frame
            best = (top_frame, None)
            for f in win:
                landmarks = sm.get(f)
                if landmarks:
                    h = _hinge2d_shaft(landmarks, handedness, roll_deg)
                    if h is not None and (best[1] is None or h > best[1]):
                        best = (f, h)
            top_frame, hinge_top = best
        
        # Additional validation to catch landmark selection errors (now acute angles)
        if hinge_top is not None and (hinge_top < 5.0 or hinge_top > 120.0):
            dbg(f"WARNING: Suspicious acute hinge angle {hinge_top:.1f}° - possible landmark error")
            hinge_top = np.clip(hinge_top, 20.0, 90.0)  # Conservative clamp for acute angles
    
    dbg(f"top_selection: selected={top_frame}")

    # FIXED: P3 frame and hinge with better validation
    p3_frame = _pick_p3_lead_arm_parallel(sm, backswing_frames, handedness)
    hinge_p3 = None
    if p3_frame is not None:
        p3_landmarks = sm.get(p3_frame)
        if p3_landmarks:
            hinge_p3 = _hinge2d_shaft(p3_landmarks, handedness, roll_deg)
            # Validate P3 hinge angle is reasonable (now acute angle)
            if hinge_p3 is not None and (hinge_p3 < 10.0 or hinge_p3 > 120.0):
                dbg(f"WARNING: Suspicious P3 acute hinge angle {hinge_p3:.1f}° - possible landmark error")

    # FIXED: Address baseline with improved landmark validation
    addr_frames = _pick_stable_setup_frames(sm, swing_phases, max_frames=6) or _address_frames(swing_phases)
    addr_vals = []
    for f in addr_frames[:6]:
        addr_landmarks = sm.get(f)
        if addr_landmarks:
            h = _hinge2d_shaft(addr_landmarks, handedness, roll_deg)
            # Remove address hinge range filter - accept all valid measurements
            if h is not None:
                addr_vals.append(h)
    hinge_addr = float(np.median(addr_vals)) if addr_vals else None
    
    # If top hinge failed, borrow P3; if that also failed, borrow address
    if hinge_top is None:
        if hinge_p3 is not None:
            hinge_top = hinge_p3
            dbg(f"hinge_top was None, borrowed from P3: {hinge_top}")
        elif hinge_addr is not None:
            hinge_top = hinge_addr  # last resort (won't be graded as "top", but avoids None)
            dbg(f"hinge_top was None, borrowed from address: {hinge_top}")

    # Remove auto-promotion to avoid convergence bias
    # Keep original values to show real differences

    # hinge_top and hinge_p3 are now acute angles (forearm-shaft angle)
    theta_top = hinge_top  # No conversion needed, already acute
    theta_p3 = hinge_p3   # No conversion needed, already acute
    
    # Debug prints to verify shaft angles
    if theta_top is not None:
        dbg(f"[WRIST-CHECK] theta_top = {theta_top:.1f}° (acute angle between forearm & shaft)")
    
    # Also calculate obtuse angles for backward compatibility and debugging
    obtuse_top = None if hinge_top is None else (180.0 - hinge_top)
    obtuse_p3 = None if hinge_p3 is None else (180.0 - hinge_p3)

    # Enhanced debug output with window information
    window_size = len([f for f in range(max(backswing_frames[0], top_frame-2),
                                      min(backswing_frames[-1], top_frame+2)+1) if f in backswing_frames]) if top_frame else 0
    hinge_str = f"{hinge_top:.1f}" if hinge_top is not None else "None"
    dbg(f"[WRIST] samples={window_size}, mean={hinge_str}°, window=±2")
    dbg(f"[WRIST] roll={roll_deg:.1f}°, addr={hinge_addr}, p3={hinge_p3}, top={hinge_top}, "
          f"Δp3={None if hinge_addr is None or hinge_p3 is None else hinge_p3-hinge_addr}, "
          f"Δtop={None if hinge_addr is None or hinge_top is None else hinge_top-hinge_addr}")
    
    # Trust but verify debug printout
    if hinge_top is not None:
        dbg(f"[WRIST] final wrist_hinge_top={hinge_top:.1f}° (handed={handedness})")
    else:
        dbg(f"[WRIST] final wrist_hinge_top=None (handed={handedness})")

    result = {
        # Report acute angles (what coaches expect: ~50-70° for pros)
        "forearm_shaft_angle_top_deg": theta_top,
        "forearm_shaft_angle_p3_deg": theta_p3,
        "addr_deg": hinge_addr,
        "delta_deg_p3": (hinge_p3 - hinge_addr) if (hinge_p3 is not None and hinge_addr is not None) else None,
        "delta_deg_top": (hinge_top - hinge_addr) if (hinge_top is not None and hinge_addr is not None) else None,
        # NEW: Standardize to acute angles for main reporting
        "acute_angle_deg_top": hinge_top,
        "acute_angle_deg_p3": hinge_p3,
        # Keep obtuse angles for debugging and backward compatibility
        "obtuse_angle_deg_top": obtuse_top,
        "obtuse_angle_deg_p3": obtuse_p3,
        # Flag outside typical pro band for P3 (now using acute angle range)
        "definition_suspect": (hinge_p3 is None) or not (40 <= hinge_p3 <= 80),
        # Legacy field for backward compatibility - use acute angle
        "radial_deviation_deg": hinge_p3 if hinge_p3 is not None else hinge_top
    }
    return result


def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[str, List], 
                                world_landmarks: Optional[Dict[int, List]] = None,
                                frames: Optional[List[np.ndarray]] = None,
                                club: str = "iron", 
                                handedness: str = "right") -> Dict[str, Dict[str, Union[float, str, None]]]:
    """Compute the 4 front-facing golf swing metrics with club-aware grading"""
    
    # Calculate individual metrics
    torso_sidebend_result = calculate_torso_sidebend_at_impact(
        pose_data, swing_phases, frames=frames, handedness=handedness, world_landmarks=world_landmarks
    )
    # Extract value for compatibility
    shoulder_tilt_impact = torso_sidebend_result.get("value") if torso_sidebend_result else None
    hip_sway_top = calculate_hip_sway_at_top(pose_data, swing_phases, world_landmarks)
    wrist_hinge_result = calculate_wrist_hinge_at_top(pose_data, swing_phases, handedness=handedness)
    hip_shoulder_sep_result = calculate_hip_shoulder_separation_at_impact(pose_data, swing_phases, handedness)
    
    # Extract wrist hinge values (NEW: prioritize forearm-shaft angle measurements)
    wrist_top_theta = wrist_hinge_result.get('forearm_shaft_angle_top_deg') if wrist_hinge_result else None
    wrist_p3_theta = wrist_hinge_result.get('forearm_shaft_angle_p3_deg') if wrist_hinge_result else None
    wrist_p3 = wrist_hinge_result.get('radial_deviation_deg_p3') if wrist_hinge_result else None
    wrist_top = wrist_hinge_result.get('radial_deviation_deg_top') if wrist_hinge_result else None
    wrist_addr = wrist_hinge_result.get('addr_deg') if wrist_hinge_result else None
    delta_p3 = wrist_hinge_result.get('delta_deg_p3') if wrist_hinge_result else None
    delta_top = wrist_hinge_result.get('delta_deg_top') if wrist_hinge_result else None
    definition_suspect = wrist_hinge_result.get('definition_suspect', False) if wrist_hinge_result else False
    
    # Initialize to avoid undefined locals
    wrist_final = None
    wrist_kind = None
    
    # Prefer radial deviation (hinge), then P3 radial, then θ as fallback
    if wrist_top is not None:
        wrist_final = wrist_top
        wrist_kind = "radial_deviation_top"
    elif wrist_p3 is not None:
        wrist_final = wrist_p3
        wrist_kind = "radial_deviation_p3"
    elif wrist_top_theta is not None:
        wrist_final = wrist_top_theta
        wrist_kind = "forearm_shaft_angle_top"
    else:
        wrist_final = wrist_p3_theta
        wrist_kind = "forearm_shaft_angle_p3"
        
    # Add visible version tag and debug info
    print(f"[FF] {METRICS_VERSION} running (debug={'on' if VERBOSE else 'off'})")
    
    # Log metrics summary for debugging
    dbg(f"[METRIC] torso_side_bend={shoulder_tilt_impact}, sway_top_in={hip_sway_top}, hinge_top={wrist_hinge_result.get('radial_deviation_deg_top') if wrist_hinge_result else None}, hip_sho_sep={hip_shoulder_sep_result.get('hip_shoulder_sep_deg') if hip_shoulder_sep_result else None}")
    
    front_facing_metrics = {
        'torso_side_bend_deg': {
            'value': shoulder_tilt_impact,
            'metric': torso_sidebend_result.get("metric") if torso_sidebend_result else None,
            'quality': torso_sidebend_result.get("quality") if torso_sidebend_result else None,
            'details': torso_sidebend_result.get("details") if torso_sidebend_result else None
        },
        'shoulder_tilt_impact_deg': {'value': shoulder_tilt_impact},  # Deprecated, alias for compatibility
        'hip_sway_top_inches': {'value': hip_sway_top},
        'hip_shoulder_separation_impact_deg': {
            'value': hip_shoulder_sep_result.get('hip_shoulder_sep_deg') if hip_shoulder_sep_result else None,
            'abs_deg': hip_shoulder_sep_result.get('hip_shoulder_sep_abs_deg') if hip_shoulder_sep_result else None,
            'confidence': hip_shoulder_sep_result.get('confidence') if hip_shoulder_sep_result else 0.0
        },
        'wrist_hinge_top_deg': {
            'value': wrist_final,
            'value_kind': wrist_kind,
            'forearm_shaft_angle_top_deg': wrist_top_theta,
            'forearm_shaft_angle_p3_deg': wrist_p3_theta,
            'radial_deviation_p3_deg': wrist_p3,
            'radial_deviation_top_deg': wrist_top,
            'addr_deg': wrist_addr,
            'delta_p3_deg': delta_p3,
            'delta_top_deg': delta_top,
            'definition_suspect': definition_suspect
        },
        '__version__': METRICS_VERSION,
        '_debug': {
            'version': METRICS_VERSION,
            'used_world': bool(world_landmarks),
            'setup_frames': swing_phases.get('setup', [])[:8],
            'impact_frames': swing_phases.get('impact', []),
        }
    }
    
    return front_facing_metrics