File size: 23,705 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
"""Shot segmentation — cắt video dài thành từng cú (lát A1, BRIEF 13/08/2026).

Tầng "định nghĩa cú" của design Analyzer đa cú (§4 PoolCoach_Analyzer_
DemoApp_Design.md): chạy trên TRACK THÔ toàn video (detect + nối NN, trước
verifier), cắt ranh giới cú theo đúng chu trình **mọi bi đứng yên → cue
chuyển động → mọi bi đứng yên**.

Hai tầng, cùng nếp ``broadcast.py``:

- ``segment_shots``  — lõi cắt ranh giới, THUẦN NUMPY (unit test chạy trên
  venv app, không cv2/torch). Input là track cue (``track_cue`` của
  broadcast) + detection KHÔNG-cue per frame.
- ``segment_video``  — front-end trọn gói cho MỘT video dài: decode PTS thật
  + img_diff, YOLO detect từng frame, track cue, rồi ``segment_shots``.
  Import cv2 LƯỜI trong thân hàm (nếp analyze_clip).

Ngưỡng VẬT LÝ dùng NGUYÊN bộ đã chốt 11/08 (BRIEF A1: "dùng nguyên, ĐỪNG
chế ngưỡng mới") — tất cả import từ ``broadcast.py``, không định nghĩa lại:
đứng yên ``V_STILL_MPS`` 55 mm/s (p99 bi tĩnh P0); onset ``V_INIT_MPS``
0.25 m/s × ``V_INIT_RUN`` 3 bước (measure_p0); gating track ``VMAX_MPS``
11 m/s theo dt timestamp thật. Các hằng MỚI ở đây đều là hằng CẤU TRÚC
(cửa sổ thời gian, đệm biên) — không phải ngưỡng đo vật lý mới; nguồn ghi
tại chỗ từng hằng, khai trong HANDOFF A1 để Cowork duyệt.

Quy ước output: mỗi cú một dict, thời gian TUYỆT ĐỐI theo PTS video —
``t_start_s``/``t_end_s`` là biên ĐÃ đệm (cắt clip theo cặp này);
``t_onset_s``/``t_settle_s`` là mốc thô (cue bắt đầu chạy / mọi bi yên trở
lại) để đối chiếu đếm tay. ``status`` ∈ {"ok", "error"}; cú "error" vẫn
NẰM TRONG danh sách kèm ``reason`` tiếng Việt (scope §5 design: đổi cảnh
giữa cú phải báo tử tế, không nuốt im).
"""

from __future__ import annotations

import math
from pathlib import Path

import numpy as np

from poolcoach_cv.broadcast import (ANALYZE_MAX_DUR_S, BALL_CLASSES,
                                    DETECT_BATCH, GATE_SLACK_M, IN_TOL_M,
                                    TABLE_L_M, TABLE_W_M, V_INIT_MPS,
                                    V_INIT_RUN, V_STILL_MPS, VMAX_MPS,
                                    find_dup_frames, track_cue)
from poolcoach_cv.homography import TableHomography, orient_corners

# ---------------------------------------------------- hằng CẤU TRÚC (mới)
# Không hằng nào dưới đây là ngưỡng ĐO vật lý — ngưỡng đo dùng nguyên bộ
# chốt 11/08 import từ broadcast (xem docstring module).

STILL_WIN_S = 0.5     # cửa sổ CHORD đo "đứng yên": bi tĩnh jitter ~2mm/frame
                      # (p99 apparent speed 55mm/s ⇒ ~1.8mm ở dt 33ms) nên
                      # chord 2 đầu cửa sổ 0.5s của bi tĩnh ≪ 55mm/s ×
                      # 0.5s = 27.5mm — chord giết nhiễu per-step mà không
                      # cần ngưỡng mới; bi lăn chậm 0.1 m/s đi 50mm > 27.5mm
                      # vẫn bị bắt là đang chạy.
BLIND_MAX_S = 1.0     # không thấy BI NÀO (cue lẫn thường) liên tục quá mức
                      # này = mất cảnh (replay/đổi góc máy) — cú đang mở
                      # đóng lại thành "error", không cố nối track xuyên
                      # cảnh cắt (scope §5 design).
PAD_PRE_S = 0.5       # đệm TRƯỚC onset khi cắt clip: analyze cần cửa sổ
                      # tĩnh trước cú — ``_static_balls`` đòi ≥ 3 detection
                      # mỗi cụm (STATIC_MIN_DETS) và ``balls_init`` lấy
                      # frame đầu; 0.5s ≈ 15 frame @30fps là đủ dư. Clamp
                      # về t_settle cú trước nên không bao giờ nuốt đuôi
                      # chuyển động của cú trước.
PAD_POST_S = 0.5      # đệm SAU t_settle: giữ trọn pha lăn chậm cuối cú cho
                      # analyze; clamp về onset cú sau.
SHOT_MAX_S = ANALYZE_MAX_DUR_S
                      # cú mở quá mức trần clip analyze (30s) mà chưa đóng
                      # được = ranh giới hỏng (track đứt kéo dài / bi không
                      # bao giờ yên) → đóng thành "error" thay vì đẻ clip
                      # mà /api/analyze từ chối.

LY_DO_MAT_CANH = ("mất cảnh giữa cú — không thấy bi nào trên bàn quá "
                  f"{BLIND_MAX_S:g}s (video đổi góc/replay); không nối "
                  "track xuyên cảnh cắt")
LY_DO_QUA_DAI = (f"cú mở quá {SHOT_MAX_S:g}s mà mọi bi chưa đứng yên trở "
                 "lại — ranh giới không đóng được (track đứt kéo dài hoặc "
                 "cảnh không phải một cú trọn)")
LY_DO_CUT_CUOI_VIDEO = ("video kết thúc giữa cú — không thấy lúc mọi bi "
                        "đứng yên trở lại")


# ------------------------------------------------- tracklet bi (không-cue)

def _build_tracklets(frames: list[tuple[str, float]],
                     others_by_frame: dict[str, list[tuple[float, float]]]
                     ) -> list[list[tuple[float, float, float]]]:
    """Nối detection KHÔNG-cue thành tracklet bằng NN per-step + gating
    VMAX theo dt thật (đúng luật track_cue — không thêm luật mới). Greedy
    theo khoảng cách tăng dần; det không nối được mở tracklet mới. Trả
    list tracklet, mỗi cái là list (t, x, y).

    Mục đích DUY NHẤT là đo "mọi bi đứng yên" — không phải BallID, không
    cần bền qua che khuất: tracklet đứt thì mở cái mới, chord đo trên từng
    mảnh vẫn nói đúng chuyện bi đang chạy hay đứng.
    """
    tracklets: list[list[tuple[float, float, float]]] = []
    open_idx: list[int] = []          # tracklet còn "sống" ở frame trước
    prev_t: float | None = None
    for name, t in frames:
        dets = others_by_frame.get(name, [])
        if not dets:
            # frame không có det không-cue: tracklet nào cũng khép — bi
            # mất dấu một frame sẽ mở tracklet mới, đủ cho mục đích đo.
            open_idx = []
            prev_t = t
            continue
        if not open_idx or prev_t is None:
            open_idx = []
            for x, y in dets:
                tracklets.append([(t, x, y)])
                open_idx.append(len(tracklets) - 1)
            prev_t = t
            continue
        dt = max(t - prev_t, 1e-3)
        gate = VMAX_MPS * dt + GATE_SLACK_M
        pairs = []                    # (dist, det_i, tracklet_j)
        for i, (x, y) in enumerate(dets):
            for j in open_idx:
                lx, ly = tracklets[j][-1][1], tracklets[j][-1][2]
                d = math.hypot(x - lx, y - ly)
                if d <= gate:
                    pairs.append((d, i, j))
        pairs.sort(key=lambda p: p[0])
        used_det: set[int] = set()
        used_trk: set[int] = set()
        next_open: list[int] = []
        for d, i, j in pairs:
            if i in used_det or j in used_trk:
                continue
            used_det.add(i)
            used_trk.add(j)
            tracklets[j].append((t, dets[i][0], dets[i][1]))
            next_open.append(j)
        for i, (x, y) in enumerate(dets):
            if i not in used_det:
                tracklets.append([(t, x, y)])
                next_open.append(len(tracklets) - 1)
        open_idx = next_open
        prev_t = t
    return tracklets


def _moving_times(tracklets, win_s: float) -> np.ndarray:
    """Mốc thời gian mà MỘT bi không-cue nào đó đang CHẠY: điểm tracklet có
    chord-speed nhìn lùi ``win_s`` vượt V_STILL. Trả mảng t đã sort."""
    out: list[float] = []
    for trk in tracklets:
        if len(trk) < 2:
            continue
        ts = np.array([p[0] for p in trk])
        xy = np.array([[p[1], p[2]] for p in trk])
        for i in range(1, len(trk)):
            j = int(np.searchsorted(ts, ts[i] - win_s, side="left"))
            j = min(j, i - 1)
            span = float(ts[i] - ts[j])
            if span <= 1e-6:
                continue
            chord = float(np.hypot(*(xy[i] - xy[j])))
            if chord / span > V_STILL_MPS:
                out.append(float(ts[i]))
    return np.asarray(sorted(out))


# ------------------------------------------------------------- lõi cắt cú

def segment_shots(rows: list[dict],
                  others: list[dict] | None = None) -> dict:
    """Cắt ranh giới cú trên track thô toàn video.

    ``rows``: track cue per frame (``track_cue`` broadcast — frame_file,
    t_s, covered, table_x_m, table_y_m, img_diff; str từ CSV hay số đều
    nhận). ``others``: detection KHÔNG-cue per frame ``{frame_file, t_s,
    x_m, y_m}`` — cần cho vế "MỌI bi đứng yên"; None/[] thì chỉ còn cue
    làm chứng (vẫn chạy, kèm warning).

    Trả dict JSON-thuần::

        {"shots": [{"idx", "t_start_s", "t_end_s", "t_onset_s",
                    "t_settle_s", "status", "reason"}...],
         "warnings": [...], "n_frames", "n_dup_frames", "duration_s"}

    Chu trình trạng thái: chờ MỌI bi đứng yên (ARMED) → cue chạy
    ``V_INIT_RUN`` bước liên tiếp > ``V_INIT_MPS`` (onset, đúng luật
    ``_find_motion_start``) → mở cú → mọi bi yên lại trọn cửa sổ
    ``STILL_WIN_S`` (settle) → đóng cú, quay lại ARMED. Mất cảnh
    (``BLIND_MAX_S`` không thấy bi nào) hay cú mở quá ``SHOT_MAX_S`` →
    đóng thành "error" kèm lý do, KHÔNG nuốt im.
    """
    if not rows:
        raise ValueError("Track rỗng — không có frame nào để cắt cú.")

    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 names[i] not in dups]

    frames = [(names[i], float(t_all[i])) for i in keep]
    t_frames = np.array([t for _n, t in frames])

    # --- cue: chuỗi điểm covered (đã bỏ frame trùng) + speed per-step
    cue_idx = [i for i in keep if covered[i]]
    t_cue = t_all[cue_idx]
    xy_cue = (np.array([[float(rows[i]["table_x_m"]),
                         float(rows[i]["table_y_m"])] for i in cue_idx])
              if cue_idx else np.empty((0, 2)))
    if len(t_cue) >= 2:
        dt_c = np.diff(t_cue)
        dxy_c = np.diff(xy_cue, axis=0)
        v_cue = np.hypot(dxy_c[:, 0], dxy_c[:, 1]) / np.maximum(dt_c, 1e-6)
        t_cue_step = t_cue[1:]            # mốc CUỐI mỗi bước
    else:
        v_cue = np.empty(0)
        t_cue_step = np.empty(0)

    # chord-speed nhìn lùi của cue (cùng thước với bi thường) cho vế settle
    cue_moving_t: list[float] = []
    for i in range(1, len(t_cue)):
        j = int(np.searchsorted(t_cue, t_cue[i] - STILL_WIN_S, side="left"))
        j = min(j, i - 1)
        span = float(t_cue[i] - t_cue[j])
        if span <= 1e-6:
            continue
        if float(np.hypot(*(xy_cue[i] - xy_cue[j]))) / span > V_STILL_MPS:
            cue_moving_t.append(float(t_cue[i]))
    cue_moving = np.asarray(cue_moving_t)

    # --- bi không-cue: tracklet + mốc "đang chạy"
    others = others or []
    others_by_frame: dict[str, list[tuple[float, float]]] = {}
    for o in others:
        fname = str(o["frame_file"])
        if fname in dups:
            continue
        others_by_frame.setdefault(fname, []).append(
            (float(o["x_m"]), float(o["y_m"])))
    tracklets = _build_tracklets(frames, others_by_frame)
    others_moving = _moving_times(tracklets, STILL_WIN_S)

    # --- frame mù: không thấy bi NÀO (cue lẫn thường)
    blind = np.array([not covered[i] and not others_by_frame.get(names[i])
                      for i in keep])

    # mốc "có dữ liệu": mọi thời điểm một chord đánh giá được (điểm thứ ≥2
    # của cue hoặc của một tracklet) — cửa sổ không có mốc nào thì "yên" là
    # KHÔNG BIẾT, không phải yên. Precompute một mảng sort để still_at chạy
    # O(log n) mỗi frame (quét video dài hàng nghìn frame).
    data_list = [float(v) for v in t_cue[1:]]
    for trk in tracklets:
        data_list.extend(p[0] for p in trk[1:])
    data_times = np.asarray(sorted(data_list))

    def any_moving(t_a: float, t_b: float) -> bool:
        """Có bi nào (cue hay thường) mang cờ 'đang chạy' trong (t_a, t_b]?"""
        for arr in (cue_moving, others_moving):
            lo = int(np.searchsorted(arr, t_a, side="right"))
            hi = int(np.searchsorted(arr, t_b, side="right"))
            if hi > lo:
                return True
        return False

    def cue_seen_moving_before(t: float) -> bool:
        """Cue mất dấu: bước cuối trước ``t`` còn > V_INIT? (đang bay thì
        mất det — chưa được kết luận 'mọi bi yên' chỉ vì vắng mặt nó)."""
        k = int(np.searchsorted(t_cue_step, t, side="right")) - 1
        return k >= 0 and float(v_cue[k]) > V_INIT_MPS

    def still_at(t_end_win: float, in_shot: bool) -> bool:
        t_a = t_end_win - STILL_WIN_S
        if t_a < float(t_frames[0]) - 1e-9:
            return False
        lo = int(np.searchsorted(data_times, t_a, side="left"))
        hi = int(np.searchsorted(data_times, t_end_win, side="right"))
        if hi <= lo:
            return False
        if any_moving(t_a, t_end_win):
            return False
        if in_shot:
            # cue vắng mặt trọn cửa sổ mà lần cuối thấy nó còn đang bay →
            # chưa yên (bài học shot_07: người che cue giữa cú)
            c_lo = int(np.searchsorted(t_cue, t_a, side="left"))
            c_hi = int(np.searchsorted(t_cue, t_end_win, side="right"))
            if c_hi - c_lo < 2 and cue_seen_moving_before(t_a):
                return False
        return True

    # --- onset ứng viên: V_INIT_RUN bước cue liên tiếp > V_INIT_MPS
    # (đúng luật _find_motion_start của measure_p0/broadcast; mốc onset =
    # điểm ĐẦU của run). Bước vắt qua đứt track dài hơn cửa sổ không được
    # tính "liên tiếp" — re-capture sau coast dài cho speed chord to giả.
    onsets: list[float] = []
    run = 0
    for k in range(len(v_cue)):
        if (t_cue[k + 1] - t_cue[k]) > STILL_WIN_S:
            run = 0
        run = run + 1 if v_cue[k] > V_INIT_MPS else 0
        if run == V_INIT_RUN:
            onsets.append(float(t_cue[k + 1 - V_INIT_RUN]))
    onsets_arr = np.asarray(onsets)

    # --- máy trạng thái quét theo frame đã bỏ trùng
    shots: list[dict] = []
    warnings: list[str] = []
    state = "idle"                    # idle → armed → open
    armed_at: float | None = None     # mốc "mọi bi đã yên" gần nhất
    t_onset: float | None = None
    blind_from: float | None = None

    def close(t_settle: float, status: str, reason: str | None) -> None:
        nonlocal state, armed_at, t_onset
        shots.append({"t_onset_s": float(t_onset),
                      "t_settle_s": float(t_settle),
                      "status": status, "reason": reason})
        if status == "ok":
            # bàn ĐANG yên (chính là điều kiện vừa đóng cú) → armed luôn,
            # không quay về idle: cú kế bắn nhanh ngay sau settle vẫn bắt.
            state, armed_at = "armed", t_settle
        else:
            state, armed_at = "idle", None
        t_onset = None

    for fi, (name, t) in enumerate(frames):
        if blind[fi]:
            if blind_from is None:
                blind_from = t
            if t - blind_from > BLIND_MAX_S:
                if state == "open":
                    close(blind_from, "error", LY_DO_MAT_CANH)
                # mất cảnh xoá cả bằng chứng "đã yên" — phải yên lại từ đầu
                state, armed_at = "idle", None
            continue
        blind_from = None

        if state == "idle" and still_at(t, in_shot=False):
            state = "armed"
            armed_at = t - STILL_WIN_S
        if state == "armed":
            lo = int(np.searchsorted(onsets_arr, armed_at - 1e-9,
                                     side="left"))
            hi = int(np.searchsorted(onsets_arr, t + 1e-9, side="right"))
            if hi > lo:
                t_onset = float(onsets_arr[lo])
                state = "open"
                continue
        if state == "open":
            if t - t_onset > SHOT_MAX_S:
                close(t, "error", LY_DO_QUA_DAI)
            elif t - t_onset > STILL_WIN_S and still_at(t, in_shot=True):
                close(t - STILL_WIN_S, "ok", None)

    if state == "open":
        close(float(t_frames[-1]), "error", LY_DO_CUT_CUOI_VIDEO)

    # --- đệm biên + clamp vào video và cú lân cận
    t0, t1 = float(t_frames[0]), float(t_frames[-1])
    for i, s in enumerate(shots):
        prev_end = shots[i - 1]["t_settle_s"] if i > 0 else t0
        next_on = shots[i + 1]["t_onset_s"] if i + 1 < len(shots) else t1
        s["idx"] = i + 1
        s["t_start_s"] = round(max(s["t_onset_s"] - PAD_PRE_S, prev_end, t0), 3)
        s["t_end_s"] = round(min(s["t_settle_s"] + PAD_POST_S, next_on, t1), 3)
        s["t_onset_s"] = round(s["t_onset_s"], 3)
        s["t_settle_s"] = round(s["t_settle_s"], 3)

    if not others:
        warnings.append("Không có detection bi thường nào — vế 'mọi bi đứng "
                        "yên' chỉ còn cue ball làm chứng.")
    return {"shots": shots,
            "warnings": warnings,
            "n_frames": len(rows),
            "n_dup_frames": len(dups),
            "duration_s": round(float(t_all[-1] - t_all[0]), 3)
            if len(t_all) > 1 else 0.0}


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

def segment_video(video_path: str | Path, corners_px, model, *,
                  op_conf: float, imgsz: int = 640, device=None,
                  max_dur_s: float | None = None, progress=None) -> dict:
    """MỘT video dài → detect + track toàn video → danh sách cú.

    Cùng giao ước ``analyze_clip`` (corners 4 pocket pixel, model YOLO đã
    load, ``progress(stage, frac)``, ValueError cho lỗi input) nhưng KHÔNG
    trần 30s — trần video do route quyết (``max_dur_s``). Decode trong bộ
    nhớ, không ghi frame ra đĩa (ràng buộc bản quyền, nếp BRIEF 24 #7).

    Trả dict của ``segment_shots`` cộng ``fps_nominal`` và
    ``t_first_s``/``t_last_s`` (PTS tuyệt đối — clip per cú cắt theo đúng
    thước này).
    """
    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 video — 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 (max_dur_s and fps > 0 and n_est > 0
                and n_est / fps > max_dur_s + 0.5):
            raise ValueError(f"Video dài {n_est / fps / 60:.1f} phút — giới "
                             f"hạn {max_dur_s / 60:g} phút cho một lần quét.")

        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]] = {}
        other_dets: list[dict] = []
        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
                    if cls == "Cue":
                        dets_by_frame.setdefault(name, []).append(
                            {"conf": float(b.conf), "table_x_m": float(tx),
                             "table_y_m": float(ty)})
                    else:
                        other_dets.append({"frame_file": name,
                                           "t_s": meta[int(name)][1],
                                           "x_m": float(tx),
                                           "y_m": float(ty)})
            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 max_dur_s and t - t_first > max_dur_s + 0.5:
                raise ValueError(f"Video dài quá {max_dur_s / 60:g} phút "
                                 f"(container khai sai số frame).")
            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.02 + 0.9 * frac)
        flush_batch()
    finally:
        cap.release()

    if len(meta) < 5:
        raise ValueError("Video quá ngắn hoặc không đọc được frame nào.")

    if progress is not None:
        progress("segment", 0.95)
    rows = track_cue(meta, dets_by_frame, diffs)
    out = segment_shots(rows, others=other_dets)
    out["fps_nominal"] = round(fps, 3)
    out["t_first_s"] = round(float(meta[0][1]), 3)
    out["t_last_s"] = round(float(meta[-1][1]), 3)
    return out