File size: 46,249 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Pipeline Broadcast Analyzer dùng chung — bàn giao 24 (11/08/2026).

Nhấc logic đã nghiệm thu ở P0 (scripts/broadcast/{extract_shots,track_p0,
measure_p0}.py — bàn giao 23) thành module để app/worker dùng lại, cộng TẦNG
GIẢI TÍCH mới (phôi thai baseline §7.3 design doc BroadcastAnalyzer):

- ``analyze_track``  — V0 / φ / va chạm (+ phân loại ``contact`` bi/băng,
  BG25) / baseline spin thô ``spin_*`` (BG25 — xem ``_read_spin`` và bảng
  hằng SPIN_*/SIDE_*) / coverage / cảnh báo từ track cue ball. THUẦN NUMPY,
  không cv2/torch → unit test chạy trên venv app.
- ``analyze_clip``   — front-end trọn gói cho MỘT clip 1 cú: decode PTS thật
  + cột img_diff, YOLO detect, homography 4 pocket → track → analyze_track.
  Import cv2 LƯỜI trong thân hàm (nếp poolcoach_cv/__init__: venv app không
  có cv2 vẫn ``import poolcoach_cv.broadcast`` được).

Scripts P0 giữ NGUYÊN không sửa (BRIEF cho chọn wrapper hoặc giữ nguyên):
số đo bàn giao 23 sinh từ đúng code đó, không đụng là bảo đảm rẻ nhất cho
"không đổi số đo cũ". Module này là code path MỚI cho app.

Quy ước toạ độ & góc: hệ bàn broadcast x ∈ [0, W=1.27], y ∈ [0, L=2.54] mét
(mặt chơi 9ft chuẩn giải — KHÔNG phải bàn pooltool 0.9906×1.9812; FE tự
chuẩn hoá khi vẽ). ``phi_deg`` = hướng chuyển động đo bằng độ, 0° = +x,
90° = +y, ngược chiều kim đồng hồ, trả trong [0, 360).

Frame TRÙNG (stream 25fps upconvert khai 30fps — HANDOFF 23 Bất ngờ 1):
MỌI đạo hàm theo thời gian ở đây chạy trên chuỗi ĐÃ loại frame trùng bằng
``img_diff`` (PTS không đủ — PTS frame trùng vẫn hợp lệ). Không dedup thì V0
dính dip giả về 0 mỗi 6 bước.
"""

from __future__ import annotations

import math
from pathlib import Path

import numpy as np

from poolcoach_cv.homography import TableHomography, orient_corners

# ---------------------------------------------------------------- hằng số
# MỘT bảng duy nhất (BRIEF bước 1.2) — đổi ngưỡng là đổi Ở ĐÂY, kèm nguồn.

TABLE_W_M = 1.27          # mặt chơi 9ft chuẩn giải (BRIEF P0) — băng NGẮN
TABLE_L_M = 2.54          # băng DÀI

V_STILL_MPS = 0.055       # ngưỡng đứng yên — p99 apparent speed 37 bi tĩnh
                          # (P0, Cowork chốt 55 mm/s trong BRIEF 24)
V_INIT_MPS = 0.25         # ngưỡng BẢO THỦ tìm motion_start (measure_p0 —
                          # cố ý ≫ V_STILL để không phụ thuộc số đề xuất)
V_INIT_RUN = 3            # số bước liên tiếp vượt V_INIT mới là "bắt đầu chạy"
V0_WINDOW_S = 0.2         # cửa sổ đo V0 sau motion_start (BRIEF bước 1.2)

BALL_R_M = 0.028575       # bán kính bi 57.15mm/2 (bi pool 2¼", BallParams
                          # pooltool mặc định — cùng bi mà env RL dùng)
KINK_CUM_DEG = 35.0       # Δθ tích lũy ≥ mức này → va chạm (Cowork chốt,
                          # tiêu chí 2 tầng thay per-step 31.8° bị blur chi phối)
KINK_WIN_STEPS = 2        # tích lũy trên cửa sổ 1–2 BƯỚC frame thật (= 2–3 frame)
KINK_ONSET_MIN_M = 2 * BALL_R_M
                          # guard kink tại motion onset (Cowork chốt BRIEF 25,
                          # phương án (a) HANDOFF 24 câu 1): bỏ ứng viên kink
                          # khi quãng đường tích lũy < 2R ≈ 57mm — bi phải đi
                          # hết đường kính mình thì heading mới có nghĩa (bước
                          # đầu chỉ nhích cỡ sàn nhiễu dọc hướng chạy ±13mm →
                          # heading rác → kink giả 164.7° của cú 11). Tính từ
                          # điểm track ĐẦU TIÊN sau motion_start, không phải
                          # frame motion_start danh nghĩa (shot_07: 24% đầu cú
                          # mất track do người che — BRIEF 25 bối cảnh #5).
SPEED_DROP_FRAC = 0.45    # tầng 2: tốc độ rơi ≥ 45% → va chạm (phôi thai —
                          # median 2 bước mỗi bên để chịu nhiễu dọc hướng chạy
                          # ±13mm ~ ±0.4 m/s/bước, HANDOFF 23 Bất ngờ 2)
SPEED_DROP_MIN_MPS = 1.0  # sàn xét speed-drop (Cowork nâng 0.5 → 1.0, BRIEF 25:
                          # cú 11 sinh 2 va chạm giả t=4.0/4.8 ở ~0.7 m/s giữa
                          # đoạn lăn chậm — vùng nhiễu dọc hướng chạy chi phối).
                          # Hệ quả CÓ CHỦ ĐÍCH, không phải bug: va chạm nhẹ mà
                          # tốc độ trước chạm dưới 1.0 m/s sẽ BỎ SÓT ở tầng này
                          # (tầng kink vẫn có thể bắt nếu đổi hướng đủ lớn).
                          # VAI TRÒ KÉP (BG26 bước 1.2, Cowork chốt từ câu hỏi
                          # HANDOFF 25 #4): cũng là sàn tốc độ quanh va chạm
                          # cho _read_spin — chord tới va chạm chậm hơn mức
                          # này thì spin_confidence bị ÉP "low" (vùng nhiễu
                          # dọc hướng chạy chi phối, bài học cú 11). Cùng MỘT
                          # hằng, không tạo hằng mới.
COLLISION_MERGE_S = 0.15  # hai tín hiệu cách < mức này = CÙNG một va chạm

# Phân loại va chạm bi/băng/unknown (BRIEF 25 bước 2) — trường ``contact``
# của mỗi va chạm. Tên ``contact`` chứ KHÔNG phải ``kind`` như chữ BRIEF:
# ``kind`` đã là tầng tín hiệu (kink/speed_drop) từ bàn giao 24, mà Ranh
# giới cấm đổi trường JSON đã có — khai trong HANDOFF 25.
CONTACT_CUSHION_M = 1.5 * BALL_R_M
                          # điểm va chạm cách mép băng ≤ ~1.5R → "cushion":
                          # tâm bi lúc chạm băng cách mép đúng 1R, +0.5R cho
                          # nhiễu vị trí + neo lệch 1 bước frame
CONTACT_BALL_M = 2.5 * BALL_R_M
                          # cách bi tĩnh ≤ ~2.5R → "ball": tâm-tâm lúc chạm
                          # là 2R, +0.5R như trên. Xét SAU cushion — bi dính
                          # băng thì ưu tiên đọc là chạm băng (thứ tự BRIEF)
STATIC_CLUSTER_M = 0.04   # gom detection không-cue thành bi tĩnh: điểm cách
                          # median cụm < ~1.4R nhập cụm (hai bi sát nhau tâm
                          # vẫn cách 2R = 57mm nên không dính cụm nhau)
STATIC_MIN_DETS = 3       # cụm ≥ 3 detection mới là bi thật (loại det chớp)

# Baseline SPIN giải tích THÔ (BRIEF 25 bước 3; §7.3 design doc — baseline
# bắt buộc mà BG26 net phải thắng). Spin bida CHỈ LỘ QUA VA CHẠM (§3.2):
# dọc (follow/draw/stun) đọc ở va chạm BI đầu tiên, ngang (side-L/R) đọc ở
# va chạm BĂNG; cú không va chạm → null (unidentifiable về nguyên tắc).
# Vật lý tham khảo lấy từ pooltool 0.6.0 (KHÔNG import — module này phải
# thuần numpy cho venv app; số dẫn ở đây đo bằng probe chạy pooltool thật):
# - bi-bi: va chạm giữ thành phần TIẾP TUYẾN, chuyển thành phần dọc đường
#   nối tâm (resolve/ball_ball/frictionless_elastic) → hướng bật tức thời
#   của cú cắt (stun) lệch hướng tới đúng 90°−góc_cắt — tối đa 90°.
# - trượt→lăn: physics/evolve — sau chạm bi trượt 0.09–0.30s (đo ở 1.2–2.0
#   m/s) rồi mới lăn; topspin/backspin còn lại bẻ cong đường đi VỀ/RA XA
#   hướng tới cũ trong pha này.
# - băng: resolve/ball_cushion/han_2005 + evolve — bi lăn KHÔNG side đã bật
#   lệch phản xạ gương tới ~15° ở hướng lăn ổn định (đo inc 30–60°, wz=0,
#   e_c 0.85 f_c 0.2 mặc định) → ngưỡng side phải VƯỢT floor đó; hệ quả có
#   chủ đích: side yếu (per model chỉ nhích thêm vài độ) sẽ BỎ SÓT.
SPIN_PRE_S = 0.30         # cửa sổ chord hướng TỚI trước va chạm
SPIN_SETTLE_S = 0.15      # pha trượt sau chạm — bỏ khi đo hướng ổn định
SPIN_WIN_S = 0.45         # cửa sổ đọc sau va chạm (cắt tại va chạm kế nếu có)
SPIN_MIN_CHORD_M = 0.02   # chord ngắn hơn mức này không tin được hướng
SPIN_VERT_KINK_MIN_DEG = 40.0
                          # Δθ < mức này = góc cắt > ~50° (Δθ ≈ 90°−góc cắt):
                          # cú CẮT MỎNG — tangent giữ gần hết vận tốc, xoáy
                          # dọc không lộ → không đọc (ngưỡng góc cắt BRIEF)
SPIN_DRAW_KINK_DEG = 110.0
                          # Δθ > mức này KHÔNG THỂ là tangent cú cắt (tối đa
                          # 90° + nhiễu) → chữ ký draw trực tiếp (đường về)
SPIN_BEND_MIN_DEG = 15.0  # cú cắt: đường sau chạm cong ≥ mức này về hướng cũ
                          # → follow, ra xa → draw, còn lại stun (~2× nhiễu
                          # heading chord vài bước: ±13mm trên chord 10–15cm)
SPIN_STUN_MAX_M = 0.10    # cú chạm dày: trôi < mức này = dừng/trôi ngắn → stun
SPIN_DIR_MIN_M = 0.06     # cú chạm dày: dịch dọc hướng cũ ≥ mức này (~2R,
                          # ≫ sàn nhiễu 13mm) mới kết luận follow/draw
SIDE_DEV_MIN_DEG = 20.0   # lệch phản xạ gương ≥ mức này → side (floor tự
                          # nhiên ~15° ở trên + nhiễu); 20–30° đọc kèm dè dặt
SIDE_DEV_MED_DEG = 30.0   # từ mức này spin_confidence lên medium

DUP_REL = 0.3             # frame trùng: img_diff < 0.3 × median lân cận...
DUP_MIN_MEDIAN = 0.08     # ...và lân cận đang thật sự chuyển động (measure_p0)

VMAX_MPS = 11.0           # gating vận tốc track NN (design §4.4, track_p0)
GATE_SLACK_M = 0.06       # nhiễu vị trí + tâm bbox lệch tâm bi (~2R)
IN_TOL_M = 0.05           # tâm chiếu lệch ngoài mặt bàn quá mức này → loại det
BALL_DEDUP_M = 0.03       # 2 tâm < ~1R = double-detect (nếp cv_worker)

COVERAGE_WARN = 0.80      # dưới mức này cảnh báo (gate G0 dùng cùng mốc)
GAP_WARN_S = 0.5          # track đứt dài hơn mức này → cảnh báo
V0_SANE_MPS = (1.0, 8.0)  # ngoài khoảng này V0 đáng ngờ (BRIEF bước 2.2)

DETECT_BATCH = 16         # nếp track_p0
BALL_CLASSES = {"Black", "Cue", "Solid", "Striped"}   # Dot = nút thành gỗ


# ------------------------------------------------- tiện ích thuần (no cv2)

def _wrap_deg(a: float) -> float:
    """Hiệu góc về (-180, 180]."""
    return ((a + 180.0) % 360.0) - 180.0


def find_dup_frames(names: list[str], diffs: list[float]) -> set[str]:
    """Frame nhân đôi theo luật CỤC BỘ trên img_diff (measure_p0, giữ nguyên):
    diff < DUP_REL × median của ≤6 lân cận VÀ lân cận đang chuyển động
    (median > DUP_MIN_MEDIAN — tránh cờ giả trong cảnh tĩnh)."""
    dups: set[str] = set()
    for i in range(1, len(diffs)):
        if diffs[i] < 0:
            continue
        win = [diffs[j] for j in range(max(1, i - 3), min(len(diffs), i + 4))
               if j != i and diffs[j] >= 0]
        if not win:
            continue
        med = float(np.median(win))
        if med > DUP_MIN_MEDIAN and diffs[i] < DUP_REL * med:
            dups.add(names[i])
    return dups


def _speed_series(t: np.ndarray, xy: np.ndarray):
    """(t, xy) đã dedup → (t_mid, speed m/s, heading deg). len = len(t)-1."""
    dt = np.diff(t)
    dxy = np.diff(xy, axis=0)
    speed = np.hypot(dxy[:, 0], dxy[:, 1]) / np.maximum(dt, 1e-6)
    heading = np.degrees(np.arctan2(dxy[:, 1], dxy[:, 0]))
    return t[:-1] + dt / 2, speed, heading


def _find_motion_start(t_mid: np.ndarray, speed: np.ndarray) -> float | None:
    """Mốc cue ball bắt đầu chạy: V_INIT_RUN bước liên tiếp vượt V_INIT
    (measure_p0.find_motion_start, giữ nguyên)."""
    run = 0
    for i, v in enumerate(speed):
        run = run + 1 if v > V_INIT_MPS else 0
        if run >= V_INIT_RUN:
            return float(t_mid[i - V_INIT_RUN + 1])
    return None


def _find_collisions(t: np.ndarray, xy: np.ndarray, t_mid: np.ndarray,
                     speed: np.ndarray, heading: np.ndarray,
                     t_from: float) -> list[dict]:
    """Va chạm = gãy khúc 2 tầng đã chốt (BRIEF bước 1.2).

    Tầng 1 — Δθ tích lũy: |heading[i+w] − heading[i]| (wrap) ≥ KINK_CUM_DEG
    với w ∈ {1..KINK_WIN_STEPS} và mọi bước trong cửa sổ đang CHẠY. Per-step
    thuần bị nhiễu blur chi phối (p95 31.8° ~ floor nhiễu — HANDOFF 23).

    Tầng 2 — speed-drop: median tốc độ 2 bước TRƯỚC vs 2 bước SAU rơi ≥
    SPEED_DROP_FRAC (bắt cú full-hit: cue dừng, không còn heading mà gãy).
    Median 2 bước mỗi bên vì nhiễu dọc hướng chạy làm tốc độ per-step
    lắc ±0.4 m/s — so per-step sẽ báo láo giữa đoạn lăn chậm.

    Tín hiệu cách nhau < COLLISION_MERGE_S gộp làm MỘT va chạm (một cú chạm
    thường kích cả hai tầng). Chỉ xét sau ``t_from`` (motion_start).

    Guard onset (BRIEF 25 bước 1.1): ứng viên kink có neo nằm trong quãng
    KINK_ONSET_MIN_M (2R) đầu tiên của track bị BỎ — quãng đo dọc track từ
    điểm track đầu tiên sau ``t_from`` (xem chú ở bảng hằng).
    """
    moving = speed > max(V_STILL_MPS, V_INIT_MPS / 2)
    events: list[tuple[float, int, str, float]] = []  # (t, idx điểm, kind, độ mạnh)

    # quãng đường tích lũy dọc track kể từ điểm đầu tiên sau motion_start
    i0 = int(np.searchsorted(t, t_from))
    cum = np.zeros(len(t))
    if i0 < len(t) - 1:
        seg = np.hypot(*np.diff(xy[i0:], axis=0).T)
        cum[i0 + 1:] = np.cumsum(seg)

    for i in range(len(heading) - 1):
        if t_mid[i] < t_from:
            continue
        for w in range(1, KINK_WIN_STEPS + 1):
            j = i + w
            if j >= len(heading) or not all(moving[i:j + 1]):
                continue
            dth = abs(_wrap_deg(heading[j] - heading[i]))
            if dth >= KINK_CUM_DEG:
                # neo tại điểm có bước đổi hướng đơn lớn nhất trong cửa sổ
                steps = [abs(_wrap_deg(heading[k + 1] - heading[k]))
                         for k in range(i, j)]
                k = i + 1 + int(np.argmax(steps))
                if cum[k] < KINK_ONSET_MIN_M:
                    continue        # heading trong 2R đầu là rác — bỏ ứng viên
                events.append((float(t[k]), k, "kink", dth))
                break

    for i in range(1, len(speed) - 2):
        if t_mid[i] < t_from or not moving[i]:
            continue
        before = float(np.median(speed[i - 1:i + 1]))
        after = float(np.median(speed[i + 1:i + 3]))
        if before >= SPEED_DROP_MIN_MPS and after <= (1 - SPEED_DROP_FRAC) * before:
            events.append((float(t[i + 1]), i + 1, "speed_drop",
                           1 - after / before))

    events.sort(key=lambda e: e[0])
    collisions: list[dict] = []
    for t_e, idx, kind, strength in events:
        if collisions and t_e - collisions[-1]["t_s"] < COLLISION_MERGE_S:
            c = collisions[-1]
            if kind not in c["kind"]:
                c["kind"] = c["kind"] + "+" + kind
            if kind == "kink":
                c["dtheta_deg"] = max(c.get("dtheta_deg") or 0.0, strength)
            else:
                c["drop_frac"] = max(c.get("drop_frac") or 0.0, strength)
            continue
        collisions.append({
            "t_s": t_e, "x_m": float(xy[idx, 0]), "y_m": float(xy[idx, 1]),
            "kind": kind,
            "dtheta_deg": strength if kind == "kink" else None,
            "drop_frac": strength if kind == "speed_drop" else None,
        })
    for c in collisions:
        c["t_s"] = round(c["t_s"], 3)
        c["x_m"] = round(c["x_m"], 4)
        c["y_m"] = round(c["y_m"], 4)
        if c["dtheta_deg"] is not None:
            c["dtheta_deg"] = round(c["dtheta_deg"], 1)
        if c["drop_frac"] is not None:
            c["drop_frac"] = round(c["drop_frac"], 3)
    return collisions


def _static_balls(others: list[dict], t_until: float) -> list[tuple[float, float]]:
    """Bi TĨNH trước lúc cue ball chạy: gom các detection KHÔNG-cue có
    ``t_s < t_until`` (cửa sổ trước motion_start) thành cụm theo khoảng cách
    tới median cụm, trả median (x, y) mỗi cụm đủ STATIC_MIN_DETS detection.
    Không cần BallID, không track bi mục tiêu (BRIEF 25 bước 2)."""
    clusters: list[list[tuple[float, float]]] = []
    for o in others:
        if float(o["t_s"]) >= t_until:
            continue
        p = (float(o["x_m"]), float(o["y_m"]))
        best = None
        for ci, cl in enumerate(clusters):
            med = np.median(np.asarray(cl), axis=0)
            d = math.hypot(p[0] - med[0], p[1] - med[1])
            if d < STATIC_CLUSTER_M and (best is None or d < best[0]):
                best = (d, ci)
        if best is None:
            clusters.append([p])
        else:
            clusters[best[1]].append(p)
    return [tuple(np.median(np.asarray(cl), axis=0))
            for cl in clusters if len(cl) >= STATIC_MIN_DETS]


def _classify_contacts(collisions: list[dict],
                       statics: list[tuple[float, float]],
                       t: np.ndarray | None = None,
                       xy: np.ndarray | None = None) -> None:
    """Gắn ``contact`` ∈ {cushion, ball, unknown} cho từng va chạm — heuristic
    vị trí (BRIEF 25 bước 2, thứ tự cushion → ball → unknown):
    cách mép băng (4 mép hệ bàn từ homography) ≤ CONTACT_CUSHION_M → cushion;
    cách bi tĩnh gần nhất ≤ CONTACT_BALL_M → ball; còn lại unknown.

    Neo kink lệch (BG26 bước 1.1 — Cowork chốt phương án (a) HANDOFF 25 câu
    2): với va chạm CÓ tín hiệu kink, khoảng cách tới mép băng lấy MIN trên
    ±2 điểm track quanh neo (``t``/``xy`` — chuỗi đã dedup). speed_drop giữ
    cách tính cũ (đo tại neo): neo drop không lệch hệ thống, nới cửa sổ chỉ
    tổ đọc nhầm cú chạm BI gần băng thành cushion. Khoảng cách tới BI TĨNH
    vẫn đo tại neo cho mọi loại.

    KẾT QUẢ ÂM đã đo (rerun BG26, HANDOFF 26): cửa sổ MIN một mình KHÔNG
    flip được 2 ca thật BG25 — chạm băng thật rơi GIỮA hai frame nên chính
    neo đã là điểm track gần mép nhất (cú 11: 1.56R, ±2 điểm quanh nó
    1.81–2.6R; cú 12 t=2.6: 1.53R).

    (b′) BG26b — Cowork chốt từ HANDOFF 26 câu 1: NGƯỠNG cushion cho ứng
    viên KINK thành ĐỘNG theo hình học lấy mẫu:

        thresh = CONTACT_CUSHION_M + v·dt/2
                 (= 1R tâm-bi-chạm-băng + 0.5R nhiễu/neo lệch + nửa quãng
                  bi đi được giữa hai frame)

    với v = tốc độ CHORD vào va chạm trên cửa sổ SPIN_PRE_S trước neo (cùng
    định nghĩa "hướng tới" của _read_spin), dt = khoảng frame MEDIAN của
    chuỗi đã dedup. Lý do hình học: bi chạm băng giữa hai lần lấy mẫu thì
    điểm sample gần mép nhất vẫn có thể cách thêm tới v·dt/2 (cú 11: 0.58
    m/s × 33ms/2 ≈ 10mm ≈ 0.34R — đúng phần hụt 1.6mm + dư). speed_drop
    GIỮ ngưỡng tĩnh CONTACT_CUSHION_M như cũ. Caveat CÓ CHỦ ĐÍCH (Cowork
    chấp nhận trong BRIEF): cú NHANH gãy hướng sát băng có thể bị gán nhầm
    cushion vì slack v·dt/2 lớn theo v — hệ quả của lấy mẫu thưa, không
    phải ngưỡng cào theo ca."""
    dt_med = (float(np.median(np.diff(t)))
              if t is not None and len(t) > 1 else 0.0)
    for c in collisions:
        x, y = c["x_m"], c["y_m"]
        d_rail = min(x, TABLE_W_M - x, y, TABLE_L_M - y)
        thresh = CONTACT_CUSHION_M
        if "kink" in c["kind"] and t is not None and len(t):
            k = int(np.argmin(np.abs(t - c["t_s"])))
            win = xy[max(0, k - 2):k + 3]
            d_rail = float(min(
                min(px, TABLE_W_M - px, py, TABLE_L_M - py)
                for px, py in win))
            i0 = int(np.searchsorted(t, c["t_s"] - SPIN_PRE_S - 1e-9))
            if i0 < k:
                v_in = (float(np.hypot(*(xy[k] - xy[i0])))
                        / max(float(t[k] - t[i0]), 1e-6))
                thresh += v_in * dt_med / 2.0
        if d_rail <= thresh:
            c["contact"] = "cushion"
        elif statics and min(math.hypot(x - sx, y - sy)
                             for sx, sy in statics) <= CONTACT_BALL_M:
            c["contact"] = "ball"
        else:
            c["contact"] = "unknown"


_RAILS = (  # (tên hiện cho người xem, pháp tuyến HƯỚNG VÀO BÀN n̂_in)
    ("băng dài trái", (1.0, 0.0)), ("băng dài phải", (-1.0, 0.0)),
    ("băng ngắn dưới", (0.0, 1.0)), ("băng ngắn trên", (0.0, -1.0)))


def _read_spin(t: np.ndarray, xy: np.ndarray, collisions: list[dict],
               motion_start: float):
    """Baseline spin giải tích thô → (spin_class, spin_confidence, evidence).

    ``spin_class``: "+"-ghép của trục dọc {follow, draw, stun} (đọc ở va
    chạm ĐẦU, phải là contact "ball", góc cắt không quá mỏng) và trục ngang
    {side-L, side-R} (đọc ở va chạm "cushion" đầu tiên, so hướng bật ổn
    định với phản xạ gương) — một cú có thể vừa dọc vừa ngang. Không trục
    nào đủ chứng cứ → (None, None, evidence-vì-sao) — văn hoá ``number:
    null`` của BallID, không đoán bừa.

    Quy ước dấu side (nghiệm thu bằng probe han2005, khớp bẫy spin đã trả
    giá "a=+1 là mép TRÁI bi"): đánh mép trái → ωz < 0 (kim đồng hồ nhìn
    từ trên) → thành phần tiếp tuyến sau bật bị KÉO theo −(ẑ×n̂_in);
    suy ngược sign(ωz) = sign((û_out − û_gương)·(ẑ×n̂_in)); ωz<0 = side-L.

    Ép confidence theo tốc độ (BG26 bước 1.2): trục nào đọc ở va chạm mà
    tốc độ chord TỚI va chạm < SPEED_DROP_MIN_MPS thì confidence của trục
    đó bị ÉP "low" bất kể luật hình học nói gì — dưới sàn đó nhiễu dọc
    hướng chạy (±13mm ≈ ±0.4 m/s/bước) chi phối chính tín hiệu đang đọc
    (bài học cú 11 lăn chậm). Evidence ghi rõ khi ép để FE/eval khỏi đoán.

    Evidence: mỗi dòng {axis, t_s, deg|m, text} — text tiếng Việt FE nối
    hiện thẳng, số cho máy (BG26 đối chiếu baseline không phải parse text).
    """
    ev: list[dict] = []
    parts: list[tuple[str, str]] = []          # (lớp, confidence)

    def note(axis, text, t_s=None, deg=None, m=None):
        d = {"axis": axis, "text": text}
        if t_s is not None:
            d["t_s"] = round(float(t_s), 3)
        if deg is not None:
            d["deg"] = round(float(deg), 1)
        if m is not None:
            d["m"] = round(float(m), 3)
        ev.append(d)

    if not collisions:
        note("vertical", "cú không có va chạm nào — spin chỉ lộ qua va chạm, "
                         "không xác định được là ĐÁP ÁN đúng (§3.2 design)")
        return None, None, ev

    def pts(t_a, t_b):
        return np.flatnonzero((t >= t_a - 1e-9) & (t <= t_b + 1e-9))

    def unit(v):
        n = float(np.hypot(v[0], v[1]))
        return (v / n if n > 1e-9 else None), n

    def ang(u, v):
        return float(np.degrees(np.arccos(np.clip(float(np.dot(u, v)),
                                                  -1.0, 1.0))))

    def force_slow(axis, n0, v_loc):
        """BG26 bước 1.2: trục vừa đọc ra part (len(parts) > n0) mà tốc độ
        chord tới va chạm dưới sàn SPEED_DROP_MIN_MPS → ép confidence "low"
        + evidence nói rõ vì sao (kể cả khi luật hình học đã cho low)."""
        if (len(parts) > n0 and v_loc is not None
                and v_loc < SPEED_DROP_MIN_MPS):
            parts[-1] = (parts[-1][0], "low")
            note(axis, f"tốc độ quanh va chạm {v_loc:.2f} m/s dưới sàn "
                 f"{SPEED_DROP_MIN_MPS:g} m/s — vùng nhiễu dọc hướng chạy "
                 "chi phối, confidence ép low")

    # ---------------- trục DỌC: follow / draw / stun ở va chạm ĐẦU
    c0 = collisions[0]
    t0 = float(c0["t_s"])
    i_c = int(np.argmin(np.abs(t - t0)))
    t_next = float(collisions[1]["t_s"]) if len(collisions) > 1 else np.inf
    t_end = min(t0 + SPIN_WIN_S, t_next)
    dth = float(c0.get("dtheta_deg") or 0.0)
    has_drop = "speed_drop" in c0["kind"]

    pre = pts(max(motion_start, t0 - SPIN_PRE_S), t0)
    u_pre, pre_len = (unit(xy[i_c] - xy[pre[0]]) if len(pre) >= 2
                      else (None, 0.0))
    v_pre = (pre_len / max(float(t[i_c] - t[pre[0]]), 1e-6)
             if len(pre) >= 2 else None)
    n_vert = len(parts)

    if c0.get("contact") != "ball":
        note("vertical", f"va chạm đầu là {c0.get('contact') or 'unknown'}, "
             "không phải bi — không đọc được xoáy dọc", t_s=t0)
    elif u_pre is None or pre_len < SPIN_MIN_CHORD_M:
        note("vertical", "không đủ track trước va chạm để lấy hướng tới",
             t_s=t0)
    elif dth >= SPIN_DRAW_KINK_DEG:
        parts.append(("draw", "medium" if has_drop else "low"))
        note("vertical", f"sau chạm bi đường đi quay ngược Δθ {dth:.0f}° — "
             "tangent cú cắt tối đa 90°, chỉ backspin kéo về được → draw",
             t_s=t0, deg=dth)
    elif dth >= SPIN_VERT_KINK_MIN_DEG:
        tan = pts(t0, t0 + SPIN_SETTLE_S)
        st = pts(t0 + SPIN_SETTLE_S, t_end)
        u_tan, tan_len = (unit(xy[tan[-1]] - xy[i_c]) if len(tan) >= 2
                          else (None, 0.0))
        u_set, set_len = (unit(xy[st[-1]] - xy[st[0]]) if len(st) >= 2
                          else (None, 0.0))
        if (u_tan is None or tan_len < SPIN_MIN_CHORD_M
                or u_set is None or set_len < SPIN_MIN_CHORD_M):
            note("vertical", "không đủ track sau va chạm bi để đo đường "
                 "cong trượt→lăn", t_s=t0)
        else:
            bend = ang(u_tan, u_pre) - ang(u_set, u_pre)
            cls = ("follow" if bend >= SPIN_BEND_MIN_DEG else
                   "draw" if bend <= -SPIN_BEND_MIN_DEG else "stun")
            parts.append((cls, "low"))
            mo_ta = ("cong VỀ hướng tới cũ" if cls == "follow" else
                     "cong RA XA hướng tới cũ" if cls == "draw" else
                     "giữ nguyên tangent")
            note("vertical", f"cú cắt (Δθ {dth:.0f}°): sau pha trượt đường "
                 f"đi {mo_ta} {bend:+.0f}° → {cls}", t_s=t0, deg=bend)
    elif has_drop:
        post = pts(t0, t_end)
        if len(post) < 2:
            note("vertical", "không đủ track sau va chạm bi", t_s=t0)
        else:
            d_net = xy[post[-1]] - xy[i_c]
            dist = float(np.hypot(d_net[0], d_net[1]))
            proj = float(np.dot(d_net, u_pre))
            if dist < SPIN_STUN_MAX_M:
                parts.append(("stun", "medium"))
                note("vertical", f"cú chạm dày: cue dừng/trôi ngắn "
                     f"{dist * 100:.0f}cm → stun", t_s=t0, m=dist)
            elif proj >= SPIN_DIR_MIN_M:
                parts.append(("follow", "medium"))
                note("vertical", f"cú chạm dày: cue tiếp tục TIẾN dọc hướng "
                     f"cũ {proj:.2f}m → follow", t_s=t0, m=proj)
            elif proj <= -SPIN_DIR_MIN_M:
                parts.append(("draw", "medium"))
                note("vertical", f"cú chạm dày: cue LÙI ngược hướng cũ "
                     f"{-proj:.2f}m → draw", t_s=t0, m=-proj)
            else:
                parts.append(("stun", "low"))
                note("vertical", f"cú chạm dày: cue trôi {dist:.2f}m gần "
                     "vuông góc hướng cũ (tangent) → stun", t_s=t0, m=dist)
    else:
        note("vertical", f"góc cắt quá mỏng (Δθ {dth:.0f}° < "
             f"{SPIN_VERT_KINK_MIN_DEG:.0f}°) — xoáy dọc không lộ ra "
             "quỹ đạo, không đọc", t_s=t0, deg=dth)
    force_slow("vertical", n_vert, v_pre)

    # ---------------- trục NGANG: side-L/R ở va chạm BĂNG đầu tiên
    cc = next((c for c in collisions if c.get("contact") == "cushion"), None)
    if cc is None:
        note("lateral", "không có va chạm băng — không đọc được side spin")
    else:
        tc = float(cc["t_s"])
        j = int(np.argmin(np.abs(t - tc)))
        k = collisions.index(cc)
        t_nx = (float(collisions[k + 1]["t_s"]) if k + 1 < len(collisions)
                else np.inf)
        d_rails = (cc["x_m"], TABLE_W_M - cc["x_m"],
                   cc["y_m"], TABLE_L_M - cc["y_m"])
        rail_name, n_in = _RAILS[int(np.argmin(d_rails))]
        n = np.asarray(n_in)
        t_hat = np.array([-n[1], n[0]])        # ẑ × n̂_in

        pre_c = pts(max(motion_start, tc - SPIN_PRE_S), tc)
        u_in, in_len = (unit(xy[j] - xy[pre_c[0]]) if len(pre_c) >= 2
                        else (None, 0.0))
        v_in_c = (in_len / max(float(t[j] - t[pre_c[0]]), 1e-6)
                  if len(pre_c) >= 2 else None)
        n_lat = len(parts)
        st = pts(tc + SPIN_SETTLE_S, min(tc + SPIN_WIN_S, t_nx))
        u_out, out_len = (unit(xy[st[-1]] - xy[st[0]]) if len(st) >= 2
                          else (None, 0.0))
        if (u_in is None or in_len < SPIN_MIN_CHORD_M
                or u_out is None or out_len < SPIN_MIN_CHORD_M):
            note("lateral", f"bật {rail_name}: không đủ track hai phía để "
                 "so với phản xạ gương", t_s=tc)
        else:
            u_mir = u_in - 2.0 * float(np.dot(u_in, n)) * n
            dev = float(np.degrees(math.atan2(
                u_mir[0] * u_out[1] - u_mir[1] * u_out[0],
                float(np.dot(u_mir, u_out)))))
            if abs(dev) < SIDE_DEV_MIN_DEG:
                note("lateral", f"bật {rail_name}: lệch phản xạ gương "
                     f"{dev:+.0f}° dưới ngưỡng {SIDE_DEV_MIN_DEG:.0f}° "
                     "(bi lăn không side đã lệch ~15°) — không kết luận",
                     t_s=tc, deg=dev)
            else:
                cls = ("side-R" if float(np.dot(u_out - u_mir, t_hat)) > 0
                       else "side-L")
                conf = "medium" if abs(dev) >= SIDE_DEV_MED_DEG else "low"
                parts.append((cls, conf))
                note("lateral", f"bật {rail_name} lệch phản xạ gương "
                     f"{dev:+.0f}° → {cls}", t_s=tc, deg=dev)
        force_slow("lateral", n_lat, v_in_c)

    if not parts:
        return None, None, ev
    spin_class = "+".join(p[0] for p in parts)
    conf = "low" if any(p[1] == "low" for p in parts) else "medium"
    return spin_class, conf, ev


def analyze_track(rows: list[dict], others: list[dict] | None = None) -> dict:
    """Tầng giải tích: track cue ball (mọi frame, cờ covered) → số + cảnh báo.

    ``rows``: dict per frame — ``frame_file, t_s, covered, table_x_m,
    table_y_m, img_diff`` (str từ CSV hay số từ pipeline đều nhận).
    ``others``: detection KHÔNG-cue rời rạc ``{t_s, x_m, y_m}`` (mọi frame,
    không cần track) — chỉ dùng cửa sổ trước motion_start để dựng bi tĩnh
    cho phân loại ``contact``; None/[] thì va chạm giữa bàn thành "unknown"
    (đường CSV cũ không có detection bi khác vẫn chạy nguyên). Trả dict
    JSON-thuần: ``metrics`` (V0/φ/coverage/...), ``collisions``,
    ``spin_class``/``spin_confidence``/``spin_evidence`` (baseline spin thô
    — xem ``_read_spin``), ``track`` (điểm covered ĐÃ loại frame trùng —
    toạ độ THÔ, không smooth: smooth là việc của FE và CHỈ để vẽ),
    ``warnings`` (tiếng Việt, FE hiện thẳng).

    V0 = median tốc độ trong cửa sổ V0_WINDOW_S sau motion_start, CẮT tại va
    chạm đầu nếu nó tới sớm hơn (không trộn tốc độ trước/sau chạm).
    φ = hướng dây cung điểm đầu→cuối của chính cửa sổ đó (chord chịu nhiễu
    per-step tốt hơn heading từng bước).
    """
    if not rows:
        raise ValueError("Track rỗng — không có frame nào để phân tích.")

    names = [str(r["frame_file"]) for r in rows]
    t_all = np.array([float(r["t_s"]) for r in rows])
    covered = np.array([str(r.get("covered", "0")) in ("1", "True", "true")
                        for r in rows])
    diffs = [float(r.get("img_diff", -1.0)) for r in rows]

    dups = find_dup_frames(names, diffs)
    keep = [i for i in range(len(rows))
            if covered[i] and names[i] not in dups]
    t = t_all[keep]
    xy = np.array([[float(rows[i]["table_x_m"]), float(rows[i]["table_y_m"])]
                   for i in keep]) if keep else np.empty((0, 2))

    # coverage theo THỜI LƯỢNG (số gate G0 — measure_p0, giữ nguyên cách đo)
    dt_med = float(np.median(np.diff(t_all))) if len(t_all) > 1 else 1 / 30
    dur = float(t_all[-1] - t_all[0]) + dt_med
    dts = np.append(np.diff(t_all), dt_med)
    coverage = float(dts[covered].sum() / dur) if dur > 0 else 0.0
    gaps = np.diff(t) if len(t) > 1 else np.array([])
    max_gap = float(gaps.max()) if len(gaps) else 0.0

    v0 = phi = motion_start = None
    n_v0_steps = 0
    collisions: list[dict] = []
    if len(t) >= 3:
        t_mid, speed, heading = _speed_series(t, xy)
        motion_start = _find_motion_start(t_mid, speed)
        if motion_start is not None:
            collisions = _find_collisions(t, xy, t_mid, speed, heading,
                                          motion_start)
            _classify_contacts(collisions,
                               _static_balls(others or [], motion_start),
                               t, xy)
            win_end = motion_start + V0_WINDOW_S
            if collisions:
                win_end = min(win_end, collisions[0]["t_s"])
            in_win = (t_mid >= motion_start) & (t_mid <= win_end)
            n_v0_steps = int(in_win.sum())
            if n_v0_steps:
                v0 = float(np.median(speed[in_win]))
            pts = (t >= motion_start - 1e-9) & (t <= win_end + 1e-9)
            if pts.sum() >= 2:
                p = xy[pts]
                d = p[-1] - p[0]
                if np.hypot(*d) > 1e-6:
                    phi = float(np.degrees(np.arctan2(d[1], d[0])) % 360.0)

    # baseline spin thô (BRIEF 25 bước 3) — không va chạm/không motion thì
    # helper tự trả null + lý do (unidentifiable là đáp án, không phải lỗi)
    spin_class, spin_conf, spin_ev = _read_spin(
        t, xy, collisions,
        motion_start if motion_start is not None else 0.0)

    warnings: list[str] = []
    if coverage < COVERAGE_WARN:
        warnings.append(
            f"Track chỉ phủ {coverage:.0%} thời lượng — thường do người che "
            f"cue ball; số đo vẫn dùng được nhưng đọc kèm dè dặt.")
    if max_gap > GAP_WARN_S:
        warnings.append(f"Track đứt dài nhất {max_gap:.2f}s — quỹ đạo đoạn "
                        f"đó là nối thẳng, không phải đo.")
    if motion_start is None:
        warnings.append("Không thấy lúc cue ball bắt đầu chạy — clip có đúng "
                        "1 cú trọn (mọi bi đứng yên → đánh → đứng yên)?")
    elif v0 is None:
        warnings.append("Không đủ frame sau lúc bắt đầu chạy để đo V0.")
    else:
        if n_v0_steps < 2:
            warnings.append("V0 ước lượng từ dưới 2 bước frame — kém tin.")
        if not (V0_SANE_MPS[0] <= v0 <= V0_SANE_MPS[1]):
            warnings.append(f"V0 = {v0:.2f} m/s ngoài khoảng tin được "
                            f"{V0_SANE_MPS[0]:g}{V0_SANE_MPS[1]:g} m/s.")

    return {
        "metrics": {
            "v0_mps": round(v0, 3) if v0 is not None else None,
            "phi_deg": round(phi, 1) if phi is not None else None,
            "motion_start_s": (round(motion_start - float(t_all[0]), 3)
                               if motion_start is not None else None),
            "n_collisions": len(collisions),
            "coverage": round(coverage, 4),
            "max_gap_s": round(max_gap, 3),
            "n_frames": len(rows),
            "n_covered": int(covered.sum()),
            "n_dup_frames": len(dups),
            "duration_s": round(dur, 3),
            "table_w_m": TABLE_W_M,
            "table_l_m": TABLE_L_M,
        },
        "collisions": collisions,
        # spin baseline (BRIEF 25): null = không đọc được — evidence nói vì
        # sao; KHÔNG bịa lớp khi thiếu chứng cứ (văn hoá number: null)
        "spin_class": spin_class,
        "spin_confidence": spin_conf,
        "spin_evidence": spin_ev,
        "track": [{"t_s": round(float(ti - t_all[0]), 3),
                   "x_m": round(float(x), 4), "y_m": round(float(y), 4)}
                  for ti, (x, y) in zip(t, xy)],
        "warnings": warnings,
    }


def track_cue(meta: list[tuple[str, float]], dets_by_frame: dict,
              diffs: dict[str, float]) -> list[dict]:
    """NN + gating vận tốc trên det Cue — track_p0.track_cue, nhấc nguyên
    (chỉ đổi input: dict per-frame thay vì list det phẳng)."""
    rows = []
    last_pos = None
    last_t = None
    for name, t in meta:
        cands = dets_by_frame.get(name, [])
        chosen = None
        if cands:
            if last_pos is None:
                chosen = max(cands, key=lambda d: d["conf"])
            else:
                dt = max(t - last_t, 1e-3)
                gate = VMAX_MPS * dt + GATE_SLACK_M
                best = None
                for d in cands:
                    dist = float(np.hypot(d["table_x_m"] - last_pos[0],
                                          d["table_y_m"] - last_pos[1]))
                    if dist <= gate and (best is None or dist < best[0]):
                        best = (dist, d)
                chosen = best[1] if best else None
        if chosen is not None:
            last_pos = (chosen["table_x_m"], chosen["table_y_m"])
            last_t = t
        rows.append({
            "frame_file": name, "t_s": t,
            "covered": 1 if chosen is not None else 0,
            "table_x_m": chosen["table_x_m"] if chosen else "",
            "table_y_m": chosen["table_y_m"] if chosen else "",
            "img_diff": round(diffs.get(name, -1.0), 4),
        })
    return rows


# --------------------------------------- front-end clip (cv2/YOLO — lười)

ANALYZE_MAX_DUR_S = 30.0     # 1 cú trọn — quá là clip cắt sai (BRIEF bước 2.1)


def analyze_clip(video_path: str | Path, corners_px, model, *,
                 op_conf: float, imgsz: int = 640, device=None,
                 progress=None) -> dict:
    """MỘT clip 1 cú → detect + track + số giải tích. Chạy phía cv-env.

    ``corners_px``: 4 pocket GÓC pixel trên frame clip, thứ tự quy ước
    ``table_corners()`` (2 điểm đầu = một băng ngắn); chấm ngược chiều tự đảo
    (``orient_corners``). ``model``: YOLO đã load (worker giữ, load một lần).
    ``progress(stage: str, frac: float)`` gọi mỗi batch — worker dùng để cập
    nhật status + beat heartbeat trong job dài.

    Decode TRONG BỘ NHỚ, không ghi frame ra đĩa (clip upload + frame đều
    KHÔNG được đọng lại — ràng buộc bản quyền BRIEF #7); timestamp là PTS
    container thật (CAP_PROP_POS_MSEC — nếp extract_shots), img_diff tính
    trên vùng bàn như track_p0.frame_diffs.

    Ném ValueError cho mọi lỗi thuộc về input (clip hỏng/quá dài/corners suy
    biến) — transport trả 422/error validation, nếp cv_worker.
    """
    import cv2

    corners = np.asarray(corners_px, dtype=np.float64)
    if corners.shape != (4, 2):
        raise ValueError("corners phải là 4 điểm [x, y] pixel — chấm đủ "
                         "4 pocket góc.")
    th = TableHomography(orient_corners(corners),
                         table_w=TABLE_W_M, table_l=TABLE_L_M)

    cap = cv2.VideoCapture(str(video_path))
    if not cap.isOpened():
        raise ValueError("Không mở được clip — cần MP4/MOV (H.264) đọc được.")
    try:
        fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
        n_est = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0
        if fps > 0 and n_est > 0 and n_est / fps > ANALYZE_MAX_DUR_S + 0.5:
            raise ValueError(f"Clip dài {n_est / fps:.1f}s — giới hạn "
                             f"{ANALYZE_MAX_DUR_S:g}s (đúng 1 cú trọn).")

        # bbox vùng bàn cho img_diff (frame_diffs của track_p0)
        ci = th.corners_px.astype(int)
        x0, x1 = ci[:, 0].min(), ci[:, 0].max()
        y0, y1 = ci[:, 1].min(), ci[:, 1].max()

        meta: list[tuple[str, float]] = []
        diffs: dict[str, float] = {}
        dets_by_frame: dict[str, list[dict]] = {}
        first_frame_dets: list[dict] = []
        other_dets: list[dict] = []      # detection KHÔNG-cue mọi frame —
                                         # nguồn bi tĩnh cho contact (bước 2)
        prev_crop = None
        t_first = None
        batch_imgs: list = []
        batch_names: list[str] = []
        total = int(n_est) if n_est > 0 else 0

        def flush_batch() -> None:
            if not batch_imgs:
                return
            kw = {"device": device} if device is not None else {}
            results = model.predict(batch_imgs, conf=op_conf, imgsz=imgsz,
                                    verbose=False, **kw)
            for name, res in zip(batch_names, results):
                names_map = res.names
                for b in res.boxes:
                    cls = names_map[int(b.cls)]
                    if cls not in BALL_CLASSES:
                        continue
                    bx1, by1, bx2, by2 = (float(v) for v in b.xyxy[0])
                    cx, cy = (bx1 + bx2) / 2.0, (by1 + by2) / 2.0
                    tx, ty = th.px_to_table((cx, cy))
                    if not (-IN_TOL_M <= tx <= TABLE_W_M + IN_TOL_M
                            and -IN_TOL_M <= ty <= TABLE_L_M + IN_TOL_M):
                        continue
                    d = {"cls": cls, "conf": float(b.conf),
                         "table_x_m": float(tx), "table_y_m": float(ty)}
                    if name == meta[0][0]:
                        first_frame_dets.append(d)
                    if cls == "Cue":
                        dets_by_frame.setdefault(name, []).append(d)
                    else:
                        other_dets.append({"t_s": meta[int(name)][1],
                                           "x_m": d["table_x_m"],
                                           "y_m": d["table_y_m"]})
            batch_imgs.clear()
            batch_names.clear()

        while True:
            ok = cap.grab()
            if not ok:
                break
            t = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
            if t_first is None:
                t_first = t
            if t - t_first > ANALYZE_MAX_DUR_S + 0.5:
                raise ValueError(f"Clip dài quá {ANALYZE_MAX_DUR_S:g}s "
                                 f"(container khai sai số frame) — cắt lại "
                                 f"đúng 1 cú trọn.")
            ok, frame = cap.retrieve()
            if not ok:
                break
            name = f"{len(meta):05d}"
            crop = frame[max(0, y0):max(1, y1), max(0, x0):max(1, x1)]
            gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.int16)
            diffs[name] = (float(np.abs(gray - prev_crop).mean())
                           if prev_crop is not None else -1.0)
            prev_crop = gray
            meta.append((name, t))
            batch_imgs.append(frame)
            batch_names.append(name)
            if len(batch_imgs) >= DETECT_BATCH:
                flush_batch()
                if progress is not None:
                    frac = min(len(meta) / total, 1.0) if total else 0.0
                    progress("detect", 0.05 + 0.75 * frac)
        flush_batch()
    finally:
        cap.release()

    if len(meta) < 5:
        raise ValueError("Clip quá ngắn hoặc không đọc được frame nào — "
                         "cần 1 cú trọn vài giây.")

    if progress is not None:
        progress("track", 0.85)
    rows = track_cue(meta, dets_by_frame, diffs)

    if progress is not None:
        progress("analytics", 0.95)
    out = analyze_track(rows, others=other_dets)

    # thế bi TĨNH frame đầu cho table view (dedup double-detect, nếp cv_worker)
    balls_init: list[dict] = []
    for d in sorted(first_frame_dets, key=lambda d: -d["conf"]):
        if any(math.hypot(d["table_x_m"] - k["x_m"], d["table_y_m"] - k["y_m"])
               < BALL_DEDUP_M for k in balls_init):
            continue
        balls_init.append({"x_m": round(d["table_x_m"], 4),
                           "y_m": round(d["table_y_m"], 4),
                           "type": "cue" if d["cls"] == "Cue" else "ball",
                           "conf": round(d["conf"], 3)})
    out["balls_init"] = balls_init
    return out