Mirko Trasciatti commited on
Commit
79c7590
·
1 Parent(s): 9974014

Add goal crossbar annotation workflow

Browse files
Files changed (2) hide show
  1. app.py +489 -131
  2. tooltips.js +0 -38
app.py CHANGED
@@ -51,12 +51,14 @@ YOLO_IOU_THRESHOLD = 0.02
51
  PLAYER_TARGET_NAME = "person"
52
  PLAYER_OBJECT_ID = 2
53
  BALL_OBJECT_ID = 1
54
- OBJECT_CHOICES = [
55
- ("Ball", BALL_OBJECT_ID),
56
- ("Player", PLAYER_OBJECT_ID),
57
- ]
58
- OBJECT_LABELS = [label for label, _ in OBJECT_CHOICES]
59
- OBJECT_LABEL_TO_ID = {label: obj_id for label, obj_id in OBJECT_CHOICES}
 
 
60
 
61
 
62
  def get_yolo_model(model_filename: str = YOLO_DEFAULT_MODEL) -> YOLO:
@@ -174,21 +176,282 @@ def _compute_sam_window_from_kick(state: AppState, kick_frame: int | None) -> tu
174
  total_frames = state.num_frames
175
  if total_frames == 0:
176
  return 0, 0
 
 
 
177
  if kick_frame is None:
178
  start_idx = 0
179
- end_idx = total_frames
180
  else:
181
- fps = state.video_fps if state.video_fps and state.video_fps > 0 else 25.0
182
- target_window_frames = max(1, int(round(fps * 4.0)))
183
- half_window = target_window_frames // 2
184
  start_idx = max(0, int(kick_frame) - half_window)
185
- end_idx = min(total_frames, start_idx + target_window_frames)
186
- if end_idx <= start_idx:
187
- end_idx = min(total_frames, start_idx + 1)
188
  state.sam_window = (start_idx, end_idx)
189
  return start_idx, end_idx
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  def _perform_yolo_ball_tracking(state: AppState, progress: gr.Progress | None = None) -> None:
193
  if state is None or state.num_frames == 0:
194
  raise gr.Error("Load a video first, then track with YOLO.")
@@ -595,6 +858,12 @@ class AppState:
595
  self.is_sam_tracked: bool = False
596
  self.is_player_detected: bool = False
597
  self.is_player_propagated: bool = False
 
 
 
 
 
 
598
 
599
  def __repr__(self):
600
  return f"AppState(video_frames={self.video_frames}, inference_session={self.inference_session is not None}, model={self.model is not None}, processor={self.processor is not None}, device={self.device}, dtype={self.dtype}, video_fps={self.video_fps}, masks_by_frame={self.masks_by_frame}, color_by_obj={self.color_by_obj}, clicks_by_frame_obj={self.clicks_by_frame_obj}, boxes_by_frame_obj={self.boxes_by_frame_obj}, composited_frames={self.composited_frames}, current_frame_idx={self.current_frame_idx}, current_obj_id={self.current_obj_id}, current_label={self.current_label}, current_clear_old={self.current_clear_old}, current_prompt_type={self.current_prompt_type}, pending_box_start={self.pending_box_start}, pending_box_start_frame_idx={self.pending_box_start_frame_idx}, pending_box_start_obj_id={self.pending_box_start_obj_id}, is_switching_model={self.is_switching_model}, model_repo_key={self.model_repo_key}, model_repo_id={self.model_repo_id}, session_repo_id={self.session_repo_id})"
@@ -693,6 +962,18 @@ def init_video_session(GLOBAL_STATE: gr.State, video: str | dict) -> tuple[AppSt
693
  GLOBAL_STATE.impact_debug_speed_kmh = []
694
  GLOBAL_STATE.impact_debug_speed_threshold_px = None
695
  GLOBAL_STATE.impact_meters_per_px = None
 
 
 
 
 
 
 
 
 
 
 
 
696
  GLOBAL_STATE.yolo_ball_centers = {}
697
  GLOBAL_STATE.yolo_ball_boxes = {}
698
  GLOBAL_STATE.yolo_ball_conf = {}
@@ -1094,6 +1375,8 @@ def compose_frame(state: AppState, frame_idx: int, remove_bg: bool = False) -> I
1094
  base_np = focus_alpha * orig_np + (1.0 - focus_alpha) * base_np
1095
  out_img = Image.fromarray(np.clip(base_np * 255.0, 0, 255).astype(np.uint8))
1096
 
 
 
1097
  # Draw crosses for conditioning frames only (frames with recorded clicks)
1098
  clicks_map = state.clicks_by_frame_obj.get(frame_idx)
1099
  if state.show_click_marks and clicks_map:
@@ -2138,7 +2421,7 @@ def _player_has_masks(state: AppState) -> bool:
2138
 
2139
  def _button_updates(state: AppState) -> tuple[Any, Any, Any]:
2140
  yolo_ready = isinstance(state, AppState) and state.yolo_kick_frame is not None
2141
- propagate_main_enabled = _ball_has_masks(state)
2142
  detect_player_enabled = yolo_ready
2143
  propagate_player_enabled = _player_has_masks(state)
2144
  sam_tracked = isinstance(state, AppState) and getattr(state, "is_sam_tracked", False)
@@ -2167,7 +2450,7 @@ def _ball_button_updates(state: AppState) -> tuple[Any, Any]:
2167
 
2168
 
2169
  def _ui_status_updates(state: AppState) -> tuple[Any, ...]:
2170
- return _kick_button_updates(state) + _ball_button_updates(state)
2171
 
2172
 
2173
  def _recompute_motion_metrics(state: AppState, target_obj_id: int = 1):
@@ -2584,15 +2867,29 @@ def _on_image_click_with_updates(
2584
  img: Image.Image | np.ndarray,
2585
  state: AppState,
2586
  frame_idx: int,
2587
- obj_choice: str,
2588
  label: str,
2589
  clear_old: bool,
2590
  evt: gr.SelectData,
2591
  ):
2592
- obj_id = OBJECT_LABEL_TO_ID.get(obj_choice, state.current_obj_id)
2593
- preview_img = on_image_click(img, state, frame_idx, obj_id, label, clear_old, evt)
 
 
 
 
 
 
 
2594
  propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state)
2595
- return preview_img, propagate_main_update, detect_btn_update, propagate_player_update
 
 
 
 
 
 
 
2596
 
2597
 
2598
  @spaces.GPU()
@@ -3143,33 +3440,49 @@ BUTTON_TOOLTIPS = {
3143
  "After a player mask exists, SAM2 propagates it within the same kick-centric window. This keeps athlete and ball masks "
3144
  "time-synced, enabling combined overlays, exports, and analytics comparing foot position to ball contact."
3145
  ),
3146
- "chart-yolo": (
3147
- "Visualizes YOLO’s raw measurements per frame. The green curve is estimated ball speed derived from YOLO detections, "
3148
- "while the purple vertical markers show frames where acceleration spikes past adaptive thresholds (potential kicks). "
3149
- "The dotted baselines track average motion and the magenta area plot reflects detection area/brightness—sudden drops "
3150
- "often indicate the ball leaving the foot. Use this chart to confirm the automatic kick pick or to spot earlier peaks."
 
 
3151
  ),
3152
- "chart-sam": (
3153
- "Summarizes SAM2’s refined window. The blue line is the filtered ball speed after segmentation smoothing, the orange "
3154
- "line represents the Kalman innovation (how surprising each measurement is), and the magenta curve shows distance "
3155
- "from the starting point. Vertical guides mark the current kick frame and any manually tagged impact. Inspect this "
3156
- "plot to see whether SAM2 agrees with YOLO about the kick and to gauge how long the ball stays above the minimum "
3157
- "impact speed before fading."
3158
  ),
3159
  }
3160
 
3161
- def _section_info_markdown(section_label: str, entries: list[tuple[str, str]]) -> str:
3162
- chunks = [f"### {section_label}"]
3163
- for label, key in entries:
3164
- desc = BUTTON_TOOLTIPS.get(key, "")
3165
- chunks.append(f"**{label}**\n{desc}")
3166
- return "\n\n".join(chunks)
3167
-
3168
- with gr.Blocks(
3169
- title="SAM2 Video (Transformers) - Interactive Segmentation",
3170
- theme=theme,
3171
- css=CUSTOM_CSS,
3172
- ) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3173
  GLOBAL_STATE = gr.State(AppState())
3174
 
3175
  gr.Markdown(
@@ -3217,17 +3530,7 @@ with gr.Blocks(
3217
  )
3218
  ckpt_progress = gr.Markdown(visible=False)
3219
  load_status = gr.Markdown(visible=True)
3220
- with gr.Row():
3221
- reset_btn = gr.Button("Reset Session", variant="secondary", elem_id="btn-reset-session")
3222
- with gr.Accordion("Session reset · details", open=False):
3223
- gr.Markdown(
3224
- _section_info_markdown(
3225
- "Reset workflow",
3226
- [
3227
- ("Reset Session", "btn-reset-session"),
3228
- ],
3229
- )
3230
- )
3231
  with gr.Column(scale=1):
3232
  gr.Markdown("**Preview**")
3233
  preview = gr.Image(
@@ -3255,31 +3558,31 @@ with gr.Blocks(
3255
  with gr.Row(elem_classes=["model-status"]):
3256
  manual_kick_btn = gr.Button("⚽: N/A", interactive=False)
3257
  manual_impact_btn = gr.Button("🚩: N/A", interactive=False)
3258
- with gr.Accordion("Tap & prompt settings", open=True):
3259
- object_selector = gr.Radio(
3260
- label="Tap target",
3261
- choices=OBJECT_LABELS,
3262
- value=OBJECT_LABELS[0],
3263
- info="Future clicks on the preview will be assigned to this object.",
3264
- )
3265
- with gr.Row():
3266
- label_radio = gr.Radio(
3267
- choices=["positive", "negative"],
3268
- value="positive",
3269
- label="Point label",
 
 
 
 
 
3270
  )
3271
- clear_old_chk = gr.Checkbox(value=False, label="Clear old inputs for this object")
3272
- prompt_type = gr.Radio(choices=["Points", "Boxes"], value="Points", label="Prompt type")
3273
- with gr.Accordion("Manual controls · details", open=False):
3274
- gr.Markdown(
3275
- _section_info_markdown(
3276
- "Manual controls",
3277
- [
3278
- ("⚽ Mark Kick", "btn-mark-kick"),
3279
- ("🚩 Mark Impact", "btn-mark-impact"),
3280
- ],
3281
  )
3282
- )
3283
  with gr.Column(elem_classes=["model-section"]):
3284
  with gr.Row(elem_classes=["model-row"]):
3285
  gr.Markdown("YOLO13", elem_classes=["model-label"])
@@ -3296,18 +3599,6 @@ with gr.Blocks(
3296
  yolo_kick_btn = gr.Button("⚽: N/A", interactive=False)
3297
  yolo_impact_btn = gr.Button("🚩: N/A", interactive=False)
3298
  yolo_plot = gr.Plot(label="YOLO kick diagnostics", show_label=True)
3299
- with gr.Accordion("YOLO13 workflow · details", open=False):
3300
- gr.Markdown(
3301
- _section_info_markdown(
3302
- "YOLO13 workflow",
3303
- [
3304
- ("Detect Ball", "btn-detect-ball"),
3305
- ("Track Ball", "btn-track-ball-yolo"),
3306
- ("Detect Player", "btn-detect-player"),
3307
- ],
3308
- )
3309
- )
3310
- gr.Markdown(f"#### YOLO kick diagnostics chart\n{BUTTON_TOOLTIPS['chart-yolo']}")
3311
  with gr.Column(elem_classes=["model-section"]):
3312
  with gr.Row(elem_classes=["model-row"]):
3313
  gr.Markdown("SAM2", elem_classes=["model-label"])
@@ -3325,17 +3616,7 @@ with gr.Blocks(
3325
  sam_kick_btn = gr.Button("⚽: N/A", interactive=False)
3326
  sam_impact_btn = gr.Button("🚩: N/A", interactive=False)
3327
  kick_plot = gr.Plot(label="Kick & impact diagnostics", show_label=True)
3328
- with gr.Accordion("SAM2 tracking · details", open=False):
3329
- gr.Markdown(
3330
- _section_info_markdown(
3331
- "SAM2 tracking",
3332
- [
3333
- ("Track Ball", "btn-track-ball-sam"),
3334
- ("Track Player", "btn-track-player-sam"),
3335
- ],
3336
- )
3337
- )
3338
- gr.Markdown(f"#### Kick & impact diagnostics chart\n{BUTTON_TOOLTIPS['chart-sam']}")
3339
  with gr.Row():
3340
  min_impact_speed_slider = gr.Slider(
3341
  label="Min impact speed (km/h)",
@@ -3356,6 +3637,11 @@ with gr.Blocks(
3356
  ball_status = gr.Markdown(visible=False)
3357
  propagate_status = gr.Markdown(visible=True)
3358
  impact_status = gr.Markdown("Impact frame: not computed", visible=False)
 
 
 
 
 
3359
  # Wire events
3360
  def _on_video_change(GLOBAL_STATE: gr.State, video):
3361
  GLOBAL_STATE, min_idx, max_idx, first_frame, status = init_video_session(GLOBAL_STATE, video)
@@ -3373,10 +3659,6 @@ with gr.Blocks(
3373
  propagate_main_update,
3374
  detect_btn_update,
3375
  propagate_player_update,
3376
- gr.update(value=OBJECT_LABELS[0]),
3377
- gr.update(value="positive", visible=True, interactive=True),
3378
- gr.update(value=False, interactive=True),
3379
- gr.update(value="Points"),
3380
  )
3381
 
3382
  video_in.change(
@@ -3398,13 +3680,14 @@ with gr.Blocks(
3398
  manual_impact_btn,
3399
  detect_ball_btn,
3400
  track_ball_yolo_btn,
 
 
 
 
 
3401
  propagate_btn,
3402
  detect_player_btn,
3403
  propagate_player_btn,
3404
- object_selector,
3405
- label_radio,
3406
- clear_old_chk,
3407
- prompt_type,
3408
  ],
3409
  show_progress=True,
3410
  )
@@ -3434,13 +3717,14 @@ with gr.Blocks(
3434
  manual_impact_btn,
3435
  detect_ball_btn,
3436
  track_ball_yolo_btn,
 
 
 
 
 
3437
  propagate_btn,
3438
  detect_player_btn,
3439
  propagate_player_btn,
3440
- object_selector,
3441
- label_radio,
3442
- clear_old_chk,
3443
- prompt_type,
3444
  ],
3445
  label="Examples",
3446
  cache_examples=False,
@@ -3732,6 +4016,11 @@ with gr.Blocks(
3732
  manual_impact_btn,
3733
  detect_ball_btn,
3734
  track_ball_yolo_btn,
 
 
 
 
 
3735
  ],
3736
  )
3737
  mark_impact_btn.click(
@@ -3753,15 +4042,20 @@ with gr.Blocks(
3753
  manual_impact_btn,
3754
  detect_ball_btn,
3755
  track_ball_yolo_btn,
 
 
 
 
 
3756
  ],
3757
  )
3758
 
3759
- def _sync_object_choice(s: AppState, choice: str):
3760
- if s is not None and choice in OBJECT_LABEL_TO_ID:
3761
- s.current_obj_id = OBJECT_LABEL_TO_ID[choice]
3762
  return gr.update()
3763
 
3764
- object_selector.change(_sync_object_choice, inputs=[GLOBAL_STATE, object_selector], outputs=[])
3765
 
3766
  def _sync_label(s: AppState, lab: str):
3767
  if s is not None and lab is not None:
@@ -3830,7 +4124,7 @@ with gr.Blocks(
3830
 
3831
  def _auto_detect_ball(
3832
  state_in: AppState,
3833
- obj_choice: str,
3834
  label_value: str,
3835
  clear_old_value: bool,
3836
  ):
@@ -3862,8 +4156,7 @@ with gr.Blocks(
3862
  frame_width, frame_height = frame.size
3863
  x_center = max(0, min(frame_width - 1, int(x_center)))
3864
  y_center = max(0, min(frame_height - 1, int(y_center)))
3865
- obj_id_int = OBJECT_LABEL_TO_ID.get(obj_choice, state_in.current_obj_id)
3866
- state_in.current_obj_id = obj_id_int
3867
  label_str = label_value if label_value else state_in.current_label
3868
  clear_old_flag = bool(clear_old_value)
3869
 
@@ -3902,7 +4195,7 @@ with gr.Blocks(
3902
 
3903
  detect_ball_btn.click(
3904
  _auto_detect_ball,
3905
- inputs=[GLOBAL_STATE, object_selector, label_radio, clear_old_chk],
3906
  outputs=[
3907
  preview,
3908
  ball_status,
@@ -3919,6 +4212,11 @@ with gr.Blocks(
3919
  manual_impact_btn,
3920
  detect_ball_btn,
3921
  track_ball_yolo_btn,
 
 
 
 
 
3922
  ],
3923
  )
3924
 
@@ -3977,6 +4275,11 @@ with gr.Blocks(
3977
  manual_impact_btn,
3978
  detect_ball_btn,
3979
  track_ball_yolo_btn,
 
 
 
 
 
3980
  ],
3981
  )
3982
 
@@ -4017,10 +4320,6 @@ with gr.Blocks(
4017
  def _result(preview_img, status_text):
4018
  propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state_in)
4019
  status_updates = _ui_status_updates(state_in)
4020
- selected_label = next(
4021
- (label for label, oid in OBJECT_CHOICES if oid == state_in.current_obj_id),
4022
- OBJECT_LABELS[0],
4023
- )
4024
  return (
4025
  preview_img,
4026
  gr.update(value=status_text, visible=True),
@@ -4029,7 +4328,7 @@ with gr.Blocks(
4029
  propagate_main_update,
4030
  detect_btn_update,
4031
  propagate_player_update,
4032
- gr.update(value=selected_label),
4033
  _impact_status_update(state_in),
4034
  *status_updates,
4035
  )
@@ -4102,10 +4401,7 @@ with gr.Blocks(
4102
  status_text = (
4103
  f"{_format_kick_status(state_in)} | ✅ Player auto-detected on frame {frame_idx} (conf={conf:.2f})"
4104
  )
4105
- preview_img = update_frame_display(state_in, frame_idx)
4106
- # Force mask overlay refresh now that the player has been stamped.
4107
- state_in.composited_frames.pop(frame_idx, None)
4108
- return _result(preview_img, status_text)
4109
 
4110
  detect_player_btn.click(
4111
  _auto_detect_player,
@@ -4118,7 +4414,7 @@ with gr.Blocks(
4118
  propagate_btn,
4119
  detect_player_btn,
4120
  propagate_player_btn,
4121
- object_selector,
4122
  impact_status,
4123
  yolo_kick_btn,
4124
  yolo_impact_btn,
@@ -4128,6 +4424,11 @@ with gr.Blocks(
4128
  manual_impact_btn,
4129
  detect_ball_btn,
4130
  track_ball_yolo_btn,
 
 
 
 
 
4131
  ],
4132
  )
4133
 
@@ -4298,14 +4599,61 @@ with gr.Blocks(
4298
  manual_impact_btn,
4299
  detect_ball_btn,
4300
  track_ball_yolo_btn,
 
 
 
 
 
4301
  ],
4302
  )
4303
 
4304
  # Image click to add a point and run forward on that frame
4305
  preview.select(
4306
  _on_image_click_with_updates,
4307
- [preview, GLOBAL_STATE, frame_slider, object_selector, label_radio, clear_old_chk],
4308
- [preview, propagate_btn, detect_player_btn, propagate_player_btn],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4309
  )
4310
 
4311
  # Playback via MP4 rendering only
@@ -4386,6 +4734,11 @@ with gr.Blocks(
4386
  manual_impact_btn,
4387
  detect_ball_btn,
4388
  track_ball_yolo_btn,
 
 
 
 
 
4389
  ],
4390
  )
4391
 
@@ -4413,6 +4766,11 @@ with gr.Blocks(
4413
  manual_impact_btn,
4414
  detect_ball_btn,
4415
  track_ball_yolo_btn,
 
 
 
 
 
4416
  ],
4417
  )
4418
 
 
51
  PLAYER_TARGET_NAME = "person"
52
  PLAYER_OBJECT_ID = 2
53
  BALL_OBJECT_ID = 1
54
+ GOAL_MODE_IDLE = "idle"
55
+ GOAL_MODE_PLACING_FIRST = "placing_first"
56
+ GOAL_MODE_PLACING_SECOND = "placing_second"
57
+ GOAL_MODE_EDITING = "editing"
58
+ GOAL_HANDLE_RADIUS_PX = 8
59
+ GOAL_HANDLE_HIT_RADIUS_PX = 28
60
+ GOAL_LINE_COLOR = (255, 214, 64)
61
+ GOAL_HANDLE_FILL = (10, 10, 10)
62
 
63
 
64
  def get_yolo_model(model_filename: str = YOLO_DEFAULT_MODEL) -> YOLO:
 
176
  total_frames = state.num_frames
177
  if total_frames == 0:
178
  return 0, 0
179
+ fps = state.video_fps if state.video_fps and state.video_fps > 0 else 25.0
180
+ target_window_frames = max(1, int(round(fps * 4.0)))
181
+ half_window = target_window_frames // 2
182
  if kick_frame is None:
183
  start_idx = 0
 
184
  else:
 
 
 
185
  start_idx = max(0, int(kick_frame) - half_window)
186
+ end_idx = min(total_frames, start_idx + target_window_frames)
187
+ if end_idx <= start_idx:
188
+ end_idx = min(total_frames, start_idx + 1)
189
  state.sam_window = (start_idx, end_idx)
190
  return start_idx, end_idx
191
 
192
 
193
+ def _goal_frame_dims(state: AppState, frame_idx: int | None = None) -> tuple[int, int]:
194
+ if state is None or not state.video_frames:
195
+ return 1, 1
196
+ idx = 0 if frame_idx is None else int(np.clip(frame_idx, 0, len(state.video_frames) - 1))
197
+ frame = state.video_frames[idx]
198
+ return frame.size
199
+
200
+
201
+ def _goal_norm_from_xy(state: AppState, frame_idx: int, x: int, y: int) -> tuple[float, float]:
202
+ width, height = _goal_frame_dims(state, frame_idx)
203
+ if width <= 0:
204
+ width = 1
205
+ if height <= 0:
206
+ height = 1
207
+ return (
208
+ float(np.clip(x / width, 0.0, 1.0)),
209
+ float(np.clip(y / height, 0.0, 1.0)),
210
+ )
211
+
212
+
213
+ def _goal_xy_from_norm(state: AppState, frame_idx: int, pt: tuple[float, float]) -> tuple[int, int]:
214
+ width, height = _goal_frame_dims(state, frame_idx)
215
+ return (
216
+ int(round(float(pt[0]) * width)),
217
+ int(round(float(pt[1]) * height)),
218
+ )
219
+
220
+
221
+ def _goal_points_for_drawing(state: AppState) -> list[tuple[float, float]]:
222
+ if state is None:
223
+ return []
224
+ if state.goal_mode in {GOAL_MODE_PLACING_FIRST, GOAL_MODE_PLACING_SECOND, GOAL_MODE_EDITING}:
225
+ return state.goal_points_norm
226
+ if state.goal_points_norm:
227
+ return state.goal_points_norm
228
+ return state.goal_confirmed_points_norm
229
+
230
+
231
+ def _goal_clear_preview_cache(state: AppState) -> None:
232
+ if state is None:
233
+ return
234
+ state.composited_frames.clear()
235
+
236
+
237
+ def _goal_has_confirmed(state: AppState) -> bool:
238
+ return isinstance(state, AppState) and len(state.goal_confirmed_points_norm) == 2
239
+
240
+
241
+ def _goal_set_status(state: AppState, text: str) -> None:
242
+ if state is None:
243
+ return
244
+ state.goal_status_text = text
245
+
246
+
247
+ def _goal_status_text(state: AppState) -> str:
248
+ if state is None:
249
+ return "Goal crossbar unavailable."
250
+ if state.goal_status_text:
251
+ return state.goal_status_text
252
+ if _goal_has_confirmed(state):
253
+ return "Goal crossbar confirmed. Click Start Mapping to adjust."
254
+ return "Goal crossbar inactive."
255
+
256
+
257
+ def _goal_button_updates(state: AppState) -> tuple[Any, Any, Any, Any, Any]:
258
+ if state is None:
259
+ return (
260
+ gr.update(interactive=False),
261
+ gr.update(interactive=False),
262
+ gr.update(interactive=False),
263
+ gr.update(interactive=False),
264
+ gr.update(value="Goal crossbar unavailable.", visible=True),
265
+ )
266
+ start_enabled = state.goal_mode == GOAL_MODE_IDLE
267
+ confirm_enabled = len(state.goal_points_norm) == 2 and state.goal_mode in {
268
+ GOAL_MODE_PLACING_SECOND,
269
+ GOAL_MODE_EDITING,
270
+ }
271
+ clear_enabled = bool(state.goal_points_norm or state.goal_confirmed_points_norm)
272
+ back_enabled = bool(state.goal_prev_confirmed_points_norm)
273
+ status_update = gr.update(value=_goal_status_text(state), visible=True)
274
+ return (
275
+ gr.update(interactive=start_enabled),
276
+ gr.update(interactive=confirm_enabled),
277
+ gr.update(interactive=clear_enabled),
278
+ gr.update(interactive=back_enabled),
279
+ status_update,
280
+ )
281
+
282
+
283
+ def _goal_handle_hit_index(state: AppState, frame_idx: int, x: int, y: int) -> int | None:
284
+ points = state.goal_points_norm
285
+ if state is None or len(points) == 0:
286
+ return None
287
+ width, height = _goal_frame_dims(state, frame_idx)
288
+ max_dist = GOAL_HANDLE_HIT_RADIUS_PX
289
+ for idx, pt in enumerate(points):
290
+ px, py = _goal_xy_from_norm(state, frame_idx, pt)
291
+ dist = math.hypot(px - x, py - y)
292
+ if dist <= max_dist:
293
+ return idx
294
+ return None
295
+
296
+
297
+ def _goal_current_frame_idx(state: AppState) -> int:
298
+ if state is None or state.num_frames == 0:
299
+ return 0
300
+ idx = int(getattr(state, "current_frame_idx", 0))
301
+ return int(np.clip(idx, 0, state.num_frames - 1))
302
+
303
+
304
+ def _goal_output_tuple(state: AppState, preview_img: Image.Image | None = None) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
305
+ if state is None:
306
+ return (preview_img, *(gr.update(interactive=False) for _ in range(4)), gr.update(value="Goal crossbar unavailable.", visible=True))
307
+ idx = _goal_current_frame_idx(state)
308
+ if preview_img is None:
309
+ preview_img = update_frame_display(state, idx)
310
+ return (preview_img, *_goal_button_updates(state))
311
+
312
+
313
+ def _goal_start_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
314
+ if state is None or not state.video_frames:
315
+ raise gr.Error("Load a video first, then map the goal crossbar.")
316
+ state.goal_prev_confirmed_points_norm = list(state.goal_confirmed_points_norm)
317
+ state.goal_points_norm = []
318
+ state.goal_mode = GOAL_MODE_PLACING_FIRST
319
+ state.goal_dragging_idx = None
320
+ _goal_set_status(state, "Click the left goalpost to start the crossbar.")
321
+ _goal_clear_preview_cache(state)
322
+ return _goal_output_tuple(state)
323
+
324
+
325
+ def _goal_confirm_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
326
+ if state is None:
327
+ return (None, *_goal_button_updates(state))
328
+ if len(state.goal_points_norm) != 2:
329
+ _goal_set_status(state, "Select both goal corners before confirming.")
330
+ return _goal_output_tuple(state)
331
+ state.goal_confirmed_points_norm = list(state.goal_points_norm)
332
+ state.goal_mode = GOAL_MODE_IDLE
333
+ state.goal_dragging_idx = None
334
+ _goal_set_status(state, "Goal crossbar saved. Click Start Mapping to adjust again.")
335
+ _goal_clear_preview_cache(state)
336
+ return _goal_output_tuple(state)
337
+
338
+
339
+ def _goal_clear_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
340
+ if state is None:
341
+ return (None, *_goal_button_updates(state))
342
+ state.goal_points_norm = []
343
+ state.goal_confirmed_points_norm = []
344
+ state.goal_prev_confirmed_points_norm = []
345
+ state.goal_mode = GOAL_MODE_IDLE
346
+ state.goal_dragging_idx = None
347
+ _goal_set_status(state, "Goal crossbar cleared.")
348
+ _goal_clear_preview_cache(state)
349
+ return _goal_output_tuple(state)
350
+
351
+
352
+ def _goal_back_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
353
+ if state is None:
354
+ return (None, *_goal_button_updates(state))
355
+ if not state.goal_prev_confirmed_points_norm:
356
+ _goal_set_status(state, "No previous goal crossbar to restore.")
357
+ return _goal_output_tuple(state)
358
+ state.goal_confirmed_points_norm = list(state.goal_prev_confirmed_points_norm)
359
+ state.goal_points_norm = list(state.goal_prev_confirmed_points_norm)
360
+ state.goal_prev_confirmed_points_norm = []
361
+ state.goal_mode = GOAL_MODE_IDLE
362
+ state.goal_dragging_idx = None
363
+ _goal_set_status(state, "Restored the previous goal crossbar.")
364
+ _goal_clear_preview_cache(state)
365
+ return _goal_output_tuple(state)
366
+
367
+
368
+ def _goal_process_preview_click(
369
+ state: AppState,
370
+ frame_idx: int,
371
+ evt: gr.SelectData | None,
372
+ ) -> tuple[Image.Image | None, bool]:
373
+ if state is None or state.goal_mode == GOAL_MODE_IDLE:
374
+ return None, False
375
+ x = y = None
376
+ if evt is not None:
377
+ try:
378
+ if hasattr(evt, "index") and isinstance(evt.index, (list, tuple)) and len(evt.index) == 2:
379
+ x, y = int(evt.index[0]), int(evt.index[1])
380
+ elif hasattr(evt, "value") and isinstance(evt.value, dict):
381
+ data = evt.value
382
+ if "x" in data and "y" in data:
383
+ x, y = int(data["x"]), int(data["y"])
384
+ except Exception:
385
+ x = y = None
386
+ if x is None or y is None:
387
+ _goal_set_status(state, "Could not read click coordinates. Please try again.")
388
+ return _goal_output_tuple(state)[0], True
389
+
390
+ norm_pt = _goal_norm_from_xy(state, frame_idx, x, y)
391
+ points = state.goal_points_norm
392
+
393
+ if state.goal_mode == GOAL_MODE_PLACING_FIRST:
394
+ state.goal_points_norm = [norm_pt]
395
+ state.goal_mode = GOAL_MODE_PLACING_SECOND
396
+ _goal_set_status(state, "Click the right goalpost to finish the crossbar.")
397
+ elif state.goal_mode == GOAL_MODE_PLACING_SECOND:
398
+ handle_idx = _goal_handle_hit_index(state, frame_idx, x, y)
399
+ if handle_idx is not None and handle_idx < len(points):
400
+ state.goal_points_norm[handle_idx] = norm_pt
401
+ _goal_set_status(state, "Adjusted the first corner. Click the other post.")
402
+ else:
403
+ if len(points) == 0:
404
+ state.goal_points_norm = [norm_pt]
405
+ _goal_set_status(state, "Click the next goalpost to finish the crossbar.")
406
+ elif len(points) == 1:
407
+ state.goal_points_norm.append(norm_pt)
408
+ state.goal_mode = GOAL_MODE_EDITING
409
+ _goal_set_status(state, "Adjust handles if needed, then Confirm.")
410
+ else:
411
+ state.goal_points_norm[1] = norm_pt
412
+ state.goal_mode = GOAL_MODE_EDITING
413
+ _goal_set_status(state, "Adjust handles if needed, then Confirm.")
414
+ elif state.goal_mode == GOAL_MODE_EDITING:
415
+ handle_idx = _goal_handle_hit_index(state, frame_idx, x, y)
416
+ if handle_idx is None and len(points) == 2:
417
+ # fall back to whichever endpoint is closest to click
418
+ px0, py0 = _goal_xy_from_norm(state, frame_idx, points[0])
419
+ px1, py1 = _goal_xy_from_norm(state, frame_idx, points[1])
420
+ dist0 = math.hypot(px0 - x, py0 - y)
421
+ dist1 = math.hypot(px1 - x, py1 - y)
422
+ handle_idx = 0 if dist0 <= dist1 else 1
423
+ if handle_idx is not None and handle_idx < len(points):
424
+ state.goal_points_norm[handle_idx] = norm_pt
425
+ _goal_set_status(state, "Handle moved. Press Confirm to save.")
426
+ state.goal_points_norm = state.goal_points_norm[:2]
427
+ _goal_clear_preview_cache(state)
428
+ preview_img = update_frame_display(state, frame_idx)
429
+ return preview_img, True
430
+
431
+
432
+ def _draw_goal_overlay(state: AppState, frame_idx: int, image: Image.Image) -> None:
433
+ if state is None or image is None:
434
+ return
435
+ points = _goal_points_for_drawing(state)
436
+ if not points:
437
+ return
438
+ draw = ImageDraw.Draw(image)
439
+ px_points = [_goal_xy_from_norm(state, frame_idx, pt) for pt in points[:2]]
440
+ if len(px_points) >= 2:
441
+ draw.line(
442
+ [px_points[0], px_points[1]],
443
+ fill=GOAL_LINE_COLOR,
444
+ width=4,
445
+ )
446
+ handle_radius = max(4, GOAL_HANDLE_RADIUS_PX)
447
+ for cx, cy in px_points:
448
+ bbox = [
449
+ (cx - handle_radius, cy - handle_radius),
450
+ (cx + handle_radius, cy + handle_radius),
451
+ ]
452
+ draw.ellipse(bbox, outline=GOAL_LINE_COLOR, fill=GOAL_HANDLE_FILL, width=2)
453
+
454
+
455
  def _perform_yolo_ball_tracking(state: AppState, progress: gr.Progress | None = None) -> None:
456
  if state is None or state.num_frames == 0:
457
  raise gr.Error("Load a video first, then track with YOLO.")
 
858
  self.is_sam_tracked: bool = False
859
  self.is_player_detected: bool = False
860
  self.is_player_propagated: bool = False
861
+ self.goal_mode: str = GOAL_MODE_IDLE
862
+ self.goal_points_norm: list[tuple[float, float]] = []
863
+ self.goal_confirmed_points_norm: list[tuple[float, float]] = []
864
+ self.goal_prev_confirmed_points_norm: list[tuple[float, float]] = []
865
+ self.goal_status_text: str = "Goal crossbar inactive."
866
+ self.goal_dragging_idx: int | None = None
867
 
868
  def __repr__(self):
869
  return f"AppState(video_frames={self.video_frames}, inference_session={self.inference_session is not None}, model={self.model is not None}, processor={self.processor is not None}, device={self.device}, dtype={self.dtype}, video_fps={self.video_fps}, masks_by_frame={self.masks_by_frame}, color_by_obj={self.color_by_obj}, clicks_by_frame_obj={self.clicks_by_frame_obj}, boxes_by_frame_obj={self.boxes_by_frame_obj}, composited_frames={self.composited_frames}, current_frame_idx={self.current_frame_idx}, current_obj_id={self.current_obj_id}, current_label={self.current_label}, current_clear_old={self.current_clear_old}, current_prompt_type={self.current_prompt_type}, pending_box_start={self.pending_box_start}, pending_box_start_frame_idx={self.pending_box_start_frame_idx}, pending_box_start_obj_id={self.pending_box_start_obj_id}, is_switching_model={self.is_switching_model}, model_repo_key={self.model_repo_key}, model_repo_id={self.model_repo_id}, session_repo_id={self.session_repo_id})"
 
962
  GLOBAL_STATE.impact_debug_speed_kmh = []
963
  GLOBAL_STATE.impact_debug_speed_threshold_px = None
964
  GLOBAL_STATE.impact_meters_per_px = None
965
+ GLOBAL_STATE.goal_mode = GOAL_MODE_IDLE
966
+ GLOBAL_STATE.goal_points_norm = []
967
+ GLOBAL_STATE.goal_confirmed_points_norm = []
968
+ GLOBAL_STATE.goal_prev_confirmed_points_norm = []
969
+ GLOBAL_STATE.goal_status_text = "Goal crossbar inactive."
970
+ GLOBAL_STATE.goal_dragging_idx = None
971
+ GLOBAL_STATE.goal_mode = GOAL_MODE_IDLE
972
+ GLOBAL_STATE.goal_points_norm = []
973
+ GLOBAL_STATE.goal_confirmed_points_norm = []
974
+ GLOBAL_STATE.goal_prev_confirmed_points_norm = []
975
+ GLOBAL_STATE.goal_status_text = "Goal crossbar inactive."
976
+ GLOBAL_STATE.goal_dragging_idx = None
977
  GLOBAL_STATE.yolo_ball_centers = {}
978
  GLOBAL_STATE.yolo_ball_boxes = {}
979
  GLOBAL_STATE.yolo_ball_conf = {}
 
1375
  base_np = focus_alpha * orig_np + (1.0 - focus_alpha) * base_np
1376
  out_img = Image.fromarray(np.clip(base_np * 255.0, 0, 255).astype(np.uint8))
1377
 
1378
+ _draw_goal_overlay(state, frame_idx, out_img)
1379
+
1380
  # Draw crosses for conditioning frames only (frames with recorded clicks)
1381
  clicks_map = state.clicks_by_frame_obj.get(frame_idx)
1382
  if state.show_click_marks and clicks_map:
 
2421
 
2422
  def _button_updates(state: AppState) -> tuple[Any, Any, Any]:
2423
  yolo_ready = isinstance(state, AppState) and state.yolo_kick_frame is not None
2424
+ propagate_main_enabled = _ball_has_masks(state) or yolo_ready
2425
  detect_player_enabled = yolo_ready
2426
  propagate_player_enabled = _player_has_masks(state)
2427
  sam_tracked = isinstance(state, AppState) and getattr(state, "is_sam_tracked", False)
 
2450
 
2451
 
2452
  def _ui_status_updates(state: AppState) -> tuple[Any, ...]:
2453
+ return _kick_button_updates(state) + _ball_button_updates(state) + _goal_button_updates(state)
2454
 
2455
 
2456
  def _recompute_motion_metrics(state: AppState, target_obj_id: int = 1):
 
2867
  img: Image.Image | np.ndarray,
2868
  state: AppState,
2869
  frame_idx: int,
2870
+ obj_id: int,
2871
  label: str,
2872
  clear_old: bool,
2873
  evt: gr.SelectData,
2874
  ):
2875
+ frame_idx = int(frame_idx)
2876
+ handled_preview = None
2877
+ handled = False
2878
+ if state is not None and state.goal_mode != GOAL_MODE_IDLE:
2879
+ handled_preview, handled = _goal_process_preview_click(state, frame_idx, evt)
2880
+ if handled and handled_preview is not None:
2881
+ preview_img = handled_preview
2882
+ else:
2883
+ preview_img = on_image_click(img, state, frame_idx, obj_id, label, clear_old, evt)
2884
  propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state)
2885
+ status_updates = _ui_status_updates(state)
2886
+ return (
2887
+ preview_img,
2888
+ propagate_main_update,
2889
+ detect_btn_update,
2890
+ propagate_player_update,
2891
+ *status_updates,
2892
+ )
2893
 
2894
 
2895
  @spaces.GPU()
 
3440
  "After a player mask exists, SAM2 propagates it within the same kick-centric window. This keeps athlete and ball masks "
3441
  "time-synced, enabling combined overlays, exports, and analytics comparing foot position to ball contact."
3442
  ),
3443
+ "btn-goal-start": (
3444
+ "Enters goal mapping mode so you can click the two crossbar corners. After the first click a handle appears; the second "
3445
+ "click closes the bar and exposes draggable anchors before you confirm."
3446
+ ),
3447
+ "btn-goal-confirm": (
3448
+ "Locks the currently placed crossbar across the entire video. The line and handles stay visible on every frame and can "
3449
+ "be re-edited later by tapping Map Goal again."
3450
  ),
3451
+ "btn-goal-clear": (
3452
+ "Removes the current crossbar (and any in-progress points) so you can restart the goal alignment workflow from scratch."
3453
+ ),
3454
+ "btn-goal-back": (
3455
+ "Restores the previously confirmed crossbar if the latest edits missed the mark. Useful when you want to compare two "
3456
+ "placements without re-clicking both corners."
3457
  ),
3458
  }
3459
 
3460
+
3461
+ def _build_tooltip_script() -> str:
3462
+ data = json.dumps(BUTTON_TOOLTIPS)
3463
+ return f"""
3464
+ <script>
3465
+ const KT_TOOLTIPS = {data};
3466
+ function applyKTTitles() {{
3467
+ Object.entries(KT_TOOLTIPS).forEach(([id, text]) => {{
3468
+ const el = document.getElementById(id);
3469
+ if (el && !el.dataset.ktTooltip) {{
3470
+ el.dataset.ktTooltip = "1";
3471
+ el.setAttribute("title", text);
3472
+ }}
3473
+ }});
3474
+ }}
3475
+ const observer = new MutationObserver(() => applyKTTitles());
3476
+ observer.observe(document.body, {{ childList: true, subtree: true }});
3477
+ document.addEventListener("DOMContentLoaded", applyKTTitles);
3478
+ applyKTTitles();
3479
+ </script>
3480
+ """
3481
+
3482
+
3483
+ TOOLTIP_SCRIPT = _build_tooltip_script()
3484
+
3485
+ with gr.Blocks(title="SAM2 Video (Transformers) - Interactive Segmentation", theme=theme, css=CUSTOM_CSS) as demo:
3486
  GLOBAL_STATE = gr.State(AppState())
3487
 
3488
  gr.Markdown(
 
3530
  )
3531
  ckpt_progress = gr.Markdown(visible=False)
3532
  load_status = gr.Markdown(visible=True)
3533
+ reset_btn = gr.Button("Reset Session", variant="secondary", elem_id="btn-reset-session")
 
 
 
 
 
 
 
 
 
 
3534
  with gr.Column(scale=1):
3535
  gr.Markdown("**Preview**")
3536
  preview = gr.Image(
 
3558
  with gr.Row(elem_classes=["model-status"]):
3559
  manual_kick_btn = gr.Button("⚽: N/A", interactive=False)
3560
  manual_impact_btn = gr.Button("🚩: N/A", interactive=False)
3561
+ with gr.Row(elem_classes=["model-actions"]):
3562
+ goal_start_btn = gr.Button(
3563
+ "Map Goal",
3564
+ variant="secondary",
3565
+ elem_id="btn-goal-start",
3566
+ )
3567
+ goal_confirm_btn = gr.Button(
3568
+ "Confirm",
3569
+ variant="primary",
3570
+ interactive=False,
3571
+ elem_id="btn-goal-confirm",
3572
+ )
3573
+ goal_clear_btn = gr.Button(
3574
+ "Clear",
3575
+ variant="secondary",
3576
+ interactive=False,
3577
+ elem_id="btn-goal-clear",
3578
  )
3579
+ goal_back_btn = gr.Button(
3580
+ "Back",
3581
+ variant="secondary",
3582
+ interactive=False,
3583
+ elem_id="btn-goal-back",
 
 
 
 
 
3584
  )
3585
+ goal_status = gr.Markdown("Goal crossbar inactive.", elem_id="goal-status-text")
3586
  with gr.Column(elem_classes=["model-section"]):
3587
  with gr.Row(elem_classes=["model-row"]):
3588
  gr.Markdown("YOLO13", elem_classes=["model-label"])
 
3599
  yolo_kick_btn = gr.Button("⚽: N/A", interactive=False)
3600
  yolo_impact_btn = gr.Button("🚩: N/A", interactive=False)
3601
  yolo_plot = gr.Plot(label="YOLO kick diagnostics", show_label=True)
 
 
 
 
 
 
 
 
 
 
 
 
3602
  with gr.Column(elem_classes=["model-section"]):
3603
  with gr.Row(elem_classes=["model-row"]):
3604
  gr.Markdown("SAM2", elem_classes=["model-label"])
 
3616
  sam_kick_btn = gr.Button("⚽: N/A", interactive=False)
3617
  sam_impact_btn = gr.Button("🚩: N/A", interactive=False)
3618
  kick_plot = gr.Plot(label="Kick & impact diagnostics", show_label=True)
3619
+ gr.HTML(value=TOOLTIP_SCRIPT, visible=False)
 
 
 
 
 
 
 
 
 
 
3620
  with gr.Row():
3621
  min_impact_speed_slider = gr.Slider(
3622
  label="Min impact speed (km/h)",
 
3637
  ball_status = gr.Markdown(visible=False)
3638
  propagate_status = gr.Markdown(visible=True)
3639
  impact_status = gr.Markdown("Impact frame: not computed", visible=False)
3640
+ with gr.Row():
3641
+ obj_id_inp = gr.Number(value=1, precision=0, label="Object ID", scale=0)
3642
+ label_radio = gr.Radio(choices=["positive", "negative"], value="positive", label="Point label")
3643
+ clear_old_chk = gr.Checkbox(value=False, label="Clear old inputs for this object")
3644
+ prompt_type = gr.Radio(choices=["Points", "Boxes"], value="Points", label="Prompt type")
3645
  # Wire events
3646
  def _on_video_change(GLOBAL_STATE: gr.State, video):
3647
  GLOBAL_STATE, min_idx, max_idx, first_frame, status = init_video_session(GLOBAL_STATE, video)
 
3659
  propagate_main_update,
3660
  detect_btn_update,
3661
  propagate_player_update,
 
 
 
 
3662
  )
3663
 
3664
  video_in.change(
 
3680
  manual_impact_btn,
3681
  detect_ball_btn,
3682
  track_ball_yolo_btn,
3683
+ goal_start_btn,
3684
+ goal_confirm_btn,
3685
+ goal_clear_btn,
3686
+ goal_back_btn,
3687
+ goal_status,
3688
  propagate_btn,
3689
  detect_player_btn,
3690
  propagate_player_btn,
 
 
 
 
3691
  ],
3692
  show_progress=True,
3693
  )
 
3717
  manual_impact_btn,
3718
  detect_ball_btn,
3719
  track_ball_yolo_btn,
3720
+ goal_start_btn,
3721
+ goal_confirm_btn,
3722
+ goal_clear_btn,
3723
+ goal_back_btn,
3724
+ goal_status,
3725
  propagate_btn,
3726
  detect_player_btn,
3727
  propagate_player_btn,
 
 
 
 
3728
  ],
3729
  label="Examples",
3730
  cache_examples=False,
 
4016
  manual_impact_btn,
4017
  detect_ball_btn,
4018
  track_ball_yolo_btn,
4019
+ goal_start_btn,
4020
+ goal_confirm_btn,
4021
+ goal_clear_btn,
4022
+ goal_back_btn,
4023
+ goal_status,
4024
  ],
4025
  )
4026
  mark_impact_btn.click(
 
4042
  manual_impact_btn,
4043
  detect_ball_btn,
4044
  track_ball_yolo_btn,
4045
+ goal_start_btn,
4046
+ goal_confirm_btn,
4047
+ goal_clear_btn,
4048
+ goal_back_btn,
4049
+ goal_status,
4050
  ],
4051
  )
4052
 
4053
+ def _sync_obj_id(s: AppState, oid):
4054
+ if s is not None and oid is not None:
4055
+ s.current_obj_id = int(oid)
4056
  return gr.update()
4057
 
4058
+ obj_id_inp.change(_sync_obj_id, inputs=[GLOBAL_STATE, obj_id_inp], outputs=[])
4059
 
4060
  def _sync_label(s: AppState, lab: str):
4061
  if s is not None and lab is not None:
 
4124
 
4125
  def _auto_detect_ball(
4126
  state_in: AppState,
4127
+ obj_id,
4128
  label_value: str,
4129
  clear_old_value: bool,
4130
  ):
 
4156
  frame_width, frame_height = frame.size
4157
  x_center = max(0, min(frame_width - 1, int(x_center)))
4158
  y_center = max(0, min(frame_height - 1, int(y_center)))
4159
+ obj_id_int = int(obj_id) if obj_id is not None else state_in.current_obj_id
 
4160
  label_str = label_value if label_value else state_in.current_label
4161
  clear_old_flag = bool(clear_old_value)
4162
 
 
4195
 
4196
  detect_ball_btn.click(
4197
  _auto_detect_ball,
4198
+ inputs=[GLOBAL_STATE, obj_id_inp, label_radio, clear_old_chk],
4199
  outputs=[
4200
  preview,
4201
  ball_status,
 
4212
  manual_impact_btn,
4213
  detect_ball_btn,
4214
  track_ball_yolo_btn,
4215
+ goal_start_btn,
4216
+ goal_confirm_btn,
4217
+ goal_clear_btn,
4218
+ goal_back_btn,
4219
+ goal_status,
4220
  ],
4221
  )
4222
 
 
4275
  manual_impact_btn,
4276
  detect_ball_btn,
4277
  track_ball_yolo_btn,
4278
+ goal_start_btn,
4279
+ goal_confirm_btn,
4280
+ goal_clear_btn,
4281
+ goal_back_btn,
4282
+ goal_status,
4283
  ],
4284
  )
4285
 
 
4320
  def _result(preview_img, status_text):
4321
  propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state_in)
4322
  status_updates = _ui_status_updates(state_in)
 
 
 
 
4323
  return (
4324
  preview_img,
4325
  gr.update(value=status_text, visible=True),
 
4328
  propagate_main_update,
4329
  detect_btn_update,
4330
  propagate_player_update,
4331
+ gr.update(),
4332
  _impact_status_update(state_in),
4333
  *status_updates,
4334
  )
 
4401
  status_text = (
4402
  f"{_format_kick_status(state_in)} | ✅ Player auto-detected on frame {frame_idx} (conf={conf:.2f})"
4403
  )
4404
+ return _result(update_frame_display(state_in, frame_idx), status_text)
 
 
 
4405
 
4406
  detect_player_btn.click(
4407
  _auto_detect_player,
 
4414
  propagate_btn,
4415
  detect_player_btn,
4416
  propagate_player_btn,
4417
+ obj_id_inp,
4418
  impact_status,
4419
  yolo_kick_btn,
4420
  yolo_impact_btn,
 
4424
  manual_impact_btn,
4425
  detect_ball_btn,
4426
  track_ball_yolo_btn,
4427
+ goal_start_btn,
4428
+ goal_confirm_btn,
4429
+ goal_clear_btn,
4430
+ goal_back_btn,
4431
+ goal_status,
4432
  ],
4433
  )
4434
 
 
4599
  manual_impact_btn,
4600
  detect_ball_btn,
4601
  track_ball_yolo_btn,
4602
+ goal_start_btn,
4603
+ goal_confirm_btn,
4604
+ goal_clear_btn,
4605
+ goal_back_btn,
4606
+ goal_status,
4607
  ],
4608
  )
4609
 
4610
  # Image click to add a point and run forward on that frame
4611
  preview.select(
4612
  _on_image_click_with_updates,
4613
+ [preview, GLOBAL_STATE, frame_slider, obj_id_inp, label_radio, clear_old_chk],
4614
+ [
4615
+ preview,
4616
+ propagate_btn,
4617
+ detect_player_btn,
4618
+ propagate_player_btn,
4619
+ yolo_kick_btn,
4620
+ yolo_impact_btn,
4621
+ sam_kick_btn,
4622
+ sam_impact_btn,
4623
+ manual_kick_btn,
4624
+ manual_impact_btn,
4625
+ detect_ball_btn,
4626
+ track_ball_yolo_btn,
4627
+ goal_start_btn,
4628
+ goal_confirm_btn,
4629
+ goal_clear_btn,
4630
+ goal_back_btn,
4631
+ goal_status,
4632
+ ],
4633
+ )
4634
+
4635
+ goal_start_btn.click(
4636
+ _goal_start_mapping,
4637
+ inputs=[GLOBAL_STATE],
4638
+ outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
4639
+ )
4640
+
4641
+ goal_confirm_btn.click(
4642
+ _goal_confirm_mapping,
4643
+ inputs=[GLOBAL_STATE],
4644
+ outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
4645
+ )
4646
+
4647
+ goal_clear_btn.click(
4648
+ _goal_clear_mapping,
4649
+ inputs=[GLOBAL_STATE],
4650
+ outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
4651
+ )
4652
+
4653
+ goal_back_btn.click(
4654
+ _goal_back_mapping,
4655
+ inputs=[GLOBAL_STATE],
4656
+ outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
4657
  )
4658
 
4659
  # Playback via MP4 rendering only
 
4734
  manual_impact_btn,
4735
  detect_ball_btn,
4736
  track_ball_yolo_btn,
4737
+ goal_start_btn,
4738
+ goal_confirm_btn,
4739
+ goal_clear_btn,
4740
+ goal_back_btn,
4741
+ goal_status,
4742
  ],
4743
  )
4744
 
 
4766
  manual_impact_btn,
4767
  detect_ball_btn,
4768
  track_ball_yolo_btn,
4769
+ goal_start_btn,
4770
+ goal_confirm_btn,
4771
+ goal_clear_btn,
4772
+ goal_back_btn,
4773
+ goal_status,
4774
  ],
4775
  )
4776
 
tooltips.js DELETED
@@ -1,38 +0,0 @@
1
- (() => {
2
- function getTooltipMap() {
3
- const el = document.getElementById("kt-tooltips-data");
4
- if (!el) {
5
- return {};
6
- }
7
- try {
8
- return JSON.parse(el.getAttribute("data-tooltips") || "{}");
9
- } catch (err) {
10
- console.warn("KickTrimmer tooltip JSON parse error", err);
11
- return {};
12
- }
13
- }
14
-
15
- function applyTooltips() {
16
- const map = getTooltipMap();
17
- Object.entries(map).forEach(([id, text]) => {
18
- const target = document.getElementById(id);
19
- if (target && !target.dataset.ktTooltip) {
20
- target.dataset.ktTooltip = "1";
21
- target.setAttribute("title", text);
22
- }
23
- });
24
- }
25
-
26
- function initObserver() {
27
- applyTooltips();
28
- const observer = new MutationObserver(() => applyTooltips());
29
- observer.observe(document.body, { childList: true, subtree: true });
30
- }
31
-
32
- if (document.readyState === "loading") {
33
- document.addEventListener("DOMContentLoaded", initObserver);
34
- } else {
35
- initObserver();
36
- }
37
- })();
38
-